CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
- New screens: build them with FreeInkUI components. Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
- Existing screens and in-flight features: use the MappedInputManager touch bridge. A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
FreeInkUI (freeink-sdk/libs/ui/FreeInkUI, namespace freeink::ui) is an immediate-mode component library. The core idea:
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via
Frame::hit(rect, action, value, inputMask, state). - Each loop you build an
InputSnapshotfrom the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routedActionEventtells you which action fired and with what value. - Components query
frame.stateFor(action, value)while painting, so touch-down highlight and focus states render correctly without any per-activity code.
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
All under freeink-sdk/libs/ui/FreeInkUI/include/components/:
| Category | Components |
|---|---|
| Controls | button, checkbox, slider, progress-bar, header |
| Lists | list (virtualized), table, dropdown, radio-group, setting-row, toggle-row, stepper-row |
| Keyboard | keyboard (QWERTY/AZERTY/QWERTZ/ES layouts), key-grid |
| Overlays | popup, option-dialog, context-menu, message-panel, toast |
| Bars | status-bar, tab-bar, reader-chrome, battery-indicator, gesture-bar, tap-zones |
| Media | book-card, cover-grid, cover-carousel, metric-card |
| Text | text-field, text-area |
The in-tree reference is src/activities/util/KeyboardEntryActivity.cpp; it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
#include <FreeInkUIGfxRenderer.h>
namespace fui = freeink::ui;
// Render step: draw the component and (re)register its hit rects.
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
fui::KeyboardProps props;
props.layout = ¤tLayout();
props.keyAction = ACTION_KEY; // one action id; the key is the event value
props.inputMask = fui::InputTouch | fui::InputLongPress;
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects// Input step (each loop): feed touch state, act on routed events.
int tx, ty, tapX, tapY, hx, hy;
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
tapped, tapX, tapY, inContact, millis());
if (result.event) {
activateValue(result.event.value, result.event.longPress);
requestUpdate();
} else if (result.activeChanged) {
requestUpdate(); // repaint touch-down highlight
}Notes:
Frame<N>is templated on the max number of hit rects; the buffer is stack/static, no heap.fui::TouchHoldRouter(seecomponents/keyboard/keyboard.h) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.- For simpler screens without long-press,
FreeInkApp/Screen<N>(FreeInkApp.h) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (takeTop, footer helpers, etc.), and itsrender(input)call routes the snapshot and dispatches registered action callbacks in one step.snapshotFrom(input, device)inFreeInkUIInputManager.hbuilds theInputSnapshotwith orientation-aware touch mapping. - Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's
inputMask.
For activities that draw their own rows, menus, and buttons, MappedInputManager (src/MappedInputManager.h) exposes bridge helpers. They all return logical screen coordinates (orientation already applied via GfxRenderer::tapToLogical); never touch raw normalized panel coordinates or the SDK InputManager directly.
| Helper | Use for |
|---|---|
wasScreenTapped(x, y) |
A completed tap (press + release), with logical coords |
wasScreenTouchDown(x, y) |
Touch-down (held > 90 ms, not yet released): draw selection highlight |
isScreenTouchHeld(x, y) |
Live contact position while the finger is down (drag tracking) |
wasTapInRect(x, y, w, h) |
One-off hit test on a rectangle (a single button, a banner) |
wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle) |
Taps on a standard UITheme list; does the row/paging math for you |
wasListItemTouchedDown(...) |
Same geometry, touch-down phase (highlight before activate) |
rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight) |
Any custom band of equal-height rows; returns RowTouch::None/Down/Tap |
colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth) |
Horizontal button bands (dialogs, prompts) |
wasSwipe() |
Raw swipe direction if you need one beyond the global gestures |
hasTouch() |
True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
One call per loop; UITheme owns the row geometry (EpubReaderBookmarksActivity.cpp:123):
int tapped = -1;
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
selectorIndex = tapped;
openBookmark();
return;
}For non-theme row layouts, use rowTouch and distinguish Down (highlight) from Tap (activate), as in EpubReaderFootnotesActivity.cpp:
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = scrollOffset + row;
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
} else { // RowTouch::Tap
selectedIndex = touched;
activateSelection();
}
return;
}The Down state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
| Gesture | Trigger | Where it is handled |
|---|---|---|
| Back | Right-swipe starting in the left 25% of the screen | Folded into Button::Back, so the existing wasPressed(Button::Back) in your activity already fires |
| Home | Up-swipe starting in the bottom 14% | ActivityManager::loop(); pops to Home (activities can override via handleHomeGesture()) |
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check wasMenuGesture() themselves (the reader does this) |
Because the back gesture arrives as Button::Back, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
- Handle touch in
loop()next to the existing button handling, one helper call per interaction zone, andreturnafter consuming an event (mirrors the button pattern). - Never call the SDK
InputManageror read GPIO directly; the HAL rule from the main guide applies to touch too. - Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
- Nothing to clean up in
onExit(); the helpers are stateless from the activity's point of view.
Each MCU family is its own binary: X3/X4 are ESP32-C3, Sticky and LilyGo T5 are ESP32-S3, M5Paper v1.1 is a classic ESP32. The Sticky env ships in platformio.ini (pio run -e sticky). Envs for other devices go in platformio.local.ini, a gitignored file that PlatformIO merges over platformio.ini (see extra_configs). Create it next to platformio.ini; personal envs, ports, and debug flags live there and never get committed.
Both envs below extend the repo's [base], so they build against the freeink-sdk submodule with all the normal deps and scripts.
[env:m5paper_v11]
extends = base
board = esp32dev
board_build.mcu = esp32
board_build.flash_mode = qio
; CP2104 UART bridge: 921600 drops out on macOS after the stub baud switch
upload_speed = 460800
build_unflags =
${base.build_unflags}
; classic ESP32 has UART serial, not USB CDC; Logging.h keys off these
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_M5PAPER=1
; the 63KB 540x960 framebuffer lives in PSRAM (FREEINK_FB_PSRAM auto-on)
-DBOARD_HAS_PSRAM
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
; touch-first device: hide front-button hint labels
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
; archive-scan-order workaround: without these a full relink drops Wire's i2c symbols
-Wl,-u,i2cInit
-Wl,-u,i2cSlaveInit[env:lilygo_t5s3]
extends = base
board = esp32-s3-devkitc1-n16r8
board_build.mcu = esp32s3
build_flags =
${base.build_flags}
-DFREEINK_DEVICE_LILYGO=1
; board injects the parallel-bus pins + PMIC power hooks (BoardT5S3)
-DFREEINK_LGFX_EPD_CONFIG=lilygoT5S3LgfxConfig
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
lib_deps =
${base.lib_deps}
; LgfxEpdConfig for the T5 S3 (pins, PCA9535/TPS65185 power sequence)
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
; LovyanGFX Panel_EPD drives the controller-less ED047TC1 panel
m5stack/M5GFX @ 0.2.20Then pio run -e m5paper_v11 -t upload (or -e lilygo_t5s3). Gotchas worth knowing:
- Flash mode matters. The M5Paper is
qio; the X4-family standalone envs needdio. A wrong flash-mode header boots into apartition 0 invalid magic number 0xffffloop even though esptool verified the write. - One
FREEINK_DEVICE_*flag per env selects the board profile (pins, panel, touch controller) from the SDK'sBoardConfig. Seefreeink-sdk/platformio.sample.inifor reference envs of every supported device. - Serial logs:
[base]does not enable logging; without-DENABLE_SERIAL_LOGa non-default env prints nothing. - No touch hardware on your desk? The X4 build still exercises the same code paths through buttons; touch-specific behavior (tap zones, gestures) needs a real device.