Touch input currently enters the app on the miniquad Stage thread and splits into two dispatch paths with different ordering and different visibility:
miniquad Stage thread
│ touch_event()
▼
┌───────────────────────────────┐
│ handle_touch_sync() (sync) │ BaseEdit: Started+Moved
│ returns true ⇒ event │ Menu: Started+Moved
│ is swallowed entirely │ Button: Started
└──────────────┬────────────────┘
│ if unclaimed
▼
event_pub.notify_touch() → executor hop
│
▼
┌───────────────────────────────┐
│ Window::handle_touch (async) │
│ 1. GestureProcessor::process │ inert: nothing consumes
│ 2. raw handle_touch fallback │ full tree, coord-translated
└───────────────────────────────┘
Five overlapping recognizers exist (see proposal.md - Why): the window-level
GestureProcessor (landed from the earlier attempt, commits 9f16bed6f /
56f1f8c60, currently dead dispatch), and hand-rolled state machines in
ChatView, Menu, BaseEdit, and EmojiPicker. Recognition constants diverge by two
orders of magnitude (tap strictness 0.05px in ChatView/Menu vs 10px in the
processor). handle_gesture cannot propagate past the first Layer and is not
hit-tested. The EMULATE_TOUCH desktop path bypasses the processor entirely.
ui/gesture.rs (two-finger pinch node) is dead code.
Relevant prior decisions: BaseEdit's Stage-thread handling exists for
selection-handle drag latency (its design.md D6 accepted one hop to the
serialized redraw pass); ChatView's scroll already runs fully async with a 20ms
throttle, so the async hop is proven tolerable for scrolling.
Goals:
Non-Goals:
Pinch
recognizer can be added without API break. Secondary touches keep today's
"ignored, cannot disturb the primary" semantics.handle_mouse_*;
EMULATE_TOUCH only gets routed through the session so emulated touches
produce gestures.app-chatview).app-chatview owns that; this change supplies the
recognizer seam it is specified to consume).The window owns a GestureSession: the touch stream, target resolution,
timers, throttling, and arbitration. Recognition is a small library of pure
per-gesture state machines instantiated per accepting node with that node's
GestureCfg.
Alternatives rejected:
GestureDetector style): keeps the targeting/timer/arbitration duplication
this change exists to remove, and inherits the sync-path blindness.This mirrors the convergent design of iOS UIGestureRecognizer/Flutter's
gesture arena: distributed recognizers, one central orchestrator.
The feed point is gfx touch_event, before any sync claiming. A touch that
begins on a sync-claiming widget (BaseEdit) still drives recognition for its
target chain. The recognizer math runs inline (pure, cheap); timers are
executor tasks using the version-guard pattern ChatView/Menu already use.
Down ──▶ (recognition) ──▶ Tap | LongPress | DragStart ─ DragMove* ─ DragEnd
Down/Up are delivered to the hit-tested target immediately, without
recognition, replacing the raw handlers' remaining legitimate uses (press
visuals, precision-grab arming, cleanup). DragEnd carries end velocity;
flick is the consumer's threshold on that velocity (ChatView's
scroll_start_accel · dist/time formula reproduces exactly), so no separate
Flick event is minted.
At Down, the session walks the tree (existing priority ordering) and resolves
the chain of gesture_hit_test passers; all events for that touch id go to that chain
until Up/cancel. A touch that wanders into a sibling mid-gesture does not
hand off — the iOS behavior, strictly more predictable than today's
per-phase re-propagation.
All recognizers in the target chain observe the stream; the first to resolve claims (events delivered to its node), the rest cancel. Tap-vs-drag is mechanical via slop/timeout; child-vs-parent (row tap inside a scrollable menu) resolves as "tap wins within slop, drag wins beyond it" — the standard mobile contract. No Flutter-scale arena politics are needed at this app's complexity.
One source of truth, long_press_timeout() already pulled from Android
ViewConfiguration:
| Constant | Replaces (today) | Value |
|---|---|---|
touch_slop |
10/15/10/10/5/0.5/0.05px scatter | 10px |
tap_max_duration |
300ms / unbounded ×3 | 300ms |
long_press_timeout |
500ms hardcoded / sys ×3, move-triggered | sys, timer-fired |
flick sampling |
40ms window ×2 | 40ms, velocity on DragEnd |
move_delivery_period |
20ms ×2, none ×2 | 20ms (raw samples still collected) |
Per-node config survives only where semantic: axes (y-lock for scrollers),
direction (BaseEdit's vertical/horizontal split), min_travel: 0. for
precision drags.
All gesture delivery is async (executor), like today's handle_touch path.
Rationale: every visual feedback already gates on the serialized redraw pass;
the sync path only accelerates state mutation by one executor hop. ChatView
scroll proves the hop is imperceptible for the highest-frequency gesture.
Risk and fallback in Risks.
The dead code goes first: the ui/gesture.rs pinch node (with its
create_gesture registration and Pimpl::Gesture variant) and win/gesture.rs
(with the dead gesture_proc dispatch in Window::handle_touch) are removed
when the module lands — the new ui/gesture/ directory takes the pinch node's
module path, and Rust rejects gesture.rs and gesture/mod.rs coexisting.
All of it is dead code, so the early deletion is behavior-neutral.
At the end: handle_touch/handle_touch_sync leave UIObject; the four
widget TouchInfo machines are removed.
Constants and stream:
pub struct GestureConstants {
/// Maximum travel between down and up that still counts as a tap.
pub touch_slop: f32,
pub tap_max_duration: u32,
pub long_press_timeout: u32,
pub move_delivery_period: u32,
pub sample_window_ms: u32,
}
pub enum GestureAction {
Down { pos: Point },
Up { pos: Point },
Tap { pos: Point },
LongPress { pos: Point },
DragStart { start: Point },
DragMove { start: Point, prev: Point, curr: Point },
DragEnd { start: Point, curr: Point, vel: Vector },
}
Widget contract on UIObject. gesture_set and gesture_hit_test are new;
handle_gesture already exists as dead dispatch taking the old
win::GestureAction and is re-pointed to the new type when the module lands
(the ui::GestureAction re-export moves from win to gesture in the same
step, or the two collide at ui:: scope). Defaults keep non-participating
nodes inert:
fn gesture_set(&self) -> GestureSet {
GestureSet::NONE
}
fn gesture_hit_test(&self, pos: Point) -> bool {
false
}
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
false
}
GestureSet composes recognizer configs:
pub struct GestureCfg {
pub tap: Option<TapCfg>,
pub long_press: Option<LongPressCfg>,
pub drag: Option<DragCfg>,
}
pub struct TapCfg {
pub axes: Axes,
}
pub struct DragCfg {
pub axes: Axes,
pub direction: Direction,
pub min_travel: f32,
}
Axes (both/y-only/x-only) and Direction (any/vertical/horizontal) encode
the semantic per-widget differences; all numeric thresholds come from
GestureConstants.
Layer forwarding mirrors handle_touch today — subtract the layer origin,
recurse in priority order — and is written once:
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
if !self.is_visible.get() {
return false
}
let mut gesture = gesture;
gesture.translate(-self.rect.get().pos());
for child in self.get_children() {
let obj = get_ui_object3(&child);
if obj.handle_gesture(gesture.clone()).await {
return true
}
}
false
}
Before: handle_touch + handle_touch_sync + handle_mouse_btn_down/up
(~110 lines) simulating mouse events, gated by an atomic mouse_btn_held
flag, with no movement threshold.
After — the entire touch surface:
fn gesture_set(&self) -> GestureSet {
GestureSet::TAP
}
fn gesture_hit_test(&self, pos: Point) -> bool {
self.is_active.get() && self.rect.get().contains(pos)
}
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
let GestureAction::Tap { pos: _ } = gesture else {
return false
};
let node = self.node.upgrade().unwrap();
node.trigger("click", vec![]).await.unwrap();
true
}
The mouse_btn_held gate disappears: down→up within slop is the click
validity check. Mouse handlers remain for desktop. Accepted delta: the
sloppy-drag-that-returns no longer clicks (now slop-bounded, standard).
Before: 65 lines of local TouchInfo { start_pos, start_scroll, is_scroll }
deciding scroll-vs-tap at a 0.5px y threshold.
After:
fn gesture_set(&self) -> GestureSet {
GestureSet::SCROLL_VERT
}
fn gesture_hit_test(&self, pos: Point) -> bool {
self.rect.get().contains(pos)
}
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
match gesture {
GestureAction::DragStart { start } => {
*self.drag_state.lock() = Some((start.y, self.scroll.get()));
true
}
GestureAction::DragMove { curr, .. } => {
let Some((start_y, start_scroll)) = *self.drag_state.lock() else {
return false
};
let scroll = (start_scroll + start_y - curr.y).clamp(0., self.max_scroll());
let atom = &mut self.redraw.make_guard(gfxtag!("EmojiPicker::drag"));
self.scroll.set(atom, scroll);
self.draw_cache.clear();
true
}
GestureAction::Tap { pos } => {
let rect = self.rect.get();
self.click_emoji(pos - rect.pos()).await;
true
}
_ => false,
}
}
Flick inertia for EmojiPicker is an open adoption decision (see Open
Questions); the DragEnd { vel } input makes it a five-line addition.
Before: ~250 lines inline in handle_touch — TouchInfo with a 40ms sample
queue, a long-press timer task with a touch_hold_version guard, 20ms move
throttling, tap forwarding gated on 0.05px y-travel, end_touch_phase
acceleration math, grab-stops-inertia via a 200ms rule.
After: handle_touch disappears; the recognizer supplies the semantics:
fn gesture_set(&self) -> GestureSet {
GestureSet::CHATVIEW
}
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
match gesture {
GestureAction::DragStart { .. } => {
// A grab kills running inertia (today's >200ms rule)
self.speed.store(0., Ordering::Relaxed);
*self.drag_state.lock() = Some(self.scroll.get());
true
}
GestureAction::DragMove { curr, .. } => {
// 1:1 finger scroll via scrollview(), clamped
true
}
GestureAction::DragEnd { vel, .. } => {
// Feed inertia: accel = scroll_start_accel * vel.y
self.speed.fetch_add(accel, Ordering::Relaxed);
self.motion_cv.notify();
true
}
GestureAction::LongPress { pos } => {
// URL toast if on_url, else select_line + select mode
true
}
GestureAction::Tap { pos } => {
// Forward to message (URL/file), else toggle line selection
true
}
_ => false,
}
}
The inertia loop, scroll_resist decay, and the motion task are untouched —
recognition and physics stay separated. PrivMessage/FileMessage handlers
keep their exact code and are invoked from the Tap branch instead of the
inline tap check.
Before: sync Started/Moved + async Ended juggling, TouchInfo +
DragInfo, a cancellable long-press task, double long-press evaluation
(timer during hold and elapsed at end).
After: one LongPress event (single-fire by construction). The hamburger
item-reorder grab stays a Down-armed precision drag — grabbing an icon is a
zero-threshold action, not a recognized gesture:
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
match gesture {
GestureAction::Down { pos } => {
// Arm DragInfo if pos is on the hamburger of a row in edit mode
true
}
GestureAction::DragMove { curr, .. } => {
// Update insert_idx of the armed reorder, invalidate draw
true
}
GestureAction::Up { .. } => {
// Commit reorder if armed and indices differ
true
}
GestureAction::LongPress { .. } => {
// Enter edit mode, fire "edit_active"
true
}
GestureAction::Tap { pos } => {
// handle_selection or X-delete in edit mode
true
}
_ => false,
}
}
Selection-handle dragging needs sub-slop precision and stage-adjacent
latency, so Down arms it and a min_travel: 0. drag recognizer drives it;
long-press and tap move to the session, fixing the still-finger latent bug
(move-triggered long-press today):
fn gesture_set(&self) -> GestureSet {
GestureSet::EDIT
}
async fn handle_gesture(&self, gesture: GestureAction) -> bool {
match gesture {
GestureAction::Down { pos } => {
// try_handle_drag(): grab a selection handle by radius,
// or arm word-select/cursor state
true
}
GestureAction::DragMove { curr, .. } => {
// Handle drag with select+autoscroll, or ScrollVert, or
// SetCursorPos per the armed mode
true
}
GestureAction::LongPress { pos } => {
// start_touch_select(): word select + action menu
true
}
GestureAction::Tap { pos } => {
// touch_set_cursor_pos() + focus_request
true
}
GestureAction::Up { pos } => {
// handle_touch_end(): cursor set, stop autoscroll, focus
true
}
_ => false,
}
}
handle_gesture_sync delivery variant for
Down/DragMove consumers as a contained fallback (D7 does not preclude
it).TapCfg) if field use disagrees.app-chatview] → ChatView migration here is the
proof of composition; if chatview2 lands first, this change skips task 8
and chatview2 consumes the session directly. Decide at task-8 time.GestureAction, recognizers + unit tests), session
(Stage feed, targeting, timers, throttling, arbitration), UIObject
additions and the handle_gesture re-point, Layer forwarding. Raw path
untouched; nothing behavior-visible yet.make compile-dev + EMULATE_TOUCH.make compile-apk + on-device feel pass over: chat
scrolling/flick/grab-stop, URL tap, line select, edit word-select/handles,
menu edit-mode/reorder, emoji scroll, button taps.Rollback: every step is an independent commit reverting to the previous shippable state; step 1 is inert by construction.
DragEnd
{ vel }) is available either way.DragMove with an armed flag (as sketched)
or from a min_travel: 0. drag recognizer like BaseEdit's handles?
Task-level decision, both supported.Pinch recognizer when a
consumer appears (image zoom is the plausible one). Deferred by design.