Touch and UI Development #
CrossPoint runs on touch devices (Seeed Sticky, M5Paper, M5Stack PaperMono, LilyGo T5, Xteink X4 Pro) alongside the button-only Xteink X3/X4. Every screen must work with both input styles.
There is one supported way to build a new screen: FreeInkUI, hosted through the firmware base classes below. Touch hit-testing, tap highlighting, long-press, swipe scrolling, and button focus navigation all come from the shared stack; you never hand-roll coordinate math.
The old bridge helpers (rowTouch, colTouch, wasTapInRect, manual rect contains() checks) are legacy. They survive only for the two remaining hand-rolled surfaces (the theme-driven home screen and the reader page) and must not appear in new code. PRs that add new uses will be asked to convert.
Picking the right host #
| Your screen is... | Use | In-tree reference |
|---|---|---|
| A single list of rows | subclass UiListActivity |
LanguageSelectActivity (minimal), RecentBooksActivity (long-press) |
| Tabbed lists | subclass UiTabListActivity |
SettingsActivity |
| Custom FUI layout (sliders, prompts, state machines) | inherit UiAppHost directly |
EpubReaderPercentSelectionActivity, WifiSelectionActivity |
| A modal picker or confirm inside a legacy activity | OptionPopup (or push ConfirmationActivity) |
OtaUpdateActivity |
| A Yes/No prompt inside a FUI screen | build fui::optionDialog into the screen |
WifiSelectionActivity::buildPromptDialog |
All of these paths route input through the same SDK interaction table, so touch and physical buttons fire the same actions with no per-screen coordinate code.
The hosting stack #
UiAppHost (src/components/UiAppHost.h) owns what every FUI screen shares: the font-bound render target, the FreeInkApp, and the uiReady handshake that lets the loop task route touch snapshots against the interaction table the render task rebuilds. Never re-implement that handshake; it is a cross-task protocol and lives in exactly one place.
UiListActivity (src/activities/UiListActivity.h) layers the list protocol on top: swipes scroll the viewport without moving the selection, buttons move the selection and pull the viewport along (fui::ListNav), plus the render skeleton (header chrome, app, footer hints). UiTabListActivity is the tab-bar sibling; its selection ring puts the tab bar at position 0 and the rows after it.
New list screen skeleton #
Subclasses supply the data; the base owns the loop. The whole contract:
class MyListActivity final : public UiListActivity {
public:
MyListActivity(GfxRenderer& r, MappedInputManager& in) : UiListActivity("MyList", r, in) {}
private:
std::vector<Entry> entries; // the activity's data, loaded in onEnter
std::vector<fui::ListItem> rows; // activity-owned row cache: rebuilt only when entries changes, reused every render
int listCount() const override { return entries.size(); }
const char* headerTitle() const override { return tr(STR_MY_TITLE); }
void onEnter() override {
UiListActivity::onEnter();
entries = /* ... load from wherever ... */;
rebuildRows();
}
// Called whenever entries changes (here, only onEnter; a mutable list would
// also call this after any add/remove). NOT called from buildScreen().
void rebuildRows() {
rows.clear();
rows.reserve(entries.size());
// ... one fui::ListItem per entry (label, actionValue = index) ...
}
void buildScreen(UiScreen& screen) override {
// set content margin from the theme safe area, then:
fui::ListProps props;
props.items = rows.data();
props.count = rows.size();
props.action = ACTION_ROW;
props.inputMask = fui::InputTouch; // physical buttons stay in the base loop
syncListViewport(screen, props); // selection/viewport handoff, always right before list()
screen.list(props);
}
void activateIndex(int index) override {
app.clearTapFlash(); // leaving the screen: a lingering flash would gray the next render
// ... open the thing ...
}
};
See FileBrowserActivity's rebuildRowItems() for this pattern applied to a directory listing that can run into the hundreds of entries, and LanguageSelectActivity.cpp for the rest of the skeleton (content-margin math, footer, etc.) — note that file still builds its row vector locally inside buildScreen(); match FileBrowserActivity, not that file, for the row cache. Optional overrides: onRowLongPress(index), drawFooter(), handleButtons() for extra physical-button handling, and ACTION_USER-and-up action ids for non-row elements (register handlers in onEnter after the base's).
Rules that apply to every FUI screen #
- Register actions with
app.on(...)inonEnter(afterresetUi()for directUiAppHostusers). Handlers receive avoid* useryou cast back to the activity. - Handlers that leave the current screen call
app.clearTapFlash()first. - Theme tokens are shared and bound by
resetUi(); never callapp.setThemeyourself. Metrics flow from the active UITheme throughUIThemeTokens.h, including the per-board bezel insets that keep scrollbars visible. TextStyle.maxLinesdefaults to 1 and truncates with an ellipsis. SetmaxLinesexplicitly on any dialog headline or message that can wrap.- Everything stays allocation-free in steady state. A local
std::vectorinsidebuildScreen()is not allocation-free even withreserve()first: it starts at zero capacity on every call,reserve()allocates, and the destructor frees that storage before the call returns — real allocator work and fragmentation risk on every repaint (cursor move, tap flash, ...), not just on data changes. BuildListItemrows into activity-owned storage instead, reserved once when the underlying data loads (onEnter()/aload*()— see the skeleton above andFileBrowserActivity::rebuildRowItems()), and reused unchanged by everybuildScreen()call. Use a fixed-capacity array (e.g.ListItem rows[MAX], asOptionPopupandKOReaderSyncActivity's action rows do) when the count is small and bounded. Do not hold FUIpropsacross renders — only the row storage they point into.
Component inventory #
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 (Latin, Cyrillic and Hebrew 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 |
One exception to "always go through a host": KeyboardEntryActivity drives the keyboard component with a raw fui::Frame and TouchHoldRouter because per-key hold repeat needs its own routing. If you think your screen needs that, raise it in the PR first.
Global gestures: do not reimplement these #
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.
Legacy bridge helpers (do not use in new code) #
MappedInputManager still exposes raw touch accessors. The FUI stack consumes them internally (via touchSnapshotFrom in UiAppHelpers.h); activities should not.
| Helper | Status |
|---|---|
wasScreenTapped / wasScreenTouchDown / isScreenTouchHeld |
Consumed by the FUI snapshot builder. Direct use only in the two legacy surfaces |
wasTapInRect(x, y, w, h) |
Legacy one-off hit test |
rowTouch / colTouch |
Legacy row/column band math. Sole remaining user: HomeActivity (theme-driven layout) |
wasSwipe() |
Raw swipe direction, for behavior beyond the global gestures (reader page turns) |
hasTouch() |
Still fine anywhere: gate touch-only chrome (on-screen Cancel/OK pairs) on it |
The wasListItemTapped / wasListItemTouchedDown helpers from the button-era bridge have been removed; every themed list is a UiListActivity now. If you are porting a branch that still calls them, convert the screen to UiListActivity rather than resurrecting the helpers.
As with all input: never call the SDK InputManager or read GPIO directly. The HAL rule from the main guide applies to touch too.
Building and testing on non-Xteink devices #
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.
M5Paper v1.1 (classic ESP32, IT8951 panel) #
[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
LilyGo T5 S3 (ESP32-S3, controller-less panel via LovyanGFX) #
[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.20
Then 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.