Merge branch 'master' into mod/master

Resolve single conflict in SleepActivity.cpp: adopt upstream millis()
timestamp log format while preserving mod's edgeCachePath argument to
renderBitmapSleepScreen().

Upstream changes (14 commits): unified navigation handling, Italian
hyphenation, natural file sort, auto WiFi reconnect, power saving on
idle, OPDS fixes, uniform debug logging, and more.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-12 11:54:40 -05:00
47 changed files with 6007 additions and 429 deletions

View File

@@ -230,6 +230,7 @@ Accessible by pressing **Confirm** while inside a book.
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
* **Images:** Embedded images in e-books will not render. * **Images:** Embedded images in e-books will not render.
* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up.
--- ---
@@ -242,3 +243,5 @@ pio device monitor
``` ```
If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen. If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen.
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.bin`, `state.bin`, or `epub_*` cache directories in the `.crosspoint/` folder).

View File

@@ -13,7 +13,9 @@ fi
# --modified: files tracked by git that have been modified (staged or unstaged) # --modified: files tracked by git that have been modified (staged or unstaged)
# --exclude-standard: ignores files in .gitignore # --exclude-standard: ignores files in .gitignore
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated. # Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \ git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
| grep -E '\.(c|cpp|h|hpp)$' \ | grep -E '\.(c|cpp|h|hpp)$' \
| grep -v -E '^lib/EpdFont/builtinFonts/' \ | grep -v -E '^lib/EpdFont/builtinFonts/' \
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
| xargs -r clang-format -style=file -i | xargs -r clang-format -style=file -i

View File

@@ -8,6 +8,7 @@
#include "generated/hyph-en.trie.h" #include "generated/hyph-en.trie.h"
#include "generated/hyph-es.trie.h" #include "generated/hyph-es.trie.h"
#include "generated/hyph-fr.trie.h" #include "generated/hyph-fr.trie.h"
#include "generated/hyph-it.trie.h"
#include "generated/hyph-ru.trie.h" #include "generated/hyph-ru.trie.h"
namespace { namespace {
@@ -18,15 +19,17 @@ LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic); LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
using EntryArray = std::array<LanguageEntry, 5>; using EntryArray = std::array<LanguageEntry, 6>;
const EntryArray& entries() { const EntryArray& entries() {
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator}, static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
{"french", "fr", &frenchHyphenator}, {"french", "fr", &frenchHyphenator},
{"german", "de", &germanHyphenator}, {"german", "de", &germanHyphenator},
{"russian", "ru", &russianHyphenator}, {"russian", "ru", &russianHyphenator},
{"spanish", "es", &spanishHyphenator}}}; {"spanish", "es", &spanishHyphenator},
{"italian", "it", &italianHyphenator}}};
return kEntries; return kEntries;
} }

View File

@@ -0,0 +1,113 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "../SerializedHyphenationTrie.h"
// Auto-generated by generate_hyphenation_trie.py. Do not edit manually.
alignas(4) constexpr uint8_t it_trie_data[] = {
0x00, 0x00, 0x05, 0xC4, 0x17, 0x0C, 0x33, 0x35, 0x0C, 0x29, 0x22, 0x0D, 0x3E, 0x0B, 0x47, 0x20,
0x0D, 0x16, 0x0B, 0x34, 0x0D, 0x21, 0x0C, 0x3D, 0x1F, 0x0C, 0x2A, 0x17, 0x2A, 0x0B, 0x02, 0x0C,
0x01, 0x02, 0x16, 0x02, 0x0D, 0x0C, 0x0C, 0x0D, 0x03, 0x0C, 0x01, 0x0C, 0x0E, 0x0D, 0x04, 0x02,
0x0B, 0xA0, 0x00, 0x42, 0x21, 0x6E, 0xFD, 0xA0, 0x00, 0x72, 0x21, 0x6E, 0xFD, 0xA1, 0x00, 0x61,
0x6D, 0xFD, 0x21, 0x69, 0xFB, 0x21, 0x74, 0xFD, 0x22, 0x70, 0x6E, 0xEC, 0xFD, 0xA0, 0x00, 0x91,
0x21, 0x6F, 0xFD, 0x21, 0x69, 0xFD, 0xA0, 0x00, 0xA2, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0xA0,
0x00, 0xC2, 0x21, 0x6D, 0xFD, 0x21, 0x75, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0xA0, 0x00,
0xE1, 0x21, 0x6F, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x74, 0xFD, 0x21, 0x6E, 0xFD, 0xA3, 0x01, 0x11,
0x61, 0x69, 0x6F, 0xDF, 0xEE, 0xFD, 0xA0, 0x00, 0xF2, 0x21, 0x65, 0xFD, 0x21, 0x6E, 0xFD, 0x21,
0x69, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0xA1, 0x01, 0x11, 0x69, 0xFD, 0xA0, 0x01, 0x12,
0x21, 0x75, 0xFD, 0x21, 0x65, 0xFD, 0x21, 0x78, 0xFD, 0xA0, 0x01, 0x32, 0x21, 0x6B, 0xFD, 0x21,
0x6E, 0xFD, 0xA0, 0x00, 0x71, 0x21, 0x65, 0xFD, 0x22, 0x61, 0x65, 0xF7, 0xFD, 0x21, 0x72, 0xFB,
0xA0, 0x01, 0x52, 0x21, 0x61, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0x21, 0x69, 0xFD, 0xA0,
0x01, 0x71, 0x21, 0x6F, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0xA0, 0x00,
0x61, 0x21, 0x6F, 0xFD, 0x21, 0x74, 0xFD, 0x41, 0x70, 0xFF, 0x50, 0x21, 0x6F, 0xFC, 0x21, 0x74,
0xFD, 0x22, 0x70, 0x72, 0xF3, 0xFD, 0x21, 0x61, 0xE8, 0x21, 0x72, 0xFD, 0xA0, 0x00, 0xF1, 0x22,
0x6C, 0x72, 0xFD, 0xFD, 0x21, 0x69, 0xE3, 0x21, 0x6C, 0xFD, 0x41, 0x65, 0xFF, 0x43, 0xA0, 0x01,
0x11, 0x25, 0x61, 0x68, 0x6F, 0x72, 0x73, 0xE8, 0xEE, 0xF6, 0xF9, 0xFD, 0xA0, 0x01, 0x82, 0x21,
0x72, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x69, 0xFD, 0x21, 0x65, 0xFD, 0xA0, 0x01,
0xA2, 0x21, 0x65, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0x41, 0x75, 0xFF, 0x4C, 0x42, 0x6C,
0x72, 0xFF, 0xFC, 0xFF, 0x48, 0x21, 0x62, 0xF9, 0x22, 0x68, 0x75, 0xEF, 0xFD, 0x47, 0x63, 0x64,
0x6C, 0x6E, 0x70, 0x72, 0x74, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x5C, 0x21, 0x73, 0xEA, 0x21, 0x6E, 0xFD, 0x21, 0x61, 0xFD, 0xA1, 0x01, 0x11, 0x72,
0xFD, 0x41, 0x6E, 0xFF, 0x15, 0x21, 0x67, 0xFC, 0xA0, 0x01, 0xC2, 0x21, 0x74, 0xFD, 0x21, 0x6C,
0xFD, 0x22, 0x61, 0x65, 0xF4, 0xFD, 0x52, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x6C, 0x6E, 0x6F,
0x70, 0x72, 0x73, 0x74, 0x77, 0x68, 0x6A, 0x6B, 0x7A, 0xFE, 0xC2, 0xFE, 0xCD, 0xFE, 0xF7, 0xFF,
0x12, 0xFF, 0x20, 0xFF, 0x37, 0xFF, 0x46, 0xFF, 0x55, 0xFF, 0x6B, 0xFF, 0x8B, 0xFF, 0xA5, 0xFF,
0xC2, 0xFF, 0xE6, 0xFF, 0xFB, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xA0, 0x01, 0xE2,
0xA0, 0x00, 0xD1, 0x24, 0x61, 0x65, 0x6F, 0x75, 0xFD, 0xFD, 0xFD, 0xFD, 0x21, 0x6F, 0xF4, 0x21,
0x61, 0xF1, 0xA0, 0x01, 0xE1, 0x21, 0x2E, 0xFD, 0x24, 0x69, 0x75, 0x79, 0x74, 0xEB, 0xF4, 0xF7,
0xFD, 0x21, 0x75, 0xDF, 0xA0, 0x00, 0x51, 0x22, 0x69, 0x77, 0xFA, 0xFD, 0x21, 0x69, 0xD7, 0xAE,
0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0x6C, 0x72, 0x2E, 0x27,
0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xF5, 0xF5, 0xE3, 0xE3, 0x22, 0x2E,
0x27, 0xC4, 0xC7, 0xC6, 0x00, 0x51, 0x68, 0x2E, 0x27, 0x62, 0x72, 0x6E, 0xFF, 0xBF, 0xFF, 0xBF,
0xFF, 0xFB, 0xFF, 0xBF, 0xFE, 0xFB, 0xFF, 0xBF, 0xD0, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6B,
0x6D, 0x6E, 0x71, 0x73, 0x74, 0x7A, 0x68, 0x6C, 0x72, 0x2E, 0x27, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xEB, 0xFF, 0xBC, 0xFF, 0xBC, 0xFF, 0xAA, 0xFF, 0xAA, 0xCE, 0x02, 0x01, 0x62, 0x64,
0x67, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x89, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xCA, 0x02, 0x01, 0x62, 0x67, 0x66, 0x6E, 0x6C,
0x72, 0x73, 0x74, 0x2E, 0x27, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xA0, 0x02, 0x12, 0xA1, 0x00, 0x51, 0x74,
0xFD, 0xD1, 0x02, 0x01, 0x62, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74,
0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0xFB, 0xFF,
0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF,
0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0x41, 0x70, 0xFD, 0x4D, 0xCB, 0x02, 0x01, 0x62, 0x64,
0x68, 0x69, 0x6C, 0x6D, 0x6E, 0x72, 0x76, 0x2E, 0x27, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFF,
0xFC, 0xFE, 0xF9, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xC2,
0x02, 0x01, 0x2E, 0x27, 0xFE, 0xC3, 0xFE, 0xC3, 0xCB, 0x02, 0x01, 0x67, 0x66, 0x68, 0x6B, 0x6C,
0x6D, 0x72, 0x73, 0x74, 0x2E, 0x27, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xCC,
0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xA0, 0x02, 0x33, 0x42,
0x2E, 0x27, 0xFE, 0x93, 0xFE, 0x93, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6A,
0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF6, 0xFE, 0x8C, 0xFE, 0x9E, 0xFE, 0x9E, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF9, 0xCF, 0x02, 0x01, 0x62, 0x63, 0x66, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xA0, 0x02, 0x62, 0xA1, 0x01, 0xE1, 0x6E, 0xFD,
0x21, 0x72, 0xF8, 0x21, 0x65, 0xFD, 0xA1, 0x01, 0xE1, 0x66, 0xFD, 0x41, 0x74, 0xFE, 0x07, 0x21,
0x69, 0xFC, 0x21, 0x65, 0xFD, 0xD3, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6B, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x7A, 0x68, 0x2E, 0x27, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xE6, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xF1, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFF, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xA0, 0x02, 0x82, 0xA1, 0x01, 0xE1, 0x65, 0xFD, 0x21, 0x63, 0xF8, 0xA1, 0x01, 0xE1, 0x69,
0xFD, 0xCB, 0x02, 0x01, 0x64, 0x68, 0x6C, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x7A, 0x2E, 0x27, 0xFD,
0xB1, 0xFD, 0xC3, 0xFD, 0xC3, 0xFF, 0xF3, 0xFD, 0xB1, 0xFD, 0xC3, 0xFF, 0xFB, 0xFD, 0xB1, 0xFD,
0xB1, 0xFD, 0xB1, 0xFD, 0xB1, 0xC3, 0x02, 0x01, 0x71, 0x2E, 0x27, 0xFD, 0x8D, 0xFD, 0x8D, 0xFD,
0x8D, 0xA0, 0x02, 0x53, 0xA1, 0x01, 0xE1, 0x73, 0xFD, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66,
0x68, 0x67, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x78, 0x77, 0x7A, 0x2E,
0x27, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x8B, 0xFD, 0x79, 0xFD, 0x79, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFF, 0xFB, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0x43, 0x6D, 0x2E, 0x27, 0xFD,
0x37, 0xFD, 0x37, 0xFD, 0x37, 0xA0, 0x02, 0xC2, 0xA1, 0x02, 0x32, 0x6D, 0xFD, 0x41, 0x6E, 0xFE,
0x8F, 0x4B, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xA0, 0x02, 0xE1, 0x22, 0x2E, 0x27, 0xFD, 0xFD, 0xC7, 0x02, 0xA2, 0x68, 0x73,
0x70, 0x74, 0x7A, 0x2E, 0x27, 0xFF, 0xC0, 0xFF, 0xCD, 0xFF, 0xD2, 0xFF, 0xD6, 0xFC, 0xF7, 0xFF,
0xF8, 0xFF, 0xFB, 0xC1, 0x00, 0x51, 0x2E, 0xFC, 0xDF, 0x41, 0x68, 0xFF, 0x18, 0xA1, 0x00, 0x51,
0x63, 0xFC, 0xC1, 0x01, 0xE1, 0x73, 0xFE, 0xB6, 0xC2, 0x00, 0x51, 0x6B, 0x73, 0xFC, 0xCA, 0xFC,
0x06, 0xD2, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73,
0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1,
0xFF, 0xE2, 0xFC, 0xD3, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xD3, 0xFF, 0xEC, 0xFF, 0xF1,
0xFC, 0xC1, 0xFC, 0xC1, 0xFF, 0xF7, 0xFC, 0xC1, 0xFE, 0x2E, 0xC6, 0x02, 0x01, 0x63, 0x6C, 0x72,
0x76, 0x2E, 0x27, 0xFC, 0x88, 0xFC, 0x9A, 0xFC, 0x9A, 0xFC, 0x88, 0xFC, 0x88, 0xFD, 0xF5, 0x41,
0x72, 0xFB, 0xAF, 0xA0, 0x02, 0xF2, 0xC5, 0x02, 0x01, 0x68, 0x61, 0x79, 0x2E, 0x27, 0xFC, 0x7E,
0xFF, 0xF9, 0xFF, 0xFD, 0xFC, 0x6C, 0xFC, 0x6C, 0xCA, 0x02, 0x01, 0x62, 0x63, 0x66, 0x68, 0x6D,
0x70, 0x74, 0x77, 0x2E, 0x27, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC,
0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0x42, 0x6F, 0x69, 0xFC, 0x48, 0xFC, 0x27,
0xCB, 0x02, 0x01, 0x62, 0x64, 0x6C, 0x6E, 0x70, 0x74, 0x73, 0x76, 0x7A, 0x2E, 0x27, 0xFC, 0x32,
0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32,
0xFC, 0x32, 0xFD, 0x9F, 0x5A, 0x2E, 0x27, 0x61, 0x65, 0x6F, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68,
0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x78, 0x79, 0x7A, 0xFB,
0xC2, 0xFB, 0xF9, 0xFC, 0x14, 0xFC, 0x23, 0xFC, 0x28, 0xFC, 0x2B, 0xFC, 0x64, 0xFC, 0x97, 0xFC,
0xC4, 0xFC, 0xED, 0xFD, 0x27, 0xFD, 0x4B, 0xFD, 0x54, 0xFD, 0x82, 0xFD, 0xC4, 0xFE, 0x11, 0xFE,
0x5D, 0xFE, 0x81, 0xFE, 0x95, 0xFF, 0x17, 0xFF, 0x4D, 0xFF, 0x86, 0xFF, 0xA2, 0xFF, 0xB4, 0xFF,
0xD5, 0xFF, 0xDC,
};
constexpr SerializedHyphenationPatterns it_patterns = {
it_trie_data,
sizeof(it_trie_data),
};

View File

@@ -11,7 +11,7 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);

View File

@@ -1,32 +1,46 @@
#!/usr/bin/env python3
"""
ESP32 Serial Monitor with Memory Graph
This script provides a real-time serial monitor for ESP32 devices with
integrated memory usage graphing capabilities. It reads serial output,
parses memory information, and displays it in both console and graphical form.
"""
import sys import sys
import argparse import argparse
import re import re
import threading import threading
from datetime import datetime from datetime import datetime
from collections import deque from collections import deque
import time
# Try to import potentially missing packages # Try to import potentially missing packages
PACKAGE_MAPPING: dict[str, str] = {
"serial": "pyserial",
"colorama": "colorama",
"matplotlib": "matplotlib",
}
try: try:
import serial import serial
from colorama import init, Fore, Style from colorama import init, Fore, Style
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.animation as animation from matplotlib import animation
except ImportError as e: except ImportError as e:
missing_package = e.name ERROR_MSG = str(e).lower()
missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG]
if not missing_packages:
# Fallback if mapping doesn't cover
missing_packages = ["pyserial", "colorama", "matplotlib"]
print("\n" + "!" * 50) print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.") print(f" Error: Required package(s) not installed: {', '.join(missing_packages)}")
print("!" * 50) print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n") print("\nTo fix this, please run the following command in your terminal:\n")
INSTALL_CMD = "pip install " if sys.platform.startswith("win") else "pip3 install "
install_cmd = "pip install " print(f" {INSTALL_CMD}{' '.join(missing_packages)}")
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...") print("\nExiting...")
sys.exit(1) sys.exit(1)
@@ -34,50 +48,92 @@ except ImportError as e:
# --- Global Variables for Data Sharing --- # --- Global Variables for Data Sharing ---
# Store last 50 data points # Store last 50 data points
MAX_POINTS = 50 MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS) time_data: deque[str] = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS) free_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS) total_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
# Initialize colors # Initialize colors
init(autoreset=True) init(autoreset=True)
def get_color_for_line(line): # Color mapping for log lines
COLOR_KEYWORDS: dict[str, list[str]] = {
Fore.RED: ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"],
Fore.CYAN: ["[MEM]", "FREE:"],
Fore.MAGENTA: [
"[GFX]",
"[ERS]",
"DISPLAY",
"RAM WRITE",
"RAM COMPLETE",
"REFRESH",
"POWERING ON",
"FRAME BUFFER",
"LUT",
],
Fore.GREEN: [
"[EBP]",
"[BMC]",
"[ZIP]",
"[PARSER]",
"[EHP]",
"LOADING EPUB",
"CACHE",
"DECOMPRESSED",
"PARSING",
],
Fore.YELLOW: ["[ACT]", "ENTERING ACTIVITY", "EXITING ACTIVITY"],
Fore.BLUE: ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"],
Fore.LIGHTYELLOW_EX: [
"[CPS]",
"SETTINGS",
"[CLEAR_CACHE]",
"[CHAP]",
"[OPDS]",
"[COF]",
],
Fore.LIGHTBLACK_EX: [
"ESP-ROM",
"BUILD:",
"RST:",
"BOOT:",
"SPIWP:",
"MODE:",
"LOAD:",
"ENTRY",
"[SD]",
"STARTING CROSSPOINT",
"VERSION",
],
Fore.LIGHTCYAN_EX: ["[RBS]"],
Fore.LIGHTMAGENTA_EX: [
"[KRS]",
"EINKDISPLAY:",
"STATIC FRAME",
"INITIALIZING",
"SPI INITIALIZED",
"GPIO PINS",
"RESETTING",
"SSD1677",
"E-INK",
],
Fore.LIGHTGREEN_EX: ["[FNS]", "FOOTNOTE"],
}
# pylint: disable=R0912
def get_color_for_line(line: str) -> str:
""" """
Classify log lines by type and assign appropriate colors. Classify log lines by type and assign appropriate colors.
""" """
line_upper = line.upper() line_upper = line.upper()
for color, keywords in COLOR_KEYWORDS.items():
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]): if any(keyword in line_upper for keyword in keywords):
return Fore.RED return color
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 return Fore.WHITE
def parse_memory_line(line):
def parse_memory_line(line: str) -> tuple[int | None, int | None]:
""" """
Extracts Free and Total bytes from the specific log line. Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
@@ -93,12 +149,29 @@ def parse_memory_line(line):
return None, None return None, None
return None, None return None, None
def serial_worker(port, baud):
def serial_worker(port: str, baud: int, kwargs: dict[str, str]) -> None:
""" """
Runs in a background thread. Handles reading serial, printing to console, Runs in a background thread. Handles reading serial, printing to console,
and updating the data lists. and updating the data lists.
""" """
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}") print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
filter_keyword = kwargs.get("filter", "").lower()
suppress = kwargs.get("suppress", "").lower()
if filter_keyword and suppress and filter_keyword == suppress:
print(
f"{Fore.YELLOW}Warning: Filter and Suppress keywords are the same. "
f"This may result in no output.{Style.RESET_ALL}"
)
if filter_keyword:
print(
f"{Fore.YELLOW}Filtering lines to only show those containing: "
f"'{filter_keyword}'{Style.RESET_ALL}"
)
if suppress:
print(
f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}"
)
try: try:
ser = serial.Serial(port, baud, timeout=0.1) ser = serial.Serial(port, baud, timeout=0.1)
@@ -111,7 +184,7 @@ def serial_worker(port, baud):
try: try:
while True: while True:
try: try:
raw_data = ser.readline().decode('utf-8', errors='replace') raw_data = ser.readline().decode("utf-8", errors="replace")
if not raw_data: if not raw_data:
continue continue
@@ -127,33 +200,38 @@ def serial_worker(port, baud):
# Check for Memory Line # Check for Memory Line
if "[MEM]" in formatted_line: if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line) free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None: if free_val is not None and total_val is not None:
with data_lock: with data_lock:
time_data.append(pc_time) time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB total_mem_data.append(total_val / 1024) # Convert to KB
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console # Print to console
line_color = get_color_for_line(formatted_line) line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}") print(f"{line_color}{formatted_line}")
except OSError: except (OSError, UnicodeDecodeError):
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}") print(f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}")
break break
except Exception as e: except KeyboardInterrupt:
# If thread is killed violently (e.g. main exit), silence errors # If thread is killed violently (e.g. main exit), silence errors
pass pass
finally: finally:
if 'ser' in locals() and ser.is_open: if "ser" in locals() and ser.is_open:
ser.close() ser.close()
def update_graph(frame):
def update_graph(frame) -> list: # pylint: disable=unused-argument
""" """
Called by Matplotlib animation to redraw the chart. Called by Matplotlib animation to redraw the chart.
""" """
with data_lock: with data_lock:
if not time_data: if not time_data:
return return []
# Convert deques to lists for plotting # Convert deques to lists for plotting
x = list(time_data) x = list(time_data)
@@ -163,52 +241,105 @@ def update_graph(frame):
plt.cla() # Clear axis plt.cla() # Clear axis
# Plot Total RAM # Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--') plt.plot(x, y_total, label="Total RAM (KB)", color="red", linestyle="--")
# Plot Free RAM # Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o') plt.plot(x, y_free, label="Free RAM (KB)", color="green", marker="o")
# Fill area under Free RAM # Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1) plt.fill_between(x, y_free, color="green", alpha=0.1)
plt.title("ESP32 Memory Monitor") plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)") plt.ylabel("Memory (KB)")
plt.xlabel("Time") plt.xlabel("Time")
plt.legend(loc='upper left') plt.legend(loc="upper left")
plt.grid(True, linestyle=':', alpha=0.6) plt.grid(True, linestyle=":", alpha=0.6)
# Rotate date labels # Rotate date labels
plt.xticks(rotation=45, ha='right') plt.xticks(rotation=45, ha="right")
plt.tight_layout() plt.tight_layout()
def main(): return []
def main() -> None:
"""
Main entry point for the ESP32 monitor application.
Sets up argument parsing, starts serial monitoring thread, and initializes the memory graph.
"""
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph") parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port") if sys.platform.startswith("win"):
parser.add_argument("--baud", type=int, default=115200, help="Baud rate") default_port = "COM8"
elif sys.platform.startswith("darwin"):
default_port = "/dev/cu.usbmodem101"
else:
default_port = "/dev/ttyACM0"
default_baudrate = 115200
parser.add_argument(
"port",
nargs="?",
default=default_port,
help=f"Serial port (default: {default_port})",
)
parser.add_argument(
"--baud",
type=int,
default=default_baudrate,
help=f"Baud rate (default: {default_baudrate})",
)
parser.add_argument(
"--filter",
type=str,
default="",
help="Only display lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--suppress",
type=str,
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
args = parser.parse_args() args = parser.parse_args()
# 1. Start the Serial Reader in a separate thread # 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes # Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True) myargs = vars(args) # Convert Namespace to dict for easier passing
t = threading.Thread(
target=serial_worker, args=(args.port, args.baud, myargs), daemon=True
)
t.start() t.start()
# 2. Set up the Graph (Main Thread) # 2. Set up the Graph (Main Thread)
try: try:
plt.style.use('light_background') import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
except: default_styles = ("light_background", "ggplot", "seaborn", "dark_background", )
styles = list(mplstyle.available)
for default_style in default_styles:
if default_style in styles:
print(
f"\n{Fore.CYAN}--- Using Matplotlib style: {default_style} ---{Style.RESET_ALL}"
)
mplstyle.use(default_style)
break
except (AttributeError, ValueError):
pass pass
fig = plt.figure(figsize=(10, 6)) fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms # Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000) _ = animation.FuncAnimation(
fig, update_graph, interval=1000, cache_frame_data=False
)
try: try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}") print(
f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}"
)
plt.show() plt.show()
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}") print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows plt.close("all") # Force close any lingering plot windows
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -15,6 +15,7 @@ class MappedInputManager {
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {} explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
void update() const { gpio.update(); }
bool wasPressed(Button button) const; bool wasPressed(Button button) const;
bool wasReleased(Button button) const; bool wasReleased(Button button) const;
bool isPressed(Button button) const; bool isPressed(Button button) const;

View File

@@ -83,7 +83,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
lastBookFileName = path.substr(lastSlash + 1); lastBookFileName = path.substr(lastSlash + 1);
} }
Serial.printf("Loading recent book: %s\n", path.c_str()); Serial.printf("[%lu] [RBS] Loading recent book: %s\n", millis(), path.c_str());
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {

View File

@@ -9,7 +9,7 @@ WifiCredentialStore WifiCredentialStore::instance;
namespace { namespace {
// File format version // File format version
constexpr uint8_t WIFI_FILE_VERSION = 1; constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version
// WiFi credentials file path // WiFi credentials file path
constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
@@ -38,6 +38,7 @@ bool WifiCredentialStore::saveToFile() const {
// Write header // Write header
serialization::writePod(file, WIFI_FILE_VERSION); serialization::writePod(file, WIFI_FILE_VERSION);
serialization::writeString(file, lastConnectedSsid); // Save last connected SSID
serialization::writePod(file, static_cast<uint8_t>(credentials.size())); serialization::writePod(file, static_cast<uint8_t>(credentials.size()));
// Write each credential // Write each credential
@@ -67,12 +68,18 @@ bool WifiCredentialStore::loadFromFile() {
// Read and verify version // Read and verify version
uint8_t version; uint8_t version;
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != WIFI_FILE_VERSION) { if (version > WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version); Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version);
file.close(); file.close();
return false; return false;
} }
if (version >= 2) {
serialization::readString(file, lastConnectedSsid);
} else {
lastConnectedSsid.clear();
}
// Read credential count // Read credential count
uint8_t count; uint8_t count;
serialization::readPod(file, count); serialization::readPod(file, count);
@@ -128,6 +135,9 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
if (cred != credentials.end()) { if (cred != credentials.end()) {
credentials.erase(cred); credentials.erase(cred);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
if (ssid == lastConnectedSsid) {
clearLastConnectedSsid();
}
return saveToFile(); return saveToFile();
} }
return false; // Not found return false; // Not found
@@ -146,8 +156,25 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; } bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
if (lastConnectedSsid != ssid) {
lastConnectedSsid = ssid;
saveToFile();
}
}
const std::string& WifiCredentialStore::getLastConnectedSsid() const { return lastConnectedSsid; }
void WifiCredentialStore::clearLastConnectedSsid() {
if (!lastConnectedSsid.empty()) {
lastConnectedSsid.clear();
saveToFile();
}
}
void WifiCredentialStore::clearAll() { void WifiCredentialStore::clearAll() {
credentials.clear(); credentials.clear();
lastConnectedSsid.clear();
saveToFile(); saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis()); Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
} }

View File

@@ -16,6 +16,7 @@ class WifiCredentialStore {
private: private:
static WifiCredentialStore instance; static WifiCredentialStore instance;
std::vector<WifiCredential> credentials; std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
static constexpr size_t MAX_NETWORKS = 8; static constexpr size_t MAX_NETWORKS = 8;
@@ -48,6 +49,11 @@ class WifiCredentialStore {
// Check if a network is saved // Check if a network is saved
bool hasSavedCredential(const std::string& ssid) const; bool hasSavedCredential(const std::string& ssid) const;
// Last connected network
void setLastConnectedSsid(const std::string& ssid);
const std::string& getLastConnectedSsid() const;
void clearLastConnectedSsid();
// Clear all credentials // Clear all credentials
void clearAll(); void clearAll();
}; };

View File

@@ -622,12 +622,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle XTC file // Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) { if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC"); Serial.printf("[%lu] [SLP] Failed to load last XTC\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastXtc.generateCoverBmp()) { if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp"); Serial.printf("[%lu] [SLP] Failed to generate XTC cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -636,12 +636,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle TXT file - looks for cover image in the same folder // Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) { if (!lastTxt.load()) {
Serial.println("[SLP] Failed to load last TXT"); Serial.printf("[%lu] [SLP] Failed to load last TXT\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastTxt.generateCoverBmp()) { if (!lastTxt.generateCoverBmp()) {
Serial.println("[SLP] No cover image found for TXT file"); Serial.printf("[%lu] [SLP] No cover image found for TXT file\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -651,12 +651,12 @@ void SleepActivity::renderCoverSleepScreen() const {
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here // Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) { if (!lastEpub.load(true, true)) {
Serial.println("[SLP] Failed to load last epub"); Serial.printf("[%lu] [SLP] Failed to load last epub\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastEpub.generateCoverBmp(cropped)) { if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp"); Serial.printf("[%lu] [SLP] Failed to generate cover bmp\n", millis());
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -675,7 +675,7 @@ void SleepActivity::renderCoverSleepScreen() const {
if (Storage.openFileForRead("SLP", coverBmpPath, file)) { if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath.c_str()); Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap, edgeCachePath); renderBitmapSleepScreen(bitmap, edgeCachePath);
return; return;
} }

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();
} else {
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
} }
// Handle navigation
if (!entries.empty()) {
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;
@@ -62,4 +64,5 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void navigateToEntry(const OpdsEntry& entry); void navigateToEntry(const OpdsEntry& entry);
void navigateBack(); void navigateBack();
void downloadBook(const OpdsEntry& book); void downloadBook(const OpdsEntry& book);
bool preventAutoSleep() override { return true; }
}; };

View File

@@ -196,13 +196,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;
@@ -226,12 +231,6 @@ void HomeActivity::loop() {
} else if (menuSelectedIndex == settingsIdx) { } else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
} }
} }

View File

@@ -8,6 +8,7 @@
#include "../Activity.h" #include "../Activity.h"
#include "./MyLibraryActivity.h" #include "./MyLibraryActivity.h"
#include "util/ButtonNavigator.h"
struct RecentBook; struct RecentBook;
struct Rect; struct Rect;
@@ -15,6 +16,7 @@ struct Rect;
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 recentsLoading = false; bool recentsLoading = false;

View File

@@ -11,17 +11,58 @@
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true; // Directories first
if (str1.back() != '/' && str2.back() == '/') return false; bool isDir1 = str1.back() == '/';
return lexicographical_compare( bool isDir2 = str2.back() == '/';
begin(str1), end(str1), begin(str2), end(str2), if (isDir1 != isDir2) return isDir1;
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
}); });
} }
@@ -109,13 +150,6 @@ void MyLibraryActivity::loop() {
return; return;
} }
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -157,21 +191,26 @@ void MyLibraryActivity::loop() {
} }
int listSize = static_cast<int>(files.size()); int listSize = static_cast<int>(files.size());
if (upReleased) {
if (skipPage) { buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0); selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
updateRequired = true; updateRequired = true;
} else if (downReleased) { });
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1); buttonNavigator.onPreviousRelease([this, listSize] {
} else { selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
selectorIndex = (selectorIndex + 1) % listSize;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
} }
void MyLibraryActivity::displayTaskLoop() { void MyLibraryActivity::displayTaskLoop() {
@@ -207,7 +246,7 @@ void MyLibraryActivity::render() const {
} }
// Help text // Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); const auto labels = mappedInput.mapLabels(basepath == "/" ? "« Home" : "« Back", "Open", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

@@ -8,11 +8,14 @@
#include <vector> #include <vector>
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity { 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;
size_t selectorIndex = 0; size_t selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@@ -12,7 +12,6 @@
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
@@ -70,18 +69,11 @@ void RecentBooksActivity::onExit() {
} }
void RecentBooksActivity::loop() { void RecentBooksActivity::loop() {
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str()); Serial.printf("[%lu] [RBA] Selected recent book: %s\n", millis(), recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path); onSelectBook(recentBooks[selectorIndex].path);
return; return;
} }
@@ -92,21 +84,26 @@ void RecentBooksActivity::loop() {
} }
int listSize = static_cast<int>(recentBooks.size()); int listSize = static_cast<int>(recentBooks.size());
if (upReleased) {
if (skipPage) { buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0); selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
updateRequired = true; updateRequired = true;
} else if (downReleased) { });
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1); buttonNavigator.onPreviousRelease([this, listSize] {
} else { selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
selectorIndex = (selectorIndex + 1) % listSize;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
} }
void RecentBooksActivity::displayTaskLoop() { void RecentBooksActivity::displayTaskLoop() {

View File

@@ -9,11 +9,13 @@
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class RecentBooksActivity final : public Activity { class RecentBooksActivity final : public Activity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0; size_t selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@@ -348,6 +348,9 @@ void CrossPointWebServerActivity::loop() {
// Yield and check for exit button every 64 iterations // Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) { if ((i & 0x3F) == 0x3F) {
yield(); yield();
// Force trigger an update of which buttons are being pressed so be have accurate state
// for back button checking
mappedInput.update();
// Check for exit button inside loop for responsiveness // Check for exit button inside loop for responsiveness
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();

View File

@@ -73,18 +73,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

@@ -21,7 +21,8 @@ void WifiSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials - SD card operations need lock as we use SPI for both // Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile(); WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -37,6 +38,7 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false; usedSavedPassword = false;
savePromptSelection = 0; savePromptSelection = 0;
forgetPromptSelection = 0; forgetPromptSelection = 0;
autoConnecting = false;
// Cache MAC address for display // Cache MAC address for display
uint8_t mac[6]; uint8_t mac[6];
@@ -46,9 +48,7 @@ void WifiSelectionActivity::onEnter() {
mac[5]); mac[5]);
cachedMacAddress = std::string(macStr); cachedMacAddress = std::string(macStr);
// Trigger first update to show scanning message // Task creation
updateRequired = true;
xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask", xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask",
4096, // Stack size (larger for WiFi operations) 4096, // Stack size (larger for WiFi operations)
this, // Parameters this, // Parameters
@@ -56,7 +56,26 @@ void WifiSelectionActivity::onEnter() {
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
); );
// Start WiFi scan // Attempt to auto-connect to the last network
if (allowAutoConnect) {
const std::string lastSsid = WIFI_STORE.getLastConnectedSsid();
if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) {
Serial.printf("[%lu] [WIFI] Attempting to auto-connect to %s\n", millis(), lastSsid.c_str());
selectedSSID = cred->ssid;
enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty();
usedSavedPassword = true;
autoConnecting = true;
attemptConnection();
updateRequired = true;
return;
}
}
}
// Fallback to scanning
startWifiScan(); startWifiScan();
} }
@@ -70,15 +89,17 @@ void WifiSelectionActivity::onExit() {
WiFi.scanDelete(); WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity) // Note: We do NOT disconnect WiFi here - the parent activity
// manages WiFi connection state. We just clean up the scan and task. // (CrossPointWebServerActivity) manages WiFi connection state. We just clean
// up the scan and task.
// Acquire mutex before deleting task to ensure task isn't using it // Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted // This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis()); Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it needs it) // Delete the display task (we now hold the mutex, so task is blocked if it
// needs it)
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis()); Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis());
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
@@ -96,6 +117,7 @@ void WifiSelectionActivity::onExit() {
} }
void WifiSelectionActivity::startWifiScan() { void WifiSelectionActivity::startWifiScan() {
autoConnecting = false;
state = WifiSelectionState::SCANNING; state = WifiSelectionState::SCANNING;
networks.clear(); networks.clear();
updateRequired = true; updateRequired = true;
@@ -181,6 +203,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
selectedRequiresPassword = network.isEncrypted; selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false; usedSavedPassword = false;
enteredPassword.clear(); enteredPassword.clear();
autoConnecting = false;
// Check if we have saved credentials for this network // Check if we have saved credentials for this network
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID); const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
@@ -223,7 +246,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
} }
void WifiSelectionActivity::attemptConnection() { void WifiSelectionActivity::attemptConnection() {
state = WifiSelectionState::CONNECTING; state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING;
connectionStartTime = millis(); connectionStartTime = millis();
connectedIP.clear(); connectedIP.clear();
connectionError.clear(); connectionError.clear();
@@ -239,7 +262,7 @@ void WifiSelectionActivity::attemptConnection() {
} }
void WifiSelectionActivity::checkConnectionStatus() { void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) { if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) {
return; return;
} }
@@ -251,6 +274,13 @@ void WifiSelectionActivity::checkConnectionStatus() {
char ipStr[16]; char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr; connectedIP = ipStr;
autoConnecting = false;
// Save this as the last connected network - SD card operations need lock as
// we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setLastConnectedSsid(selectedSSID);
xSemaphoreGive(renderingMutex);
// If we entered a new password, ask if user wants to save it // If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server // Otherwise, immediately complete so parent can start web server
@@ -260,7 +290,10 @@ void WifiSelectionActivity::checkConnectionStatus() {
updateRequired = true; updateRequired = true;
} else { } else {
// Using saved password or open network - complete immediately // Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis()); Serial.printf(
"[%lu] [WIFI] Connected with saved/open credentials, "
"completing immediately\n",
millis());
onComplete(true); onComplete(true);
} }
return; return;
@@ -299,7 +332,7 @@ void WifiSelectionActivity::loop() {
} }
// Check connection progress // Check connection progress
if (state == WifiSelectionState::CONNECTING) { if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) {
checkConnectionStatus(); checkConnectionStatus();
return; return;
} }
@@ -368,17 +401,16 @@ void WifiSelectionActivity::loop() {
} }
} }
// Go back to network list (whether Cancel or Forget network was selected) // Go back to network list (whether Cancel or Forget network was selected)
state = WifiSelectionState::NETWORK_LIST; startWifiScan();
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list // Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST; startWifiScan();
updateRequired = true;
} }
return; return;
} }
// Handle connected state (should not normally be reached - connection completes immediately) // Handle connected state (should not normally be reached - connection
// completes immediately)
if (state == WifiSelectionState::CONNECTED) { if (state == WifiSelectionState::CONNECTED) {
// Safety fallback - immediately complete // Safety fallback - immediately complete
onComplete(true); onComplete(true);
@@ -389,12 +421,14 @@ void WifiSelectionActivity::loop() {
if (state == WifiSelectionState::CONNECTION_FAILED) { if (state == WifiSelectionState::CONNECTION_FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network // If we were auto-connecting or using a saved credential, offer to forget
if (usedSavedPassword) { // the network
if (autoConnecting || usedSavedPassword) {
autoConnecting = false;
state = WifiSelectionState::FORGET_PROMPT; state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel" forgetPromptSelection = 0; // Default to "Cancel"
} else { } else {
// Go back to network list on failure // Go back to network list on failure for non-saved credentials
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
} }
updateRequired = true; updateRequired = true;
@@ -420,20 +454,33 @@ void WifiSelectionActivity::loop() {
return; return;
} }
// Handle UP/DOWN navigation if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || startWifiScan();
mappedInput.wasPressed(MappedInputManager::Button::Left)) { return;
if (selectedNetworkIndex > 0) { }
selectedNetworkIndex--;
const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left);
if (leftPressed) {
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
if (hasSavedPassword) {
selectedSSID = networks[selectedNetworkIndex].ssid;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
updateRequired = true; updateRequired = true;
return;
} }
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || }
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) { // Handle navigation
selectedNetworkIndex++; buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
updateRequired = true; updateRequired = true;
} });
}
buttonNavigator.onPrevious([this] {
selectedNetworkIndex = ButtonNavigator::previousIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
} }
} }
@@ -483,6 +530,9 @@ void WifiSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
switch (state) { switch (state) {
case WifiSelectionState::AUTO_CONNECTING:
renderConnecting();
break;
case WifiSelectionState::SCANNING: case WifiSelectionState::SCANNING:
renderConnecting(); // Reuse connecting screen with different message renderConnecting(); // Reuse connecting screen with different message
break; break;
@@ -586,7 +636,11 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
const char* forgetLabel = hasSavedPassword ? "Forget" : "";
const auto labels = mappedInput.mapLabels("« Back", "Connect", forgetLabel, "Refresh");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
@@ -690,8 +744,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "..."); ssidInfo.replace(25, ssidInfo.length() - 25, "...");

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 {
@@ -21,6 +22,7 @@ struct WifiNetworkInfo {
// WiFi selection states // WiFi selection states
enum class WifiSelectionState { enum class WifiSelectionState {
AUTO_CONNECTING, // Trying to connect to the last known network
SCANNING, // Scanning for networks SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network PASSWORD_ENTRY, // Entering password for selected network
@@ -45,6 +47,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;
@@ -68,6 +71,12 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
// Whether network was connected using a saved password (skip save prompt) // Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false; bool usedSavedPassword = false;
// Whether to attempt auto-connect on entry
const bool allowAutoConnect;
// Whether we are attempting to auto-connect
bool autoConnecting = false;
// Save/forget prompt selection (0 = Yes, 1 = No) // Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0; int savePromptSelection = 0;
int forgetPromptSelection = 0; int forgetPromptSelection = 0;
@@ -96,8 +105,10 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
public: public:
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete) const std::function<void(bool connected)>& onComplete, bool autoConnect = true)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {} : ActivityWithSubactivity("WifiSelection", renderer, mappedInput),
onComplete(onComplete),
allowAutoConnect(autoConnect) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@@ -664,9 +664,9 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
data[5] = (pageCount >> 8) & 0xFF; data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6); f.write(data, 6);
f.close(); f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage); Serial.printf("[%lu] [ERS] Progress saved: Chapter %d, Page %d\n", millis(), spineIndex, currentPage);
} else { } else {
Serial.printf("[ERS] Could not save progress!\n"); Serial.printf("[%lu] [ERS] Could not save progress!\n", millis());
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,

View File

@@ -6,11 +6,6 @@
#include "components/UITheme.h" #include "components/UITheme.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
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); } int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
int EpubReaderChapterSelectionActivity::getPageItems() const { int EpubReaderChapterSelectionActivity::getPageItems() const {
@@ -77,12 +72,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();
@@ -95,21 +84,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;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true; updateRequired = true;
} else if (nextReleased) { });
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems; buttonNavigator.onPreviousRelease([this, totalItems] {
} else { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true; 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

@@ -48,16 +48,19 @@ void EpubReaderMenuActivity::loop() {
return; return;
} }
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
// Use local variables for items we need to check after potential deletion // Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) || if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto selectedAction = menuItems[selectedIndex].action; const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) { if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit. // Cycle orientation preview locally; actual rotation happens on menu exit.

View File

@@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public: public:
@@ -48,6 +49,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
bool updateRequired = false; bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
std::string title = "Reader Menu"; std::string title = "Reader Menu";
uint8_t pendingOrientation = 0; uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};

View File

@@ -79,25 +79,11 @@ void EpubReaderPercentSelectionActivity::loop() {
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
adjustPercent(-kSmallStep); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
adjustPercent(kSmallStep); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
adjustPercent(kLargeStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
adjustPercent(-kLargeStep);
return;
}
} }
void EpubReaderPercentSelectionActivity::renderScreen() { void EpubReaderPercentSelectionActivity::renderScreen() {

View File

@@ -7,6 +7,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity { class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity {
public: public:
@@ -31,6 +32,7 @@ class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity
// FreeRTOS task and mutex for rendering. // FreeRTOS task and mutex for rendering.
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
// Callback invoked when the user confirms a percent. // Callback invoked when the user confirms a percent.
const std::function<void(int)> onSelect; const std::function<void(int)> onSelect;

View File

@@ -8,10 +8,6 @@
#include "components/UITheme.h" #include "components/UITheme.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 lineHeight = 30; constexpr int lineHeight = 30;
@@ -78,13 +74,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();
@@ -93,29 +84,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;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true; updateRequired = true;
} else if (nextReleased) { });
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) { buttonNavigator.onPreviousRelease([this, totalItems] {
return; selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true; 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

@@ -63,15 +63,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

@@ -64,15 +64,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

@@ -11,15 +11,12 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OtaUpdateActivity.h" #include "OtaUpdateActivity.h"
#include "SettingsList.h" #include "SettingsList.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int changeTabsMs = 700;
} // namespace
void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param); auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
@@ -50,11 +47,13 @@ void SettingsActivity::onEnter() {
} }
// Append device-only ACTION items // Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(), SettingInfo::Action("Remap Front Buttons")); controlsSettings.insert(controlsSettings.begin(),
systemSettings.push_back(SettingInfo::Action("KOReader Sync")); SettingInfo::Action("Remap Front Buttons", SettingAction::RemapFrontButtons));
systemSettings.push_back(SettingInfo::Action("OPDS Browser")); systemSettings.push_back(SettingInfo::Action("Network", SettingAction::Network));
systemSettings.push_back(SettingInfo::Action("Clear Cache")); systemSettings.push_back(SettingInfo::Action("KOReader Sync", SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action("Check for updates")); systemSettings.push_back(SettingInfo::Action("OPDS Browser", SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action("Clear Cache", SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action("Check for updates", SettingAction::CheckForUpdates));
// Reset selection to first category // Reset selection to first category
selectedCategoryIndex = 0; selectedCategoryIndex = 0;
@@ -116,28 +115,28 @@ void SettingsActivity::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 rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool changeTab = mappedInput.getHeldTime() > changeTabsMs;
// Handle navigation // Handle navigation
if (upReleased && changeTab) { buttonNavigator.onNextRelease([this] {
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectedSettingIndex = ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, &hasChangedCategory] {
hasChangedCategory = true; hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); selectedCategoryIndex = ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount);
updateRequired = true; updateRequired = true;
} else if (downReleased && changeTab) { });
buttonNavigator.onPreviousContinuous([this, &hasChangedCategory] {
hasChangedCategory = true; hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; selectedCategoryIndex = ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount);
updateRequired = true; updateRequired = true;
} else if (upReleased || leftReleased) { });
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
updateRequired = true;
} else if (rightReleased || downReleased) {
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
if (hasChangedCategory) { if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
@@ -182,46 +181,45 @@ void SettingsActivity::toggleCurrentSetting() {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
} }
} else if (setting.type == SettingType::ACTION) { } else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "Remap Front Buttons") == 0) { auto enterSubActivity = [this](Activity* activity) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
enterNewActivity(new ButtonRemapActivity(renderer, mappedInput, [this] { enterNewActivity(activity);
xSemaphoreGive(renderingMutex);
};
auto onComplete = [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); };
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "KOReader Sync") == 0) { auto onCompleteBool = [this](bool) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); };
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) { switch (setting.action) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); case SettingAction::RemapFrontButtons:
exitActivity(); enterSubActivity(new ButtonRemapActivity(renderer, mappedInput, onComplete));
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { break;
exitActivity(); case SettingAction::KOReaderSync:
updateRequired = true; enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
})); break;
xSemaphoreGive(renderingMutex); case SettingAction::OPDSBrowser:
} else if (strcmp(setting.name, "Clear Cache") == 0) { enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
xSemaphoreTake(renderingMutex, portMAX_DELAY); break;
exitActivity(); case SettingAction::Network:
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));
exitActivity(); break;
updateRequired = true; case SettingAction::ClearCache:
})); enterSubActivity(new ClearCacheActivity(renderer, mappedInput, onComplete));
xSemaphoreGive(renderingMutex); break;
} else if (strcmp(setting.name, "Check for updates") == 0) { case SettingAction::CheckForUpdates:
xSemaphoreTake(renderingMutex, portMAX_DELAY); enterSubActivity(new OtaUpdateActivity(renderer, mappedInput, onComplete));
exitActivity(); break;
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { case SettingAction::None:
exitActivity(); // Do nothing
updateRequired = true; break;
}));
xSemaphoreGive(renderingMutex);
} }
} else { } else {
return; return;

View File

@@ -8,16 +8,28 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings; class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
enum class SettingAction {
None,
RemapFrontButtons,
KOReaderSync,
OPDSBrowser,
Network,
ClearCache,
CheckForUpdates,
};
struct SettingInfo { struct SettingInfo {
const char* name; const char* name;
SettingType type; SettingType type;
uint8_t CrossPointSettings::* valuePtr = nullptr; uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<std::string> enumValues; std::vector<std::string> enumValues;
SettingAction action = SettingAction::None;
struct ValueRange { struct ValueRange {
uint8_t min; uint8_t min;
@@ -62,10 +74,11 @@ struct SettingInfo {
return s; return s;
} }
static SettingInfo Action(const char* name) { static SettingInfo Action(const char* name, SettingAction action) {
SettingInfo s; SettingInfo s;
s.name = name; s.name = name;
s.type = SettingType::ACTION; s.type = SettingType::ACTION;
s.action = action;
return s; return s;
} }
@@ -124,6 +137,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
int selectedSettingIndex = 0; int selectedSettingIndex = 0;

View File

@@ -142,37 +142,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)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; 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
@@ -191,20 +178,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
@@ -223,18 +204,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

@@ -27,6 +27,7 @@
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/ButtonNavigator.h"
HalDisplay display; HalDisplay display;
HalGPIO gpio; HalGPIO gpio;
@@ -304,6 +305,7 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) { switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:
@@ -408,6 +410,13 @@ void loop() {
if (currentActivity && currentActivity->skipLoopDelay()) { if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else { } else {
delay(10); // Normal delay when no activity requires fast response static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds
if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
delay(50);
} else {
// Short delay to prevent tight loop while still being responsive
delay(10);
}
} }
} }

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

@@ -25,6 +25,10 @@ std::string extractHost(const std::string& url) {
} }
std::string buildUrl(const std::string& serverUrl, const std::string& path) { std::string buildUrl(const std::string& serverUrl, const std::string& path) {
// If path is already an absolute URL (has protocol), use it directly
if (path.find("://") != std::string::npos) {
return path;
}
const std::string urlWithProtocol = ensureProtocol(serverUrl); const std::string urlWithProtocol = ensureProtocol(serverUrl);
if (path.empty()) { if (path.empty()) {
return urlWithProtocol; return urlWithProtocol;

View File

@@ -43,6 +43,7 @@ const std::vector<LanguageConfig> kSupportedLanguages = {
{"german", "test/hyphenation_eval/resources/german_hyphenation_tests.txt", "de"}, {"german", "test/hyphenation_eval/resources/german_hyphenation_tests.txt", "de"},
{"russian", "test/hyphenation_eval/resources/russian_hyphenation_tests.txt", "ru"}, {"russian", "test/hyphenation_eval/resources/russian_hyphenation_tests.txt", "ru"},
{"spanish", "test/hyphenation_eval/resources/spanish_hyphenation_tests.txt", "es"}, {"spanish", "test/hyphenation_eval/resources/spanish_hyphenation_tests.txt", "es"},
{"italian", "test/hyphenation_eval/resources/italian_hyphenation_tests.txt", "it"},
}; };
std::vector<size_t> expectedPositionsFromAnnotatedWord(const std::string& annotated) { std::vector<size_t> expectedPositionsFromAnnotatedWord(const std::string& annotated) {

File diff suppressed because it is too large Load Diff