From 526cf1b6f97f9bd79ff8bd28b4042340e34ad277 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 19 Jun 2026 13:39:10 -0400 Subject: [PATCH] Add touch gesture support and LilyGo T5 S3 board Implements bottom-edge swipe-up gesture detection and delayed touch-select with tracking state. Adds isTouchTapCandidate() to distinguish taps from swipes. Includes LilyGo T5 S3 board configuration with USB CDC logging transport for boards where Serial operator bool reports false incorrectly. --- freeink-sdk | 2 +- lib/Epub/Epub/BookMetadataCache.cpp | 83 ++++++++++++++++--- lib/Epub/Epub/BookMetadataCache.h | 12 ++- lib/Logging/Logging.cpp | 18 ++-- lib/hal/HalGPIO.cpp | 4 + lib/hal/HalGPIO.h | 3 + platformio.ini | 24 ++++++ src/MappedInputManager.cpp | 54 +++++++++++- src/MappedInputManager.h | 12 ++- src/activities/home/FileBrowserActivity.cpp | 2 - src/activities/home/HomeActivity.cpp | 5 +- .../network/WifiSelectionActivity.cpp | 2 +- .../EpubReaderChapterSelectionActivity.cpp | 68 ++++++++++----- .../EpubReaderChapterSelectionActivity.h | 2 + .../reader/EpubReaderMenuActivity.cpp | 2 - src/activities/settings/SettingsActivity.cpp | 7 +- src/main.cpp | 10 +++ 17 files changed, 248 insertions(+), 62 deletions(-) diff --git a/freeink-sdk b/freeink-sdk index fe50609e..e016d51e 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit fe50609efcb6f1ae7743ab2369e1d209456d1d7c +Subproject commit e016d51e51ccb858dbbc8e25fca9894b4ed0913b diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 149cc1d8..a71ab5cb 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -13,8 +13,47 @@ constexpr uint8_t BOOK_CACHE_VERSION = 7; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; +constexpr uint32_t MAX_CACHE_STRING_LEN = 4096; + +bool readStringBounded(HalFile& file, std::string& out, const uint32_t maxLen = MAX_CACHE_STRING_LEN) { + uint32_t len = 0; + if (file.read(&len, sizeof(len)) != static_cast(sizeof(len))) { + return false; + } + if (len > maxLen || len > static_cast(file.available())) { + LOG_ERR("BMC", "Invalid cache string length: %lu (max=%lu available=%d)", static_cast(len), + static_cast(maxLen), file.available()); + return false; + } + out.clear(); + if (len == 0) { + return true; + } + out.resize(len); + return file.read(out.data(), len) == static_cast(len); +} + +class CacheIoLock { + public: + explicit CacheIoLock(SemaphoreHandle_t mutex) : mutex(mutex) { + if (mutex) xSemaphoreTakeRecursive(mutex, portMAX_DELAY); + } + ~CacheIoLock() { + if (mutex) xSemaphoreGiveRecursive(mutex); + } + + private: + SemaphoreHandle_t mutex; +}; } // namespace +BookMetadataCache::~BookMetadataCache() { + if (ioMutex) { + vSemaphoreDelete(ioMutex); + ioMutex = nullptr; + } +} + /* ============= WRITING / BUILDING FUNCTIONS ================ */ bool BookMetadataCache::beginWrite() { @@ -372,6 +411,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri /* ============= READING / LOADING FUNCTIONS ================ */ bool BookMetadataCache::load() { + CacheIoLock ioLock(ioMutex); if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { return false; } @@ -389,11 +429,13 @@ bool BookMetadataCache::load() { serialization::readPod(bookFile, spineCount); serialization::readPod(bookFile, tocCount); - serialization::readString(bookFile, coreMetadata.title); - serialization::readString(bookFile, coreMetadata.author); - serialization::readString(bookFile, coreMetadata.language); - serialization::readString(bookFile, coreMetadata.coverItemHref); - serialization::readString(bookFile, coreMetadata.textReferenceHref); + if (!readStringBounded(bookFile, coreMetadata.title) || !readStringBounded(bookFile, coreMetadata.author) || + !readStringBounded(bookFile, coreMetadata.language) || !readStringBounded(bookFile, coreMetadata.coverItemHref) || + !readStringBounded(bookFile, coreMetadata.textReferenceHref)) { + LOG_ERR("BMC", "Invalid cache metadata strings"); + bookFile.close(); + return false; + } loaded = true; LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount); @@ -401,6 +443,7 @@ bool BookMetadataCache::load() { } BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { + CacheIoLock ioLock(ioMutex); if (!loaded) { LOG_ERR("BMC", "getSpineEntry called but cache not loaded"); return {}; @@ -415,11 +458,16 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) bookFile.seek(lutOffset + sizeof(uint32_t) * index); uint32_t spineEntryPos; serialization::readPod(bookFile, spineEntryPos); + if (spineEntryPos >= bookFile.size()) { + LOG_ERR("BMC", "Spine entry offset out of range: %lu", static_cast(spineEntryPos)); + return {}; + } bookFile.seek(spineEntryPos); return readSpineEntry(bookFile); } BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { + CacheIoLock ioLock(ioMutex); if (!loaded) { LOG_ERR("BMC", "getTocEntry called but cache not loaded"); return {}; @@ -434,24 +482,33 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index); uint32_t tocEntryPos; serialization::readPod(bookFile, tocEntryPos); + if (tocEntryPos >= bookFile.size()) { + LOG_ERR("BMC", "TOC entry offset out of range: %lu", static_cast(tocEntryPos)); + return {}; + } bookFile.seek(tocEntryPos); return readTocEntry(bookFile); } BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const { SpineEntry entry; - serialization::readString(file, entry.href); - serialization::readPod(file, entry.cumulativeSize); - serialization::readPod(file, entry.tocIndex); + if (!readStringBounded(file, entry.href) || + file.read(&entry.cumulativeSize, sizeof(entry.cumulativeSize)) != static_cast(sizeof(entry.cumulativeSize)) || + file.read(&entry.tocIndex, sizeof(entry.tocIndex)) != static_cast(sizeof(entry.tocIndex))) { + LOG_ERR("BMC", "Invalid spine cache entry"); + return {}; + } return entry; } BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const { TocEntry entry; - serialization::readString(file, entry.title); - serialization::readString(file, entry.href); - serialization::readString(file, entry.anchor); - serialization::readPod(file, entry.level); - serialization::readPod(file, entry.spineIndex); + if (!readStringBounded(file, entry.title) || !readStringBounded(file, entry.href) || + !readStringBounded(file, entry.anchor) || + file.read(&entry.level, sizeof(entry.level)) != static_cast(sizeof(entry.level)) || + file.read(&entry.spineIndex, sizeof(entry.spineIndex)) != static_cast(sizeof(entry.spineIndex))) { + LOG_ERR("BMC", "Invalid TOC cache entry"); + return {}; + } return entry; } diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index 2114c664..0c4bd354 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -54,6 +55,7 @@ class BookMetadataCache { // Temp file handles during build HalFile spineFile; HalFile tocFile; + SemaphoreHandle_t ioMutex; // Index for fast href→spineIndex lookup (used only for large EPUBs) struct SpineHrefIndexEntry { @@ -85,8 +87,14 @@ class BookMetadataCache { BookMetadata coreMetadata; explicit BookMetadataCache(std::string cachePath) - : cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} - ~BookMetadataCache() = default; + : cachePath(std::move(cachePath)), + lutOffset(0), + spineCount(0), + tocCount(0), + loaded(false), + buildMode(false), + ioMutex(xSemaphoreCreateRecursiveMutex()) {} + ~BookMetadataCache(); // Building phase (stream to disk immediately) bool beginWrite(); diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 1547a1b6..8f9298cd 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,9 +1,10 @@ -#include "Logging.h" - +#include #include #include +#include "Logging.h" + #define MAX_ENTRY_LEN 256 #define MAX_LOG_LINES 16 @@ -61,12 +62,13 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { } } va_end(args); -#if defined(CONFIG_IDF_TARGET_ESP32S3) - // ESP32-S3 over USB-Serial-JTAG (HWCDC): Serial's `operator bool` reads false under a - // host monitor (and HWCDC.write itself drops when it thinks it's disconnected), so the - // `if (logSerial)` path below silently swallows every line. esp_rom_printf writes to the - // always-on ROM/IDF console — the same channel that carries the boot banner and ARDUHAL - // logs — so output is actually visible. `buf` is already fully formatted; pass via %s. +#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_USB_CDC_WRITE + // Native USB CDC can report false while PlatformIO monitor is attached on + // boards like LilyGo T5 S3, so write directly to the CDC object. + logSerial.write(reinterpret_cast(buf), strnlen(buf, sizeof(buf))); +#elif FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF + // IDF/ROM console path for boards whose monitor is attached there during + // bring-up, e.g. Sticky. esp_rom_printf("%s", buf); #else if (logSerial) { diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 4169faca..ce2b3591 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -247,6 +247,10 @@ bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouch bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); } +bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const { + return inputMgr.isTouchTapCandidate(nx, ny, heldMs); +} + unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); } bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const { diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 493635ea..cdf9a0d9 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -86,6 +86,9 @@ class HalGPIO { // 0..1 (panel native). For showing the pressed/selected element before release. bool wasTouchDown(float& nx, float& ny) const; + // True while a touch remains within tap slop; writes touch-down position and held time. + bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const; + // Duration (ms) of the last touch contact, latched on release. Valid on the // release frame (alongside wasTouchTap). For tap-vs-long-press decisions. unsigned long lastTouchHeldMs() const; diff --git a/platformio.ini b/platformio.ini index dc38b9ab..5c94f4ac 100644 --- a/platformio.ini +++ b/platformio.ini @@ -117,6 +117,30 @@ build_flags = ; serial output is disabled in slim builds to save space -UENABLE_SERIAL_LOG +; --- LilyGo T5 S3 (ESP32-S3 N16R8, 4.7" ED047TC1 / raw-parallel EPD) --------- +; Uses FreeInk's LilyGo board-support library for the PCA9535/TPS65185 display +; power hooks and expander button. +; pio run -e lilygo_t5s3 -t upload --upload-port /dev/tty.usbmodemXXXX +[env:lilygo_t5s3] +extends = base +board = esp32-s3-devkitc1-n16r8 +board_build.mcu = esp32s3 +board_build.flash_mode = qio +board_build.arduino.memory_type = qio_opi +build_flags = + ${base.build_flags} + -DBOARD_HAS_PSRAM + -DFREEINK_DEVICE_LILYGO=1 + -DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo_t5s3\" + -DCROSSPOINT_SHOW_BUTTON_HINTS=0 + -DENABLE_SERIAL_LOG + -DLOG_LEVEL=2 + -DTOUCH_PROBE_DEBUG=1 +lib_deps = + ${base.lib_deps} + BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3 + m5stack/M5GFX @ 0.2.20 + ; --- M5Paper v1.1 (classic ESP32-D0WDQ6, 4.7" 540x960 / IT8951E) ------------- ; Different MCU family than the C3 base: override board/mcu/flash, disable the ; C3-only native-USB CDC (logs over UART0), and select the M5Paper device. diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index ce0e524f..5049a8aa 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -63,8 +63,25 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint // Top-left corner fallback, as a fraction of the logical screen. Generous to hit. static constexpr float BACK_GESTURE_FRAC_X = 0.22f; static constexpr float BACK_GESTURE_FRAC_Y = 0.12f; +static constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f; +static constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90; + +bool MappedInputManager::wasBottomEdgeSwipeUp() const { + float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f; + if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false; + + int sx = 0, sy = 0, ex = 0, ey = 0; + renderer.tapToLogical(nxs, nys, sx, sy); + renderer.tapToLogical(nxe, nye, ex, ey); + + const int screenHeight = renderer.getScreenHeight(); + const int bottomEdgeTop = screenHeight - static_cast(screenHeight * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y); + return sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx); +} bool MappedInputManager::wasBackGesture() const { + if (wasBottomEdgeSwipeUp()) return true; + float nx = 0.0f, ny = 0.0f; if (!gpio.wasTouchTap(nx, ny)) return false; int lx = 0, ly = 0; @@ -87,10 +104,36 @@ bool MappedInputManager::wasItemTapped(int& id) const { bool MappedInputManager::wasItemTouchedDown(int& id) const { float nx = 0.0f, ny = 0.0f; - if (!gpio.wasTouchDown(nx, ny)) return false; - int lx = 0, ly = 0; - renderer.tapToLogical(nx, ny, lx, ly); - return TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id); + unsigned long heldMs = 0; + if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) { + touchSelectTracking = false; + touchSelectEmitted = false; + touchSelectId = -1; + return false; + } + + if (!touchSelectTracking) { + float downNx = 0.0f, downNy = 0.0f; + if (!gpio.wasTouchDown(downNx, downNy)) return false; + + int lx = 0, ly = 0; + renderer.tapToLogical(downNx, downNy, lx, ly); + int candidateId = -1; + if (!TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, candidateId)) { + touchSelectTracking = false; + touchSelectEmitted = false; + touchSelectId = -1; + return false; + } + touchSelectTracking = true; + touchSelectEmitted = false; + touchSelectId = candidateId; + } + + if (touchSelectEmitted || heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false; + touchSelectEmitted = true; + id = touchSelectId; + return true; } bool MappedInputManager::wasItemLongPressed(int& id) const { @@ -122,6 +165,7 @@ bool MappedInputManager::wasCoverTapped(int& id) const { bool MappedInputManager::wasListScroll(int& index, int count, int pageItems) const { if (count <= 0) return false; if (pageItems < 1) pageItems = 1; + if (wasBottomEdgeSwipeUp()) return false; const SwipeDir swipe = wasSwipe(); if (swipe == SwipeDir::Up) { index = std::min(index + pageItems, count - 1); @@ -135,6 +179,8 @@ bool MappedInputManager::wasListScroll(int& index, int count, int pageItems) con } MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const { + if (wasBottomEdgeSwipeUp()) return SwipeDir::None; + float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f; if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None; // Map both endpoints into the logical frame so the direction follows what the diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 03cbb5b7..d623a371 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -23,13 +23,14 @@ class MappedInputManager { bool wasReleased(Button button) const; bool isPressed(Button button) const; // Touch "back" gesture: a tap on the theme's header Back target, or in the - // top-left corner. Folded into Back's edges, so every screen gets it for free. + // top-left corner, or a swipe up from the visible bottom edge. Folded into + // Back's edges, so every screen gets it for free. bool wasBackGesture() const; // True (and writes the id) if a tap this frame hit a TouchRegistry item. // Activities treat the id as "select + activate". False on non-touch devices. bool wasItemTapped(int& id) const; - // Press-edge of wasItemTapped: fires on touch-DOWN over an item so the activity - // can show it selected before release. Mirrors button nav (move, then confirm). + // Stable touch-down candidate: fires once when a touch remains over an item + // briefly without crossing tap slop, so swipes do not show row selection. bool wasItemTouchedDown(int& id) const; // Subset of wasItemTapped's releases held past the long-press threshold (check // this first). Distinguishes tap vs press-and-hold. @@ -58,4 +59,9 @@ class MappedInputManager { GfxRenderer& renderer; bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const; + bool wasBottomEdgeSwipeUp() const; + + mutable bool touchSelectTracking = false; + mutable bool touchSelectEmitted = false; + mutable int touchSelectId = -1; }; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index b8fef609..c04e5be0 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -214,8 +214,6 @@ void FileBrowserActivity::loop() { return; } - // Touch-down moves the selector to the pressed entry (shows selected state); release - // opens it below. int downId = -1; if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast(files.size())) { selectorIndex = downId; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 9632da32..f23ec02d 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -181,7 +181,7 @@ void HomeActivity::loop() { // Tap a menu button to select + activate it. The button menu registers // menu-local ids (drawn with selectorIndex offset by recentBooks.size()), so map - // back into the global selector space. Touch-down shows it selected; release opens. + // back into the global selector space. int downId = -1; if (mappedInput.wasItemTouchedDown(downId)) { selectorIndex = static_cast(recentBooks.size()) + downId; @@ -274,8 +274,7 @@ void HomeActivity::render(RenderLock&&) { const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset; const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing; GUI.drawButtonMenu( - renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, - static_cast(menuItems.size()), + renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, static_cast(menuItems.size()), metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), [&menuItems](int index) { return std::string(menuItems[index]); }, [&menuIcons](int index) { return menuIcons[index]; }); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index ae446f81..b1dd92db 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -417,7 +417,7 @@ void WifiSelectionActivity::loop() { requestUpdate(); return; } - // Touch: down-select highlights the pressed network, tap selects it (like Confirm). + // Touch: stable press highlights; tap selects it (like Confirm). Drag/scroll contacts do not preselect a row. int downId = -1; if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast(networks.size())) { selectedNetworkIndex = downId; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 22d92224..379afa61 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -3,11 +3,39 @@ #include #include +#include + #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" -int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); } +int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub ? epub->getTocItemsCount() : 0; } + +void EpubReaderChapterSelectionActivity::cancelAndFinish() { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); +} + +void EpubReaderChapterSelectionActivity::activateSelection(int index) { + const int totalItems = getTotalItems(); + if (!epub || totalItems <= 0) { + cancelAndFinish(); + return; + } + + index = std::clamp(index, 0, totalItems - 1); + const auto tocItem = epub->getTocItem(index); + if (tocItem.spineIndex < 0 || tocItem.spineIndex >= epub->getSpineItemsCount()) { + cancelAndFinish(); + return; + } + + selectorIndex = index; + setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor}); + finish(); +} void EpubReaderChapterSelectionActivity::onEnter() { Activity::onEnter(); @@ -30,6 +58,12 @@ void EpubReaderChapterSelectionActivity::onExit() { Activity::onExit(); } void EpubReaderChapterSelectionActivity::loop() { const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); const int totalItems = getTotalItems(); + if (!epub || totalItems <= 0) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancelAndFinish(); + } + return; + } // Vertical swipe page-scrolls the list (touch nav without the side buttons). if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) { @@ -45,23 +79,17 @@ void EpubReaderChapterSelectionActivity::loop() { int tappedId = -1; const bool tapped = mappedInput.wasItemTapped(tappedId); - if (tapped && tappedId >= 0 && tappedId < totalItems) selectorIndex = tappedId; - if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - const auto tocItem = epub->getTocItem(selectorIndex); - if (tocItem.spineIndex == -1) { - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); - } else { - setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor}); - finish(); - } + const bool validTap = tapped && tappedId >= 0 && tappedId < totalItems; + const bool confirm = mappedInput.wasReleased(MappedInputManager::Button::Confirm); + if (validTap) { + selectorIndex = tappedId; + } + if (validTap || confirm) { + activateSelection(selectorIndex); + return; } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); + cancelAndFinish(); + return; } buttonNavigator.onNextRelease([this, totalItems] { @@ -98,10 +126,12 @@ void EpubReaderChapterSelectionActivity::render(RenderLock&&) { const int contentHeight = screen.height - contentTop - metrics.verticalSpacing; const int totalItems = getTotalItems(); - GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, selectorIndex, + const int displayIndex = totalItems > 0 ? std::clamp(selectorIndex, 0, totalItems - 1) : 0; + GUI.drawList(renderer, Rect{screen.x, contentTop, screen.width, contentHeight}, totalItems, displayIndex, [this](int index) { auto item = epub->getTocItem(index); - std::string indent((item.level - 1) * 2, ' '); + const int level = item.level > 0 ? item.level - 1 : 0; + std::string indent(level * 2, ' '); return indent + item.title; }); diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index 9d593e30..7a91ed31 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -19,6 +19,8 @@ class EpubReaderChapterSelectionActivity final : public Activity { // Total TOC items count int getTotalItems() const; + void cancelAndFinish(); + void activateSelection(int index); public: explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index dbe12247..0c952238 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -64,8 +64,6 @@ void EpubReaderMenuActivity::loop() { return; } - // Touch-down moves the selection to the pressed item (shows selected state), like - // moving with Up/Down; release activates it below. int downId = -1; if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast(menuItems.size())) { selectedIndex = downId; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a7fbdcfd..80302410 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -133,15 +133,14 @@ void SettingsActivity::loop() { } else if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { // Vertical swipe page-scrolls the list (touch nav without the side buttons). // count is settingsCount + 1 because row 0 is the category tab bar. - const int page = std::max( - 1, UITheme::getNumberOfItemsPerPage(renderer, true, true, BaseTheme::showButtonHints(), false)); + const int page = + std::max(1, UITheme::getNumberOfItemsPerPage(renderer, true, true, BaseTheme::showButtonHints(), false)); mappedInput.wasListScroll(selectedSettingIndex, settingsCount + 1, page); requestUpdate(); } // Tap a settings row to select + activate it. Row 0 is the tab bar, so the list - // is drawn at selectedSettingIndex - 1; map the tapped row back by +1. Touch-down - // shows it selected; release toggles/activates below. + // is drawn at selectedSettingIndex - 1; map the tapped row back by +1. int downId = -1; if (!swiped && mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < settingsCount) { selectedSettingIndex = downId + 1; diff --git a/src/main.cpp b/src/main.cpp index 7595cf30..077dd659 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,10 @@ #include #include #include + +#if defined(FREEINK_DEVICE_LILYGO) && FREEINK_DEVICE_LILYGO +#include +#endif #include #include #include @@ -367,6 +371,12 @@ void setup() { #endif #endif +#if defined(FREEINK_DEVICE_LILYGO) && FREEINK_DEVICE_LILYGO + // LilyGo T5 S3 board-support owns the shared I2C setup plus non-reader + // peripherals that can otherwise contend with SD/display pins. + BoardT5S3::begin(); +#endif + HalSystem::begin(); // Read-and-clear so a panic later in setup() doesn't loop into silent reboot.