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.
This commit is contained in:
Justin Mitchell
2026-06-19 13:39:10 -04:00
parent 6648a980cb
commit 526cf1b6f9
17 changed files with 248 additions and 62 deletions
+70 -13
View File
@@ -13,8 +13,47 @@ constexpr uint8_t BOOK_CACHE_VERSION = 7;
constexpr char bookBinFile[] = "/book.bin"; constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.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<int>(sizeof(len))) {
return false;
}
if (len > maxLen || len > static_cast<uint32_t>(file.available())) {
LOG_ERR("BMC", "Invalid cache string length: %lu (max=%lu available=%d)", static_cast<unsigned long>(len),
static_cast<unsigned long>(maxLen), file.available());
return false;
}
out.clear();
if (len == 0) {
return true;
}
out.resize(len);
return file.read(out.data(), len) == static_cast<int>(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 } // namespace
BookMetadataCache::~BookMetadataCache() {
if (ioMutex) {
vSemaphoreDelete(ioMutex);
ioMutex = nullptr;
}
}
/* ============= WRITING / BUILDING FUNCTIONS ================ */ /* ============= WRITING / BUILDING FUNCTIONS ================ */
bool BookMetadataCache::beginWrite() { bool BookMetadataCache::beginWrite() {
@@ -372,6 +411,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
/* ============= READING / LOADING FUNCTIONS ================ */ /* ============= READING / LOADING FUNCTIONS ================ */
bool BookMetadataCache::load() { bool BookMetadataCache::load() {
CacheIoLock ioLock(ioMutex);
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
return false; return false;
} }
@@ -389,11 +429,13 @@ bool BookMetadataCache::load() {
serialization::readPod(bookFile, spineCount); serialization::readPod(bookFile, spineCount);
serialization::readPod(bookFile, tocCount); serialization::readPod(bookFile, tocCount);
serialization::readString(bookFile, coreMetadata.title); if (!readStringBounded(bookFile, coreMetadata.title) || !readStringBounded(bookFile, coreMetadata.author) ||
serialization::readString(bookFile, coreMetadata.author); !readStringBounded(bookFile, coreMetadata.language) || !readStringBounded(bookFile, coreMetadata.coverItemHref) ||
serialization::readString(bookFile, coreMetadata.language); !readStringBounded(bookFile, coreMetadata.textReferenceHref)) {
serialization::readString(bookFile, coreMetadata.coverItemHref); LOG_ERR("BMC", "Invalid cache metadata strings");
serialization::readString(bookFile, coreMetadata.textReferenceHref); bookFile.close();
return false;
}
loaded = true; loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount); 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) { BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
CacheIoLock ioLock(ioMutex);
if (!loaded) { if (!loaded) {
LOG_ERR("BMC", "getSpineEntry called but cache not loaded"); LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
return {}; return {};
@@ -415,11 +458,16 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
bookFile.seek(lutOffset + sizeof(uint32_t) * index); bookFile.seek(lutOffset + sizeof(uint32_t) * index);
uint32_t spineEntryPos; uint32_t spineEntryPos;
serialization::readPod(bookFile, spineEntryPos); serialization::readPod(bookFile, spineEntryPos);
if (spineEntryPos >= bookFile.size()) {
LOG_ERR("BMC", "Spine entry offset out of range: %lu", static_cast<unsigned long>(spineEntryPos));
return {};
}
bookFile.seek(spineEntryPos); bookFile.seek(spineEntryPos);
return readSpineEntry(bookFile); return readSpineEntry(bookFile);
} }
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
CacheIoLock ioLock(ioMutex);
if (!loaded) { if (!loaded) {
LOG_ERR("BMC", "getTocEntry called but cache not loaded"); LOG_ERR("BMC", "getTocEntry called but cache not loaded");
return {}; return {};
@@ -434,24 +482,33 @@ BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index); bookFile.seek(lutOffset + sizeof(uint32_t) * spineCount + sizeof(uint32_t) * index);
uint32_t tocEntryPos; uint32_t tocEntryPos;
serialization::readPod(bookFile, tocEntryPos); serialization::readPod(bookFile, tocEntryPos);
if (tocEntryPos >= bookFile.size()) {
LOG_ERR("BMC", "TOC entry offset out of range: %lu", static_cast<unsigned long>(tocEntryPos));
return {};
}
bookFile.seek(tocEntryPos); bookFile.seek(tocEntryPos);
return readTocEntry(bookFile); return readTocEntry(bookFile);
} }
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const { BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(HalFile& file) const {
SpineEntry entry; SpineEntry entry;
serialization::readString(file, entry.href); if (!readStringBounded(file, entry.href) ||
serialization::readPod(file, entry.cumulativeSize); file.read(&entry.cumulativeSize, sizeof(entry.cumulativeSize)) != static_cast<int>(sizeof(entry.cumulativeSize)) ||
serialization::readPod(file, entry.tocIndex); file.read(&entry.tocIndex, sizeof(entry.tocIndex)) != static_cast<int>(sizeof(entry.tocIndex))) {
LOG_ERR("BMC", "Invalid spine cache entry");
return {};
}
return entry; return entry;
} }
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const { BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(HalFile& file) const {
TocEntry entry; TocEntry entry;
serialization::readString(file, entry.title); if (!readStringBounded(file, entry.title) || !readStringBounded(file, entry.href) ||
serialization::readString(file, entry.href); !readStringBounded(file, entry.anchor) ||
serialization::readString(file, entry.anchor); file.read(&entry.level, sizeof(entry.level)) != static_cast<int>(sizeof(entry.level)) ||
serialization::readPod(file, entry.level); file.read(&entry.spineIndex, sizeof(entry.spineIndex)) != static_cast<int>(sizeof(entry.spineIndex))) {
serialization::readPod(file, entry.spineIndex); LOG_ERR("BMC", "Invalid TOC cache entry");
return {};
}
return entry; return entry;
} }
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <HalStorage.h> #include <HalStorage.h>
#include <freertos/semphr.h>
#include <algorithm> #include <algorithm>
#include <deque> #include <deque>
@@ -54,6 +55,7 @@ class BookMetadataCache {
// Temp file handles during build // Temp file handles during build
HalFile spineFile; HalFile spineFile;
HalFile tocFile; HalFile tocFile;
SemaphoreHandle_t ioMutex;
// Index for fast href→spineIndex lookup (used only for large EPUBs) // Index for fast href→spineIndex lookup (used only for large EPUBs)
struct SpineHrefIndexEntry { struct SpineHrefIndexEntry {
@@ -85,8 +87,14 @@ class BookMetadataCache {
BookMetadata coreMetadata; BookMetadata coreMetadata;
explicit BookMetadataCache(std::string cachePath) explicit BookMetadataCache(std::string cachePath)
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} : cachePath(std::move(cachePath)),
~BookMetadataCache() = default; lutOffset(0),
spineCount(0),
tocCount(0),
loaded(false),
buildMode(false),
ioMutex(xSemaphoreCreateRecursiveMutex()) {}
~BookMetadataCache();
// Building phase (stream to disk immediately) // Building phase (stream to disk immediately)
bool beginWrite(); bool beginWrite();
+10 -8
View File
@@ -1,9 +1,10 @@
#include "Logging.h" #include <BoardConfig.h>
#include <esp_rom_sys.h> #include <esp_rom_sys.h>
#include <string> #include <string>
#include "Logging.h"
#define MAX_ENTRY_LEN 256 #define MAX_ENTRY_LEN 256
#define MAX_LOG_LINES 16 #define MAX_LOG_LINES 16
@@ -61,12 +62,13 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
} }
} }
va_end(args); va_end(args);
#if defined(CONFIG_IDF_TARGET_ESP32S3) #if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_USB_CDC_WRITE
// ESP32-S3 over USB-Serial-JTAG (HWCDC): Serial's `operator bool` reads false under a // Native USB CDC can report false while PlatformIO monitor is attached on
// host monitor (and HWCDC.write itself drops when it thinks it's disconnected), so the // boards like LilyGo T5 S3, so write directly to the CDC object.
// `if (logSerial)` path below silently swallows every line. esp_rom_printf writes to the logSerial.write(reinterpret_cast<const uint8_t*>(buf), strnlen(buf, sizeof(buf)));
// always-on ROM/IDF console — the same channel that carries the boot banner and ARDUHAL #elif FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
// logs — so output is actually visible. `buf` is already fully formatted; pass via %s. // IDF/ROM console path for boards whose monitor is attached there during
// bring-up, e.g. Sticky.
esp_rom_printf("%s", buf); esp_rom_printf("%s", buf);
#else #else
if (logSerial) { if (logSerial) {
+4
View File
@@ -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::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(); } unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const { bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
+3
View File
@@ -86,6 +86,9 @@ class HalGPIO {
// 0..1 (panel native). For showing the pressed/selected element before release. // 0..1 (panel native). For showing the pressed/selected element before release.
bool wasTouchDown(float& nx, float& ny) const; 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 // Duration (ms) of the last touch contact, latched on release. Valid on the
// release frame (alongside wasTouchTap). For tap-vs-long-press decisions. // release frame (alongside wasTouchTap). For tap-vs-long-press decisions.
unsigned long lastTouchHeldMs() const; unsigned long lastTouchHeldMs() const;
+24
View File
@@ -117,6 +117,30 @@ build_flags =
; serial output is disabled in slim builds to save space ; serial output is disabled in slim builds to save space
-UENABLE_SERIAL_LOG -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) ------------- ; --- M5Paper v1.1 (classic ESP32-D0WDQ6, 4.7" 540x960 / IT8951E) -------------
; Different MCU family than the C3 base: override board/mcu/flash, disable the ; 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. ; C3-only native-USB CDC (logs over UART0), and select the M5Paper device.
+50 -4
View File
@@ -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. // 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_X = 0.22f;
static constexpr float BACK_GESTURE_FRAC_Y = 0.12f; 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<int>(screenHeight * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
return sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx);
}
bool MappedInputManager::wasBackGesture() const { bool MappedInputManager::wasBackGesture() const {
if (wasBottomEdgeSwipeUp()) return true;
float nx = 0.0f, ny = 0.0f; float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchTap(nx, ny)) return false; if (!gpio.wasTouchTap(nx, ny)) return false;
int lx = 0, ly = 0; int lx = 0, ly = 0;
@@ -87,10 +104,36 @@ bool MappedInputManager::wasItemTapped(int& id) const {
bool MappedInputManager::wasItemTouchedDown(int& id) const { bool MappedInputManager::wasItemTouchedDown(int& id) const {
float nx = 0.0f, ny = 0.0f; float nx = 0.0f, ny = 0.0f;
if (!gpio.wasTouchDown(nx, ny)) return false; unsigned long heldMs = 0;
int lx = 0, ly = 0; if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) {
renderer.tapToLogical(nx, ny, lx, ly); touchSelectTracking = false;
return TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Item, id); 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 { 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 { bool MappedInputManager::wasListScroll(int& index, int count, int pageItems) const {
if (count <= 0) return false; if (count <= 0) return false;
if (pageItems < 1) pageItems = 1; if (pageItems < 1) pageItems = 1;
if (wasBottomEdgeSwipeUp()) return false;
const SwipeDir swipe = wasSwipe(); const SwipeDir swipe = wasSwipe();
if (swipe == SwipeDir::Up) { if (swipe == SwipeDir::Up) {
index = std::min(index + pageItems, count - 1); 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 { MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
if (wasBottomEdgeSwipeUp()) return SwipeDir::None;
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f; float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None; if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None;
// Map both endpoints into the logical frame so the direction follows what the // Map both endpoints into the logical frame so the direction follows what the
+9 -3
View File
@@ -23,13 +23,14 @@ class MappedInputManager {
bool wasReleased(Button button) const; bool wasReleased(Button button) const;
bool isPressed(Button button) const; bool isPressed(Button button) const;
// Touch "back" gesture: a tap on the theme's header Back target, or in the // 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; bool wasBackGesture() const;
// True (and writes the id) if a tap this frame hit a TouchRegistry item. // 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. // Activities treat the id as "select + activate". False on non-touch devices.
bool wasItemTapped(int& id) const; bool wasItemTapped(int& id) const;
// Press-edge of wasItemTapped: fires on touch-DOWN over an item so the activity // Stable touch-down candidate: fires once when a touch remains over an item
// can show it selected before release. Mirrors button nav (move, then confirm). // briefly without crossing tap slop, so swipes do not show row selection.
bool wasItemTouchedDown(int& id) const; bool wasItemTouchedDown(int& id) const;
// Subset of wasItemTapped's releases held past the long-press threshold (check // Subset of wasItemTapped's releases held past the long-press threshold (check
// this first). Distinguishes tap vs press-and-hold. // this first). Distinguishes tap vs press-and-hold.
@@ -58,4 +59,9 @@ class MappedInputManager {
GfxRenderer& renderer; GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const; 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;
}; };
@@ -214,8 +214,6 @@ void FileBrowserActivity::loop() {
return; return;
} }
// Touch-down moves the selector to the pressed entry (shows selected state); release
// opens it below.
int downId = -1; int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(files.size())) { if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(files.size())) {
selectorIndex = downId; selectorIndex = downId;
+2 -3
View File
@@ -181,7 +181,7 @@ void HomeActivity::loop() {
// Tap a menu button to select + activate it. The button menu registers // Tap a menu button to select + activate it. The button menu registers
// menu-local ids (drawn with selectorIndex offset by recentBooks.size()), so map // 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; int downId = -1;
if (mappedInput.wasItemTouchedDown(downId)) { if (mappedInput.wasItemTouchedDown(downId)) {
selectorIndex = static_cast<int>(recentBooks.size()) + downId; selectorIndex = static_cast<int>(recentBooks.size()) + downId;
@@ -274,8 +274,7 @@ void HomeActivity::render(RenderLock&&) {
const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset; const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing; const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing;
GUI.drawButtonMenu( GUI.drawButtonMenu(
renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve}, static_cast<int>(menuItems.size()),
static_cast<int>(menuItems.size()),
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(),
[&menuItems](int index) { return std::string(menuItems[index]); }, [&menuItems](int index) { return std::string(menuItems[index]); },
[&menuIcons](int index) { return menuIcons[index]; }); [&menuIcons](int index) { return menuIcons[index]; });
@@ -417,7 +417,7 @@ void WifiSelectionActivity::loop() {
requestUpdate(); requestUpdate();
return; 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; int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(networks.size())) { if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(networks.size())) {
selectedNetworkIndex = downId; selectedNetworkIndex = downId;
@@ -3,11 +3,39 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <I18n.h> #include <I18n.h>
#include <algorithm>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.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() { void EpubReaderChapterSelectionActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@@ -30,6 +58,12 @@ void EpubReaderChapterSelectionActivity::onExit() { Activity::onExit(); }
void EpubReaderChapterSelectionActivity::loop() { void EpubReaderChapterSelectionActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
const int totalItems = getTotalItems(); 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). // Vertical swipe page-scrolls the list (touch nav without the side buttons).
if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) { if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) {
@@ -45,23 +79,17 @@ void EpubReaderChapterSelectionActivity::loop() {
int tappedId = -1; int tappedId = -1;
const bool tapped = mappedInput.wasItemTapped(tappedId); const bool tapped = mappedInput.wasItemTapped(tappedId);
if (tapped && tappedId >= 0 && tappedId < totalItems) selectorIndex = tappedId; const bool validTap = tapped && tappedId >= 0 && tappedId < totalItems;
if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const bool confirm = mappedInput.wasReleased(MappedInputManager::Button::Confirm);
const auto tocItem = epub->getTocItem(selectorIndex); if (validTap) {
if (tocItem.spineIndex == -1) { selectorIndex = tappedId;
ActivityResult result; }
result.isCancelled = true; if (validTap || confirm) {
setResult(std::move(result)); activateSelection(selectorIndex);
finish(); return;
} else {
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
finish();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result; cancelAndFinish();
result.isCancelled = true; return;
setResult(std::move(result));
finish();
} }
buttonNavigator.onNextRelease([this, totalItems] { buttonNavigator.onNextRelease([this, totalItems] {
@@ -98,10 +126,12 @@ void EpubReaderChapterSelectionActivity::render(RenderLock&&) {
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing; const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
const int totalItems = getTotalItems(); 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) { [this](int index) {
auto item = epub->getTocItem(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; return indent + item.title;
}); });
@@ -19,6 +19,8 @@ class EpubReaderChapterSelectionActivity final : public Activity {
// Total TOC items count // Total TOC items count
int getTotalItems() const; int getTotalItems() const;
void cancelAndFinish();
void activateSelection(int index);
public: public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -64,8 +64,6 @@ void EpubReaderMenuActivity::loop() {
return; 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; int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(menuItems.size())) { if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast<int>(menuItems.size())) {
selectedIndex = downId; selectedIndex = downId;
+3 -4
View File
@@ -133,15 +133,14 @@ void SettingsActivity::loop() {
} else if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { } else if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) {
// Vertical swipe page-scrolls the list (touch nav without the side buttons). // Vertical swipe page-scrolls the list (touch nav without the side buttons).
// count is settingsCount + 1 because row 0 is the category tab bar. // count is settingsCount + 1 because row 0 is the category tab bar.
const int page = std::max( const int page =
1, UITheme::getNumberOfItemsPerPage(renderer, true, true, BaseTheme::showButtonHints(), false)); std::max(1, UITheme::getNumberOfItemsPerPage(renderer, true, true, BaseTheme::showButtonHints(), false));
mappedInput.wasListScroll(selectedSettingIndex, settingsCount + 1, page); mappedInput.wasListScroll(selectedSettingIndex, settingsCount + 1, page);
requestUpdate(); requestUpdate();
} }
// Tap a settings row to select + activate it. Row 0 is the tab bar, so the list // 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 // is drawn at selectedSettingIndex - 1; map the tapped row back by +1.
// shows it selected; release toggles/activates below.
int downId = -1; int downId = -1;
if (!swiped && mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < settingsCount) { if (!swiped && mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < settingsCount) {
selectedSettingIndex = downId + 1; selectedSettingIndex = downId + 1;
+10
View File
@@ -12,6 +12,10 @@
#include <HalStorage.h> #include <HalStorage.h>
#include <HalSystem.h> #include <HalSystem.h>
#include <HalTiltSensor.h> #include <HalTiltSensor.h>
#if defined(FREEINK_DEVICE_LILYGO) && FREEINK_DEVICE_LILYGO
#include <BoardT5S3.h>
#endif
#include <I18n.h> #include <I18n.h>
#include <Logging.h> #include <Logging.h>
#include <SPI.h> #include <SPI.h>
@@ -367,6 +371,12 @@ void setup() {
#endif #endif
#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(); HalSystem::begin();
// Read-and-clear so a panic later in setup() doesn't loop into silent reboot. // Read-and-clear so a panic later in setup() doesn't loop into silent reboot.