applib: touch drag-to-scroll and tap-to-select for ScrollLayer / MenuLayer - #1773
applib: touch drag-to-scroll and tap-to-select for ScrollLayer / MenuLayer#1773tsutomu1984-hub wants to merge 2 commits into
Conversation
On touch platforms (CONFIG_TOUCH), subscribe to the touch service from the scroll layer's click config provider, so a focused scroll layer receives touch events by riding along on the existing click setup without any new public API. A vertical drag past a 10px threshold scrolls the content, hard-clamped to the content bounds (honoring clips_content_offset); any running scroll animation is stopped on touchdown so the content follows the finger. A near-stationary touch that lifts off within 300ms is dispatched as a tap to an optional internal handler, letting higher-level layers (e.g. MenuLayer) react to taps. Gesture state and the active tap handler are file-static: a single physical touchscreen means only one gesture is ever in flight, so no per-layer storage (and no ABI-affecting struct growth) is needed. The subscription is torn down in scroll_layer_deinit to avoid a dangling reference to a destroyed layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: tsutomu sasaki <tsutomu1984@gmail.com>
Register a tap handler on the menu's scroll layer (CONFIG_TOUCH). A tap is mapped from its content-space Y coordinate to a row by walking the existing cell iterator; section headers and separator gaps are ignored. A tapped row is selected via menu_layer_set_selected_index and then fires the same select_click callback as a physical SELECT press. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: tsutomu sasaki <tsutomu1984@gmail.com>
|
really cool! any videos of this in action? |
jplexer
left a comment
There was a problem hiding this comment.
Existing apps that use these widgets get this for free
we should never change existing runtime behavior for third party apps if we can avoid it so this is definitely a no-go. having this enable-able (via code) and turning it on for system apps would be better imho
|
Oh, this is awesome! I'd been designing touch scrolling myself but hadn't gotten around to implementing it yet. 1)Wake-up. The screen always shows the menu, but the backlight times out — so a tap that's just meant to wake the watch also fires select_click on whatever row is under your finger. Maybe a touch that woke the screen itself shouldn't count as input? Otherwise it's easy to hit the wrong thing. 2)selection_will_change. The tap handler calls menu_layer_set_selected_index() + select_click directly, but selection_will_change only fires from the wrap path (menu_layer.c:138) and set_selected_next (:1308) — never from set_selected_index. Because of that, a finger can select a row the buttons can't (like Timezone in settings/time.c:403-409). Shouldn't the tap honor that veto too? 3)Small thing with the tap coordinate. You take it from the Liftoff, but on recovery the driver sometimes emits FingerUp with (0,0) (cst816.c:304,314) — and then the tap flies off to the top row. Safer to take it from the last PositionUpdate? 4)touch_service_unsubscribe() in scroll_layer_deinit. It's called unconditionally, but there's only one subscriber per task — so deiniting one layer tears down the subscription of another, still-focused one. You clear s_touch_gesture under a condition, but not the unsubscribe; probably worth making it symmetric. 5)About notifications. Looks like they don't get covered here — they're on SwapLayer, not ScrollLayer. Same with the action menu (ActionMenuLayer) — it has its own click config provider (action_menu_layer.c:742), so it bypasses scroll_layer_click_config_provider and won't get touch either. Overall the approach is great, but my hunch is that covering all of this properly (the wake gate, the row veto, notifications, the action menu) would be easier on the recognizer framework (applib/ui/recognizer/) — that's exactly what it's for. Though it needs fixing first: there's a use-after-free in recognizer_reset — flags = 0 wipes the is_owned bit. Maybe it's worth building on that directly? |
vid__touch.drag-to-scroll.and.tap-to-select.mp4combined |
Replace the tap recognizer stub with a single-tap state machine. From a Possible state a Touchdown records the contact point and time; a PositionUpdate that drifts more than the movement budget in either axis fails the gesture (a drag, not a tap); a Liftoff within the max duration completes and reports the tap coordinate, otherwise it fails as a hold. The reported tap coordinate is the last PositionUpdate point (falling back to the Touchdown point when there was none), so a Liftoff at (0,0) does not corrupt it. Duration is measured from rtc_get_ticks(), the same clock the touch driver uses. The mid-gesture reset and wake gating stay the manager's responsibility; prv_reset only clears the per-gesture state. The duration (300ms) and movement (10px) budgets come from the reference PT2 touch-nav gesture spec. Add an internal tap_recognizer_get_tap_point() getter so the coordinate is observable, and unit tests covering completion, the drag and hold failure paths, the movement boundary, the liftoff-at-(0,0) case, and reset re-arming. Ref: coredevices#1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
Add continuous pan and discrete swipe recognizers on top of the recognizer engine and manager, modeled on the tap recognizer. Pan locks to a single axis: from Possible it Starts only when the finger moves unambiguously along the locked axis (the dominant axis exceeds the other by a dominance factor) AND crosses the start threshold; it Fails if the finger instead moves unambiguously along the foreign axis, and stays Possible while the direction is ambiguous. It exposes the total delta from touchdown and, separately, the delta since Start (exactly zero at the instant Started fires, so live scroll does not jump), plus the delta since the previous event and a velocity. Swipe carries a direction mask and stays passive until liftoff, failing early if the path grows too crooked (minor-axis projection past half the major once it has moved beyond the straightness minimum) or too slow. On liftoff it completes only if the path is long enough, fast enough, has a clear dominant axis, and its direction is in the mask. Both measure time from rtc_get_ticks(), take component-wise deltas from touchdown, compute velocity over the most-recent samples within a short window (zero when the elapsed time is zero, so there is no divide-by-zero), and ignore the liftoff coordinates (the driver's finger-up recovery reports (0, 0), which would otherwise fabricate an up-left gesture). Tests cover the pan start ordering and anti-jump, swipe direction/failure paths, the zero-dt and liftoff-at-origin edge cases, and gesture competition through the real recognizer manager (a vertical drag starts the pan while swipe and tap fail; a fast horizontal completes the swipe). The duration and movement budgets come from the reference PT2 touch-nav gesture spec. Ref: coredevices#1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
Make MenuLayer a Tier-1 touch widget: finger scrolling with live content offset, and one-step tap activation, on top of the recognizer stack. MenuLayer carries an 8-byte intrusive registry node (padding shrunk 40->32 so the struct size is unchanged on every board, guarded by a static assert). It registers in menu_layer_init and deregisters in menu_layer_deinit; the legacy 2.x path is not registered and falls back to the button bridge. Deinit clears the gesture target before cancelling, so a widget torn down mid-gesture under a live window cannot leave the gesture state pointing at freed memory; double deinit and re-init without deinit are safe. A pan drives the content offset live (base captured at Started, throttled), clamped so short content and centre-focused menus cannot scroll into garbage; the selection index is frozen during the pan (cell height depends on it) and only changes on liftoff. Liftoff, tap and cancel all run the full selection_will_change contract and honour every outcome: a veto keeps the old selection and does nothing, a redirected index selects without activating, and the requested index selects (tap activates only when the final selection is the tapped row). This veto is the deliberate fix for the upstream tap that could select a row the buttons cannot reach. A right swipe activates; a left swipe emulates BACK. The tap point arrives in screen coordinates, so it is mapped through the scroll layer's global frame before hitting the content; without that a menu inset below the status bar mis-hits every row by the inset height. Hit-testing rewritten from the reference implementation, extended to run the selection_will_change veto the reference omitted. Ref: coredevices#1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
@jplexer Thanks for the review — the concern makes sense, and I agree that silently changing the runtime behaviour of existing third-party apps isn't acceptable. Before I rework this, I'd like to check which shape you'd prefer, because one of the options isn't really available: Adding a flag to the layer structs is not possible without breaking the ABI. What I'd propose instead is to gate the subscription at runtime on whether the calling process is a system app, so If you'd rather have an explicit opt-in API that third-party apps can call as well (with the default being off), I'm happy to do that instead — I just wanted to avoid designing a new public API before knowing whether you want one. Separately, I've addressed the concrete issues @vvzvlad raised (the fabricated |
Replace the tap recognizer stub with a single-tap state machine. From a Possible state a Touchdown records the contact point and time; a PositionUpdate that drifts more than the movement budget in either axis fails the gesture (a drag, not a tap); a Liftoff within the max duration completes and reports the tap coordinate, otherwise it fails as a hold. The reported tap coordinate is the last PositionUpdate point (falling back to the Touchdown point when there was none), so a Liftoff at (0,0) does not corrupt it. Duration is measured from rtc_get_ticks(), the same clock the touch driver uses. The mid-gesture reset and wake gating stay the manager's responsibility; prv_reset only clears the per-gesture state. The duration (300ms) and movement (10px) budgets come from the reference PT2 touch-nav gesture spec. Add an internal tap_recognizer_get_tap_point() getter so the coordinate is observable, and unit tests covering completion, the drag and hold failure paths, the movement boundary, the liftoff-at-(0,0) case, and reset re-arming. Ref: #1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
Add continuous pan and discrete swipe recognizers on top of the recognizer engine and manager, modeled on the tap recognizer. Pan locks to a single axis: from Possible it Starts only when the finger moves unambiguously along the locked axis (the dominant axis exceeds the other by a dominance factor) AND crosses the start threshold; it Fails if the finger instead moves unambiguously along the foreign axis, and stays Possible while the direction is ambiguous. It exposes the total delta from touchdown and, separately, the delta since Start (exactly zero at the instant Started fires, so live scroll does not jump), plus the delta since the previous event and a velocity. Swipe carries a direction mask and stays passive until liftoff, failing early if the path grows too crooked (minor-axis projection past half the major once it has moved beyond the straightness minimum) or too slow. On liftoff it completes only if the path is long enough, fast enough, has a clear dominant axis, and its direction is in the mask. Both measure time from rtc_get_ticks(), take component-wise deltas from touchdown, compute velocity over the most-recent samples within a short window (zero when the elapsed time is zero, so there is no divide-by-zero), and ignore the liftoff coordinates (the driver's finger-up recovery reports (0, 0), which would otherwise fabricate an up-left gesture). Tests cover the pan start ordering and anti-jump, swipe direction/failure paths, the zero-dt and liftoff-at-origin edge cases, and gesture competition through the real recognizer manager (a vertical drag starts the pan while swipe and tap fail; a fast horizontal completes the swipe). The duration and movement budgets come from the reference PT2 touch-nav gesture spec. Ref: #1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
Make MenuLayer a Tier-1 touch widget: finger scrolling with live content offset, and one-step tap activation, on top of the recognizer stack. MenuLayer carries an 8-byte intrusive registry node (padding shrunk 40->32 so the struct size is unchanged on every board, guarded by a static assert). It registers in menu_layer_init and deregisters in menu_layer_deinit; the legacy 2.x path is not registered and falls back to the button bridge. Deinit clears the gesture target before cancelling, so a widget torn down mid-gesture under a live window cannot leave the gesture state pointing at freed memory; double deinit and re-init without deinit are safe. A pan drives the content offset live (base captured at Started, throttled), clamped so short content and centre-focused menus cannot scroll into garbage; the selection index is frozen during the pan (cell height depends on it) and only changes on liftoff. Liftoff, tap and cancel all run the full selection_will_change contract and honour every outcome: a veto keeps the old selection and does nothing, a redirected index selects without activating, and the requested index selects (tap activates only when the final selection is the tapped row). This veto is the deliberate fix for the upstream tap that could select a row the buttons cannot reach. A right swipe activates; a left swipe emulates BACK. The tap point arrives in screen coordinates, so it is mapped through the scroll layer's global frame before hitting the content; without that a menu inset below the status bar mis-hits every row by the inset height. Hit-testing rewritten from the reference implementation, extended to run the selection_will_change veto the reference omitted. Ref: #1773 Co-authored-by: tsutomu1984-hub <307831185+tsutomu1984-hub@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Vlad Zaytsev <git@vvzvlad.xyz>
|
@jplexer you merged parts of this right? Should we close this one out? |
Summary
On touch-enabled platforms, make
ScrollLayerandMenuLayerrespond totouch: a vertical drag scrolls the content, and a tap selects a menu
row (equivalent to a physical SELECT press). Existing apps that use these
widgets get this for free — no app changes, no new settings, and no public SDK
surface is added.
Motivation
Pebble Time 2 (and other
CONFIG_TOUCHboards) have a touchscreen, but thecore scrolling widgets were button-only. This adds the two most common touch
interactions — scroll and select — at the widget level, so the bulk of the
system UI and third-party apps built on
ScrollLayer/MenuLayerbecometouch-usable without modification.
Approach
CONFIG_TOUCH(the firmware Kconfig symbol, as used by theneighbouring
layer.c/event_loop.c), so the code is compiled out onnon-touch boards.
inside its existing click-config provider
(
scroll_layer_click_config_provider), which already runs on every windowappear. This means the focused scroll layer re-subscribes as the touch
handler exactly when it takes over the buttons, and it needs no change to the
app-facing API.
ScrollLayer (drag + tap dispatch)
running scroll animation, so the content follows the finger from where it
was.
threshold; past that the gesture is a confirmed drag and ΔY is applied to the
content offset via the existing internal setter, which hard-clamps to the
content bounds (honouring
clips_content_offset), matching button scrolling.it is dispatched as a tap to an optional internal handler; a confirmed drag
just finalises the position.
scroll_layer_deinit()so a destroyedlayer can never be referenced by a pending touch event.
MenuLayer (tap-to-select)
content-space Y coordinate is mapped to a row by walking the existing cell
iterator. Section headers and separator gaps are ignored. A hit row is
selected via
menu_layer_set_selected_index()and then fires the sameselect_clickcallback as a physical SELECT press — identical handling, noseparate code path.
File-scope static gesture state (design premise)
The in-flight gesture state and the active tap handler are held in file-scope
statics, not in the
ScrollLayer/MenuLayerstructs. This relies on adeliberate premise:
Because of this, per-layer storage is unnecessary — and, importantly, it keeps
the widget structs unchanged: no struct growth and therefore no ABI impact
(
applib_malloc.jsonsizes and thesize_2x/size_3xstatic asserts areuntouched, which matters because
MenuLayercurrently has zerosize_2xheadroom).
Watchface restriction: not affected
This targets watchapp contexts only and does not interfere with the touch
service's watchface restriction. The touch service already returns no per-task
state for watchfaces (
sys_app_is_watchface()), so aScrollLayer/MenuLayerliving in a watchface simply subscribes to nothing — the behaviour is inert
there by construction. The existing Settings → Display → Touch toggle
(
touch_service_set_globally_enabled) continues to gate touch globally; no newsetting is introduced.
Out of scope for v1 (open problems)
These are intentionally not implemented and left as follow-ups:
(subscription is decided at window-appear time).
page-align.
A separate, independent line of work (kept on a different branch) explores a
kernel-level "gesture → button synthesis" bridge to help apps that use their
own click handlers instead of
ScrollLayer/MenuLayer; it is not part ofthis PR and does not touch this code.
Testing
qemu_emery(touch),qemu_flint(non-touch, code correctlycompiled out), and
obelix@pvt/@slot1(real Pebble Time 2 target) allbuild cleanly; the feature's symbols are present on touch targets and absent
on non-touch.
vertical scrolling works in the launcher / Settings menus.
covered by a standalone check.
qemu-pebblebuild does not raise the touch IRQ, so theend-to-end touch path could not be exercised purely in QEMU; verification was
done on real hardware plus the logic checks above.
Compatibility
exported_symbols.jsonand the SDK revisionare unchanged); the only added function is
@internaland used solely byMenuLayer.ScrollLayer/MenuLayerapps benefit unmodified.