Browse Source

app: migrate from raw touch events to a gesture recognizer which makes touch handling more consistent, robust, easier to use and clearer code.

darkfi 2 tuần trước cách đây
mục cha
commit
731154c340
36 tập tin đã thay đổi với 2731 bổ sung2254 xóa
  1. 2 1
      bin/app/src/app/mod.rs
  2. 1 17
      bin/app/src/app/node.rs
  3. 13 3
      bin/app/src/app/schema/chat.rs
  4. 2 1
      bin/app/src/app/schema/menu/edit_buttons.rs
  5. 3 0
      bin/app/src/app/schema/wallet/main.rs
  6. 1 12
      bin/app/src/gfx/ev.rs
  7. 2 2
      bin/app/src/gfx/linalg.rs
  8. 4 7
      bin/app/src/gfx/mod.rs
  9. 0 2
      bin/app/src/scene.rs
  10. 22 4
      bin/app/src/text/editor/android.rs
  11. 13 47
      bin/app/src/ui/button.rs
  12. 108 246
      bin/app/src/ui/chatview/mod.rs
  13. 10 2
      bin/app/src/ui/chatview/page.rs
  14. 11 0
      bin/app/src/ui/edit/action.rs
  15. 387 233
      bin/app/src/ui/edit/mod.rs
  16. 39 71
      bin/app/src/ui/emoji_picker/mod.rs
  17. 0 122
      bin/app/src/ui/gesture.rs
  18. 258 0
      bin/app/src/ui/gesture/mod.rs
  19. 508 0
      bin/app/src/ui/gesture/recognizer.rs
  20. 850 0
      bin/app/src/ui/gesture/session.rs
  21. 29 9
      bin/app/src/ui/layer.rs
  22. 81 175
      bin/app/src/ui/menu/mod.rs
  23. 25 10
      bin/app/src/ui/mod.rs
  24. 10 6
      bin/app/src/ui/scroll_layer.rs
  25. 94 26
      bin/app/src/ui/tokentable/mod.rs
  26. 0 205
      bin/app/src/ui/win/gesture.rs
  27. 47 92
      bin/app/src/ui/win/mod.rs
  28. 89 12
      openspec/changes/app-chatview/design.md
  29. 12 3
      openspec/changes/app-chatview/proposal.md
  30. 68 10
      openspec/changes/app-chatview/specs/chatview/spec.md
  31. 42 24
      openspec/changes/app-chatview/tasks.md
  32. 0 2
      openspec/changes/app-gesture/.openspec.yaml
  33. 0 517
      openspec/changes/app-gesture/design.md
  34. 0 82
      openspec/changes/app-gesture/proposal.md
  35. 0 222
      openspec/changes/app-gesture/specs/gesture/spec.md
  36. 0 89
      openspec/changes/app-gesture/tasks.md

+ 2 - 1
bin/app/src/app/mod.rs

@@ -39,7 +39,7 @@ use crate::{
 
 pub mod locale;
 use locale::read_locale_ftl;
-mod node;
+pub mod node;
 use node::create_window;
 pub mod schema;
 
@@ -136,6 +136,7 @@ impl App {
                     me,
                     self.renderer.clone(),
                     i18n_fish.clone(),
+                    self.ex.clone(),
                     self.redraw_trigger.clone(),
                     self.redraw_rx.clone(),
                 )

+ 1 - 17
bin/app/src/app/node.rs

@@ -156,23 +156,6 @@ pub fn create_shortcut(name: &str) -> SceneNode {
     node
 }
 
-#[allow(dead_code)]
-pub fn create_gesture(name: &str) -> SceneNode {
-    let mut node = SceneNode::new(name, SceneNodeType::Gesture);
-
-    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null);
-    node.add_property(prop).unwrap();
-
-    node.add_signal(
-        "gesture",
-        "Gesture triggered",
-        vec![("distance", "Distance", CallArgType::Float32)],
-    )
-    .unwrap();
-
-    node
-}
-
 #[allow(dead_code)]
 pub fn create_image(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::Image);
@@ -529,6 +512,7 @@ pub fn create_baseedit(name: &str) -> SceneNode {
     node.add_method("insert_text", vec![("text", "Text", CallArgType::Str)], None).unwrap();
     node.add_method("focus", vec![], None).unwrap();
     node.add_method("unfocus", vec![], None).unwrap();
+    node.add_method("hide_ime", vec![], None).unwrap();
 
     node
 }

+ 13 - 3
bin/app/src/app/schema/chat.rs

@@ -726,6 +726,9 @@ pub async fn make(
     prop.set_f32(atom, Role::App, 3, DOWNARROW_H).unwrap();
     down_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
     down_layer.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    // The arrow floats over the chatview (priority 0) — it must be
+    // hit-tested first.
+    down_layer.set_property_u32(atom, Role::App, "priority", 1).unwrap();
     let down_layer = down_layer.setup(|me| Layer::new(me, renderer.clone(), redraw.clone())).await;
     layer_node.link(down_layer.clone());
 
@@ -1352,7 +1355,9 @@ pub async fn make(
     prop.set_f32(atom, Role::App, 2, EMOJIBTN_BOX[2]).unwrap();
     prop.set_f32(atom, Role::App, 3, EMOJIBTN_BOX[3]).unwrap();
 
-    // Chatedit is clicked and requests keyboard. Only show if emoji picker isnt visible.
+    // Chatedit is clicked and requests keyboard. Only show if emoji
+    // picker isnt visible: while the panel is open, tapping the edit
+    // just moves the cursor.
     let (slot, recvr) = Slot::new("reqkeyb");
     chatedit_node.register("focus_request", slot).unwrap();
     let chatedit_node2 = chatedit_node.clone();
@@ -1397,8 +1402,10 @@ pub async fn make(
             }
 
             if emoji_btn_is_visible.get() {
-                // Open emoji panel and close IME keyboard
-                chatedit_node2.call_method("unfocus", vec![]).await.unwrap();
+                // Open emoji panel and hide the keyboard. The edit
+                // stays focused so its cursor remains visible; only
+                // the IME is detached.
+                chatedit_node2.call_method("hide_ime", vec![]).await.unwrap();
 
                 assert!(!emoji_close_is_visible.get());
                 assert!(emoji_h_prop.get() < 0.001);
@@ -1460,6 +1467,9 @@ pub async fn make(
     prop.add_depend(&cmd_vis_rows_prop, 0, "vis_rows");
     cmd_layer_node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
     cmd_layer_node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
+    // The hint popup opens over the chatview (priority 0) — it must be
+    // hit-tested first.
+    cmd_layer_node.set_property_u32(atom, Role::App, "priority", 1).unwrap();
     let cmd_layer_node =
         cmd_layer_node.setup(|me| Layer::new(me, renderer.clone(), redraw.clone())).await;
     layer_node.link(cmd_layer_node.clone());

+ 2 - 1
bin/app/src/app/schema/menu/edit_buttons.rs

@@ -105,7 +105,8 @@ pub async fn create_edit_buttons(
     prop.set_expr(atom, Role::App, 2, code).unwrap();
     prop.set_f32(atom, Role::App, 3, MENU_BTN_H).unwrap();
     node.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
-    node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
+    // The edit-mode bar floats above the version block (z_index 3)
+    node.set_property_u32(atom, Role::App, "z_index", 4).unwrap();
     node.set_property_u32(atom, Role::App, "priority", 1).unwrap();
     let editlayer_node =
         node.setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;

+ 3 - 0
bin/app/src/app/schema/wallet/main.rs

@@ -615,6 +615,9 @@ async fn create_chat_btn(
 
     let node = create_button("chat_btn");
     node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    // The button floats above the tokens table (priority 0), whose
+    // rect spans the rest of the layer — it must be hit-tested first.
+    node.set_property_u32(atom, Role::App, "priority", 1).unwrap();
     let prop = node.get_property("rect").unwrap();
     let code = cc.compile(format!("w - {CHAT_BTN_SIZE} - {CHAT_BTN_MARGIN}")).unwrap();
     prop.set_expr(atom, Role::App, 0, code).unwrap();

+ 1 - 12
bin/app/src/gfx/ev.rs

@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use std::sync::Arc;
 
 use super::{Dimension, Point};
@@ -53,7 +53,6 @@ pub struct GraphicsEventPublisher {
     mouse_btn_up: EventChannel<(MouseButton, Point)>,
     mouse_move: EventChannel<Point>,
     mouse_wheel: EventChannel<Point>,
-    touch: EventChannel<(TouchPhase, u64, Point)>,
 }
 
 pub type GraphicsEventScreenSub = async_channel::Receiver<bool>;
@@ -65,7 +64,6 @@ pub type GraphicsEventMouseButtonDownSub = async_channel::Receiver<(MouseButton,
 pub type GraphicsEventMouseButtonUpSub = async_channel::Receiver<(MouseButton, Point)>;
 pub type GraphicsEventMouseMoveSub = async_channel::Receiver<Point>;
 pub type GraphicsEventMouseWheelSub = async_channel::Receiver<Point>;
-pub type GraphicsEventTouchSub = async_channel::Receiver<(TouchPhase, u64, Point)>;
 
 impl GraphicsEventPublisher {
     pub fn new() -> Arc<Self> {
@@ -79,7 +77,6 @@ impl GraphicsEventPublisher {
             mouse_btn_up: EventChannel::new(),
             mouse_move: EventChannel::new(),
             mouse_wheel: EventChannel::new(),
-            touch: EventChannel::new(),
         })
     }
 
@@ -117,11 +114,6 @@ impl GraphicsEventPublisher {
     pub(super) fn notify_mouse_wheel(&self, wheel_pos: Point) {
         self.mouse_wheel.notify(wheel_pos);
     }
-    pub(super) fn notify_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) {
-        let ev = (phase, id, touch_pos);
-        self.touch.notify(ev);
-    }
-
     pub fn subscribe_screen_changed(&self) -> GraphicsEventScreenSub {
         self.screen_changed.clone_recvr()
     }
@@ -150,7 +142,4 @@ impl GraphicsEventPublisher {
     pub fn subscribe_mouse_wheel(&self) -> GraphicsEventMouseWheelSub {
         self.mouse_wheel.clone_recvr()
     }
-    pub fn subscribe_touch(&self) -> GraphicsEventTouchSub {
-        self.touch.clone_recvr()
-    }
 }

+ 2 - 2
bin/app/src/gfx/linalg.rs

@@ -54,7 +54,7 @@ impl Div<f32> for Dimension {
     }
 }
 
-#[derive(Clone, Copy, Default, SerialEncodable, SerialDecodable)]
+#[derive(Clone, Copy, Default, PartialEq, SerialEncodable, SerialDecodable)]
 pub struct Point {
     pub x: f32,
     pub y: f32,
@@ -395,7 +395,7 @@ pub struct Segment {
     pub end: Point,
 }
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq)]
 pub struct Vector {
     pub x: f32,
     pub y: f32,

+ 4 - 7
bin/app/src/gfx/mod.rs

@@ -52,7 +52,7 @@ pub use ev::{
     GraphicsEventCharSub, GraphicsEventKeyDownSub, GraphicsEventKeyUpSub,
     GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub, GraphicsEventMouseMoveSub,
     GraphicsEventMouseWheelSub, GraphicsEventPublisher, GraphicsEventPublisherPtr,
-    GraphicsEventResizeSub, GraphicsEventTouchSub,
+    GraphicsEventResizeSub,
 };
 mod favico;
 mod prune;
@@ -1301,19 +1301,16 @@ impl EventHandler for Stage {
             self.window_node = god.app.sg_root.lookup_node("/window");
         }
 
-        // Direct call to Window's handle_touch_sync
         if let Some(window_node) = &self.window_node {
             match window_node.pimpl() {
                 Pimpl::Window(win) => {
-                    if win.handle_touch_sync(phase, id, pos) {
-                        return
-                    }
+                    // All touch interaction flows through the gesture
+                    // session, fed here at the Stage entry.
+                    win.feed_gesture(phase, id, pos);
                 }
                 _ => panic!(),
             }
         }
-
-        self.event_pub.notify_touch(phase, id, pos);
     }
 
     fn quit_requested_event(&mut self) {

+ 0 - 2
bin/app/src/scene.rs

@@ -116,7 +116,6 @@ pub enum SceneNodeType {
     Image = 15,
     Button = 16,
     Shortcut = 17,
-    Gesture = 18,
     EmojiPicker = 19,
     Setting = 21,
     Menu = 22,
@@ -609,7 +608,6 @@ pub enum Pimpl {
     Video(ui::VideoPtr),
     Button(ui::ButtonPtr),
     Shortcut(ui::ShortcutPtr),
-    Gesture(ui::GesturePtr),
     EmojiPicker(ui::EmojiPickerPtr),
     Menu(ui::MenuPtr),
     TokenTable(ui::TokenTablePtr),

+ 22 - 4
bin/app/src/text/editor/android.rs

@@ -200,10 +200,19 @@ impl Editor {
     }
 
     pub fn insert(&mut self, txt: &str, atom: &mut PropertyAtomicGuard) {
-        // TODO: need to verify this is correct
-        // Insert text by updating the state
-        self.state.text.push_str(txt);
-        let cursor_idx = self.state.text.len();
+        // Insert at the cursor, replacing any active selection, like
+        // the parley editor's insert_or_replace_selection. Indices are
+        // byte offsets. The selection can also arrive from the IME, so
+        // snap malformed boundaries instead of panicking.
+        let (anchor, focus) = self.state.select;
+        let (start, end) = if anchor <= focus { (anchor, focus) } else { (focus, anchor) };
+
+        let len = self.state.text.len();
+        let start = snap_char_boundary(&self.state.text, start.min(len));
+        let end = snap_char_boundary(&self.state.text, end.min(len));
+
+        self.state.text.replace_range(start..end, txt);
+        let cursor_idx = start + txt.len();
         self.state.select = (cursor_idx, cursor_idx);
         self.state.compose = None;
         self.input.set_state(self.state.clone());
@@ -275,3 +284,12 @@ impl Editor {
         self.input.set_input_type(input_type);
     }
 }
+
+/// Advance `i` to the nearest following UTF-8 char boundary. Used to
+/// keep IME-supplied indices safe for `String` slicing.
+fn snap_char_boundary(s: &str, mut i: usize) -> usize {
+    while i < s.len() && !s.is_char_boundary(i) {
+        i += 1;
+    }
+    i
+}

+ 13 - 47
bin/app/src/ui/button.rs

@@ -17,7 +17,7 @@
  */
 
 use async_trait::async_trait;
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::sync::{
@@ -34,7 +34,7 @@ use crate::{
     ExecutorPtr,
 };
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::button", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::button", $($arg)*); } }
@@ -188,56 +188,22 @@ impl UIObject for Button {
         true
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        //t!("handle_touch({phase:?}, {id}, {touch_pos:?})");
-        if !self.is_active.get() {
-            return false
-        }
-
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
-
-        let rect = self.rect.get();
-        if !rect.contains(touch_pos) {
-            //t!("not inside rect");
-            return false
-        }
-
-        // Simulate mouse events
-        match phase {
-            TouchPhase::Started => self.handle_mouse_btn_down(MouseButton::Left, touch_pos).await,
-            TouchPhase::Moved => false,
-            TouchPhase::Ended => self.handle_mouse_btn_up(MouseButton::Left, touch_pos).await,
-            TouchPhase::Cancelled => false,
-        }
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::TAP
     }
 
-    fn handle_touch_sync(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        if !self.is_active.get() {
-            return false
-        }
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.is_active.get() && self.rect.get().contains(pos)
+    }
 
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        let GestureAction::Tap { pos: _ } = gesture else { return false };
 
-        let rect = self.rect.get();
-        if !rect.contains(touch_pos) {
-            return false
-        }
+        d!("Button clicked!");
+        let node = self.node.upgrade().unwrap();
+        node.trigger("click", vec![]).await.unwrap();
 
-        match phase {
-            TouchPhase::Started => {
-                self.mouse_btn_held.store(true, Ordering::Relaxed);
-                true
-            }
-            TouchPhase::Moved => false,
-            TouchPhase::Ended => false,
-            TouchPhase::Cancelled => false,
-        }
+        true
     }
 }
 

+ 108 - 246
bin/app/src/ui/chatview/mod.rs

@@ -16,8 +16,6 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use super::long_press_timeout;
-
 use async_lock::Mutex as AsyncMutex;
 use async_trait::async_trait;
 use atomic_float::AtomicF32;
@@ -29,7 +27,6 @@ use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use regex::Regex;
 use std::{
-    collections::VecDeque,
     io::Cursor,
     sync::{
         atomic::{AtomicBool, AtomicU32, Ordering},
@@ -56,7 +53,7 @@ use crate::{
     ExecutorPtr,
 };
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::chatview", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview", $($arg)*); } }
@@ -68,10 +65,6 @@ const BIG_EPSILON: f32 = 0.05;
 /// (which only selects) rather than a click (which toggles).
 const SELECT_DRAG_THRESHOLD: f32 = 2.;
 
-/// Finger must stay within this many pixels of the touch-start position to
-/// count as "stationary" for long-hold selection on touch screens.
-const TOUCH_STATIONARY_THRESHOLD: f32 = 10.;
-
 /// Tracks an in-progress mouse selection gesture so we can distinguish a
 /// stationary click (toggles the line) from a drag (only ever selects).
 struct SelectDrag {
@@ -122,50 +115,15 @@ impl std::fmt::Display for MessageId {
 
 const PRELOAD_PAGES: usize = 1;
 
-#[derive(Clone)]
-struct TouchInfo {
-    start_scroll: f32,
-    start_y: f32,
-    start_instant: std::time::Instant,
-
-    /// Used for flick scrolling
-    samples: VecDeque<(std::time::Instant, f32)>,
-
-    last_instant: std::time::Instant,
-    last_y: f32,
-
-    /// Selection started?
-    is_select_mode: Option<bool>,
-}
-
-impl TouchInfo {
-    fn new(start_scroll: f32, y: f32) -> Self {
-        Self {
-            start_scroll,
-            start_y: y,
-            start_instant: std::time::Instant::now(),
-            samples: VecDeque::from([(std::time::Instant::now(), y)]),
-            last_instant: std::time::Instant::now(),
-            last_y: y,
-            is_select_mode: None,
-        }
-    }
-
-    fn push_sample(&mut self, y: f32) {
-        self.samples.push_back((std::time::Instant::now(), y));
-
-        // Now drop all old samples older than 40ms
-        while let Some((instant, _)) = self.samples.front() {
-            if instant.elapsed().as_micros() <= 40_000 {
-                break
-            }
-            self.samples.pop_front().unwrap();
-        }
-    }
-
-    fn first_sample(&self) -> Option<(f32, f32)> {
-        self.samples.front().map(|(t, s)| (t.elapsed().as_micros() as f32 / 1000., *s))
-    }
+/// What the fired long-press chose for this touch.
+#[derive(Clone, Copy, PartialEq)]
+enum LongPressMode {
+    /// No long-press fired
+    None,
+    /// Line selection started; drags extend it, no scroll inertia
+    Select,
+    /// URL copied with a toast; drags scroll, no inertia
+    UrlToast,
 }
 
 pub type ChatViewPtr = Arc<ChatView>;
@@ -229,8 +187,10 @@ pub struct ChatView {
 
     /// Used for detecting when scrolling view
     mouse_pos: SyncMutex<Point>,
-    /// Touch scrolling
-    touch_info: SyncMutex<Option<TouchInfo>>,
+    /// Touch scrolling: (finger y at drag start, scroll at drag start)
+    drag_state: SyncMutex<Option<(f32, f32)>>,
+    /// The mode the touch's long-press resolved to
+    lp_mode: SyncMutex<LongPressMode>,
     touch_is_active: AtomicBool,
 
     rect: PropertyRect,
@@ -279,9 +239,6 @@ pub struct ChatView {
     /// Re-arm counter so a stale dismiss task won't clear a newer toast.
     toast_version: AtomicU32,
 
-    /// Re-arm counter so a stale long-press timer won't fire for a newer touch.
-    touch_hold_version: AtomicU32,
-
     /// Weak self-reference so handlers can spawn detached tasks.
     me: Weak<Self>,
     ex: ExecutorPtr,
@@ -381,7 +338,8 @@ impl ChatView {
             dc_key: OsRng.gen(),
 
             mouse_pos: SyncMutex::new(Point::from([0., 0.])),
-            touch_info: SyncMutex::new(None),
+            drag_state: SyncMutex::new(None),
+            lp_mode: SyncMutex::new(LongPressMode::None),
             touch_is_active: AtomicBool::new(false),
 
             rect,
@@ -416,7 +374,6 @@ impl ChatView {
             url_copy_duration,
             link_toast: SyncMutex::new(None),
             toast_version: AtomicU32::new(0),
-            touch_hold_version: AtomicU32::new(0),
             me: me.clone(),
             ex,
         });
@@ -657,41 +614,6 @@ impl ChatView {
         self.notify_select_changed(has, had).await;
     }
 
-    fn end_touch_phase(&self, touch_y: f32) {
-        // Cancel any pending long-press timer.
-        self.touch_hold_version.fetch_add(1, Ordering::SeqCst);
-
-        // Now calculate scroll acceleration
-        let touch_info = std::mem::replace(&mut *self.touch_info.lock(), None);
-        let Some(touch_info) = &touch_info else { return };
-
-        self.touch_is_active.store(false, Ordering::Relaxed);
-
-        // No scroll accel when selection was active.
-        if touch_info.is_select_mode == Some(true) {
-            return
-        }
-
-        let Some((time, sample_y)) = touch_info.first_sample() else { return };
-        let dist = touch_y - sample_y;
-
-        // Ignore sub-ms events
-        if time < 1. {
-            error!(target: "ui::chatview", "Received a sub-ms touch event!");
-            return
-        }
-
-        //let speed = dist / time;
-        //self.speed.fetch_add(speed, Ordering::Relaxed);
-        //debug!(target: "ui::chatview", "speed = {dist} / {time} = {speed}");
-
-        let accel = self.scroll_start_accel.get() * dist / time;
-        let touch_time = touch_info.start_instant.elapsed();
-        t!("accel = {dist} / {time} = {accel},  touch = {touch_time:?}");
-        self.speed.fetch_add(accel, Ordering::Relaxed);
-        self.motion_cv.notify();
-    }
-
     async fn add_line_to_db(
         &self,
         timest: Timestamp,
@@ -1069,51 +991,54 @@ impl ChatView {
         .detach();
     }
 
-    /// Called by the long-press timer after `select_hold_time` elapses.
-    /// If the finger is still down and within the stationary threshold, starts
-    /// text selection (or copies the URL if the finger is on one).
-    async fn long_hold_fire(&self, version: u32, start_pos: Point) {
-        // Cancelled by Ended/Cancelled or a newer touch.
-        if self.touch_hold_version.load(Ordering::SeqCst) != version {
-            return
-        }
-
-        // Touch ended or still undecided?
-        let (start_y, last_y) = {
-            let touch_info = self.touch_info.lock();
-            let Some(ti) = &*touch_info else { return };
-            (ti.start_y, ti.last_y)
-        };
-
-        // Finger moved beyond the stationary threshold — it's a scroll.
-        if (last_y - start_y).abs() > TOUCH_STATIONARY_THRESHOLD {
-            if let Some(ti) = &mut *self.touch_info.lock() {
-                ti.is_select_mode = Some(false);
-            }
-            return
-        }
-
+    /// Long-press resolved by the recognizer: copy the URL under the
+    /// finger with a toast, or start line selection.
+    async fn handle_long_press(&self, pos: Point) {
         let rect = self.rect.get();
 
         // URL under the finger takes priority: copy it, don't select.
-        let msgbuf_pos = self.to_msgbuf_pos(start_pos);
+        let msgbuf_pos = self.to_msgbuf_pos(pos);
         let mut msgbuf = self.msgbuf.lock().await;
         let on_url = msgbuf.url_at(&rect, msgbuf_pos.x, msgbuf_pos.y).await;
         drop(msgbuf);
 
         if let Some(url) = on_url {
-            self.show_toast(&url, start_pos - rect.pos()).await;
-            if let Some(ti) = &mut *self.touch_info.lock() {
-                ti.is_select_mode = Some(false);
-            }
+            *self.lp_mode.lock() = LongPressMode::UrlToast;
+            self.show_toast(&url, pos - rect.pos()).await;
             return
         }
 
         // Not on a URL: start text selection.
-        if let Some(ti) = &mut *self.touch_info.lock() {
-            ti.is_select_mode = Some(true);
+        *self.lp_mode.lock() = LongPressMode::Select;
+        self.select_line(pos.y).await;
+    }
+
+    /// Tap resolved by the recognizer: forward to the message under
+    /// the touch (opens URLs, downloads files), or toggle line
+    /// selection when selection is active.
+    async fn handle_tap(&self, pos: Point) {
+        let mut msgbuf = self.msgbuf.lock().await;
+        let msgbuf_pos = self.to_msgbuf_pos(pos);
+        let mut is_handled = false;
+        if let Some((msg, msg_top)) = msgbuf.get_line(&self.rect.get(), msgbuf_pos.y).await {
+            is_handled = msg
+                .handle_touch(
+                    TouchPhase::Ended,
+                    0,
+                    Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y),
+                )
+                .await
+        }
+        drop(msgbuf);
+
+        // Not a URL/file tap and selection mode is active: toggle the line.
+        if !is_handled && self.select_active.load(Ordering::Relaxed) {
+            if self.is_line_selected(pos.y).await {
+                self.deselect_line(pos.y).await;
+            } else {
+                self.select_line(pos.y).await;
+            }
         }
-        self.select_line(start_pos.y).await;
     }
 }
 
@@ -1434,141 +1359,78 @@ impl UIObject for ChatView {
         true
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
-
-        let rect = self.rect.get();
-        //t!("handle_touch({phase:?}, {id},{id},  {touch_pos:?})");
-
-        let touch_y = touch_pos.y;
-
-        if !rect.contains(touch_pos) {
-            match phase {
-                TouchPhase::Started => *self.touch_info.lock() = None,
-                _ => self.end_touch_phase(touch_y),
-            }
-            return false
-        }
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::CHATVIEW
+    }
 
-        let hold_ms = long_press_timeout() as u64;
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.rect.get().contains(pos)
+    }
 
-        // Simulate mouse events
-        match phase {
-            TouchPhase::Started => {
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        match gesture {
+            GestureAction::Down { .. } => {
+                // A new touch resets the long-press mode and pauses
+                // the inertia task while the finger is down
+                *self.lp_mode.lock() = LongPressMode::None;
+                *self.drag_state.lock() = None;
                 self.touch_is_active.store(true, Ordering::Relaxed);
-
-                *self.touch_info.lock() = Some(TouchInfo::new(self.scroll.get(), touch_y));
-
-                // Arm the long-press timer for text selection.
-                let version = self.touch_hold_version.fetch_add(1, Ordering::SeqCst) + 1;
-                let me = self.me.clone();
-                let ex = self.ex.clone();
-                let start_pos = touch_pos;
-                ex.spawn(async move {
-                    msleep(hold_ms).await;
-                    let Some(self_) = me.upgrade() else { return };
-                    self_.long_hold_fire(version, start_pos).await;
-                })
-                .detach();
+                true
             }
-            TouchPhase::Moved => {
-                let (start_scroll, start_y, start_elapsed, do_update, is_select_mode) = {
-                    let mut touch_info = self.touch_info.lock();
-                    let Some(touch_info) = &mut *touch_info else { return false };
-
-                    touch_info.last_y = touch_y;
-
-                    let start_scroll = touch_info.start_scroll;
-                    let start_y = touch_info.start_y;
-
-                    let start_elapsed =
-                        touch_info.start_instant.elapsed().as_micros() as f32 / 1000.;
-                    let is_select_mode = touch_info.is_select_mode.clone();
-
-                    touch_info.push_sample(touch_y);
-
-                    // Only update screen every 20ms. Avoid wasting cycles.
-                    let last_elapsed = touch_info.last_instant.elapsed().as_micros();
-                    let do_update = last_elapsed > 20_000;
-                    if do_update {
-                        touch_info.last_instant = std::time::Instant::now();
-                    }
-
-                    (start_scroll, start_y, start_elapsed, do_update, is_select_mode)
-                };
-
-                t!("touch phase moved, is_select_mode={is_select_mode:?}");
-
-                // When scrolling if we suddenly grab the screen for more than a brief period
-                // of time then stop the scrolling completely.
-                if start_elapsed > 200. {
-                    t!("Stopping scroll accel");
-                    self.speed.store(0., Ordering::Relaxed);
-                }
-
-                // Only update every so often to prevent wasting resources.
-                if !do_update {
+            GestureAction::DragStart { start } => {
+                // A grab kills running inertia and owns the scroll
+                self.speed.store(0., Ordering::Relaxed);
+                *self.drag_state.lock() = Some((start.y, self.scroll.get()));
+                true
+            }
+            GestureAction::DragMove { curr, .. } => {
+                // Extending a long-press selection instead of scrolling
+                if *self.lp_mode.lock() == LongPressMode::Select {
+                    self.select_line(curr.y).await;
                     return true
                 }
 
-                // We are in selection mode so don't scroll the screen until touch phase ends.
-                if is_select_mode == Some(true) {
-                    self.select_line(touch_y).await;
-                    return true
-                }
+                let scroll = {
+                    let drag_state = self.drag_state.lock();
+                    let Some((start_y, start_scroll)) = *drag_state else { return false };
+                    // ChatView's scroll grows back into history (the
+                    // opposite axis of the emoji/menu scrollers), so
+                    // the finger offset adds: scroll = start + dy.
+                    start_scroll + curr.y - start_y
+                };
 
-                let dist = touch_y - start_y;
-                // No movement so just return
-                if dist.abs() < BIG_EPSILON {
-                    return true
-                }
-                let scroll = start_scroll + dist;
-                let atom = &mut self.redraw.make_guard(gfxtag!("ChatView::handle_touch_scroll"));
+                let atom = &mut self.redraw.make_guard(gfxtag!("ChatView::handle_gesture_scroll"));
                 self.scrollview(scroll, atom).await;
+                true
             }
-            TouchPhase::Ended | TouchPhase::Cancelled => {
-                let (start_y, is_select_mode) = {
-                    let touch_info = self.touch_info.lock();
-                    let Some(touch_info) = &*touch_info else { return true };
-                    (touch_info.start_y, touch_info.is_select_mode)
-                };
+            GestureAction::DragEnd { vel, .. } => {
+                *self.drag_state.lock() = None;
 
-                // If the timer never fired and movement was minimal, it is a tap.
-                if is_select_mode.is_none() && (touch_y - start_y).abs() < BIG_EPSILON {
-                    // A tap forwards to the message first (opens a URL / downloads a file).
-                    let mut msgbuf = self.msgbuf.lock().await;
-                    let msgbuf_pos = self.to_msgbuf_pos(touch_pos);
-                    let mut is_handled = false;
-                    if let Some((msg, msg_top)) =
-                        msgbuf.get_line(&self.rect.get(), msgbuf_pos.y).await
-                    {
-                        is_handled = msg
-                            .handle_touch(
-                                TouchPhase::Ended,
-                                0,
-                                Point::new(msgbuf_pos.x, msg_top - msgbuf_pos.y),
-                            )
-                            .await
-                    }
-                    drop(msgbuf);
-
-                    // Not a URL/file tap and selection mode is active: toggle the line.
-                    if !is_handled && self.select_active.load(Ordering::Relaxed) {
-                        if self.is_line_selected(touch_y).await {
-                            self.deselect_line(touch_y).await;
-                        } else {
-                            self.select_line(touch_y).await;
-                        }
-                    }
+                // No scroll accel when selection was active
+                if *self.lp_mode.lock() == LongPressMode::Select {
+                    return true
                 }
 
-                self.end_touch_phase(touch_y);
+                // Feed inertia from the release velocity, reproducing
+                // the old dist-over-sample-window formula (px/ms).
+                let accel = self.scroll_start_accel.get() * vel.y / 1000.;
+                self.speed.fetch_add(accel, Ordering::Relaxed);
+                self.motion_cv.notify();
+                true
+            }
+            GestureAction::Up { .. } => {
+                self.touch_is_active.store(false, Ordering::Relaxed);
+                true
+            }
+            GestureAction::LongPress { pos } => {
+                self.handle_long_press(pos).await;
+                true
+            }
+            GestureAction::Tap { pos } => {
+                self.handle_tap(pos).await;
+                true
             }
         }
-        true
     }
 }
 

+ 10 - 2
bin/app/src/ui/chatview/page.rs

@@ -887,8 +887,11 @@ impl UIObject for FileMessage {
         }
         true
     }
+}
 
-    async fn handle_touch(&self, phase: TouchPhase, _id: u64, touch_pos: Point) -> bool {
+impl FileMessage {
+    /// Tap on the file area downloads the file (idle/error states).
+    pub(super) async fn handle_touch(&self, phase: TouchPhase, _id: u64, touch_pos: Point) -> bool {
         if phase != TouchPhase::Ended {
             return false
         }
@@ -1101,7 +1104,12 @@ impl UIObject for Message {
             Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
         }
     }
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
+}
+
+impl Message {
+    /// Tap forwarding into the message content: opens URLs, downloads
+    /// files. Returns true if the message consumed the tap.
+    pub(super) async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
         match self {
             Self::Priv(m) => m.handle_touch(phase, touch_pos).await,
             Self::Date(_) => false,

+ 11 - 0
bin/app/src/ui/edit/action.rs

@@ -138,6 +138,17 @@ impl ActionMode {
         None
     }
 
+    /// Non-consuming hit test: whether `pos` (widget-local) lands on a
+    /// menu item. Used for gesture hit-testing so the menu overlay can
+    /// be grabbed without consuming it.
+    pub fn hit(&self, pos: Point) -> bool {
+        let menu = self.menu.lock();
+        let Some(menu) = &*menu else { return false };
+
+        let local_pos = pos - menu.pos;
+        menu.items.iter().any(|item| item.rect.contains(local_pos))
+    }
+
     /// Called by the parent layout
     pub fn get_instrs(&self) -> Vec<DrawInstruction> {
         let Some(menu) = &*self.menu.lock() else { return vec![] };

+ 387 - 233
bin/app/src/ui/edit/mod.rs

@@ -16,13 +16,12 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use super::long_press_timeout;
 use async_trait::async_trait;
 use atomic_float::AtomicF32;
 use darkfi::system::msleep;
 use darkfi_serial::Decodable;
 use futures::FutureExt;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::{
@@ -57,14 +56,14 @@ const ACTION_COPY: u32 = 0;
 const ACTION_PASTE: u32 = 1;
 const ACTION_SELALL: u32 = 2;
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 mod action;
 mod filter;
 use filter::{ALLOWED_KEYCODES, DISALLOWED_CHARS};
 mod behave;
 pub use behave::BaseEditType;
-use behave::{EditorBehavior, MultiLine, ScrollDir, SingleLine};
+use behave::{EditorBehavior, MultiLine, SingleLine};
 mod repeat;
 use repeat::{PressedKey, PressedKeysSmoothRepeat};
 
@@ -97,66 +96,38 @@ macro_rules! ed {
     };
 }
 
+/// What the touch armed at `Down`.
 #[derive(Debug, Clone)]
-enum TouchStateAction {
+enum TouchArm {
+    /// Nothing armed (no touch, or the action menu consumed the press)
     Inactive,
-    Started { pos: Point, instant: std::time::Instant },
-    StartSelect,
-    Select,
-    DragSelectHandle { side: isize },
-    ScrollVert { start_pos: Point, scroll_start: f32 },
-    SetCursorPos,
+    /// The touch began inside the rect; drags may engage scrolling
+    Pressed,
+    /// A selection handle was grabbed; the precision drag adjusts the
+    /// selection endpoint from the first movement
+    Handle { side: isize },
 }
 
-struct TouchInfo {
-    state: TouchStateAction,
-    scroll: Arc<AtomicF32>,
-    scroll_ctrl: ScrollDir,
+/// What a `Pressed` touch's drag engaged as.
+#[derive(Debug, Clone)]
+enum DragMode {
+    /// Not yet engaged (movement below the travel threshold)
+    Undecided,
+    /// Content scrolling along the scroll direction
+    Scroll,
+    /// Off-direction movement; the cursor is set at release (no-op
+    /// while dragging, as before)
+    Cursor,
 }
 
-impl TouchInfo {
-    fn new(scroll: Arc<AtomicF32>, scroll_ctrl: ScrollDir) -> Self {
-        Self { state: TouchStateAction::Inactive, scroll, scroll_ctrl }
-    }
-
-    fn start(&mut self, pos: Point) {
-        debug!(target: "ui::chatedit::touch", "start touch: Started state");
-        self.state = TouchStateAction::Started { pos, instant: std::time::Instant::now() };
-    }
-
-    fn stop(&mut self) -> TouchStateAction {
-        debug!(target: "ui::chatedit::touch", "stop touch: Inactive state");
-        std::mem::replace(&mut self.state, TouchStateAction::Inactive)
-    }
-
-    fn update(&mut self, pos: &Point) {
-        match &self.state {
-            TouchStateAction::Started { pos: start_pos, instant } => {
-                let travel_dist_sq = pos.dist_sq(*start_pos);
-                let grad = (pos.y - start_pos.y) / (pos.x - start_pos.x);
-                let elapsed = instant.elapsed().as_millis();
-                //debug!(target: "ui::chatedit::touch", "TouchInfo::update() [travel_dist_sq={travel_dist_sq}, grad={grad}]");
-
-                if travel_dist_sq < HOLD_TRAVEL_THRESHOLD_SQ {
-                    if elapsed > long_press_timeout() as u128 {
-                        debug!(target: "ui::chatedit::touch", "update touch state: Started -> StartSelect");
-                        self.state = TouchStateAction::StartSelect;
-                    }
-                } else if self.scroll_ctrl.cmp(grad) {
-                    // Vertical movement
-                    debug!(target: "ui::chatedit::touch", "update touch state: Started -> ScrollVert");
-                    let scroll_start = self.scroll.load(Ordering::Relaxed);
-                    self.state =
-                        TouchStateAction::ScrollVert { start_pos: *start_pos, scroll_start };
-                } else {
-                    // Horizontal movement
-                    debug!(target: "ui::chatedit::touch", "update touch state: Started -> SetCursorPos");
-                    self.state = TouchStateAction::SetCursorPos;
-                }
-            }
-            _ => {}
-        }
-    }
+/// Gesture-touch state, armed at `Down` and driven by the drag
+/// lifecycle.
+struct TouchInfo {
+    arm: SyncMutex<TouchArm>,
+    /// The engaged drag mode for a `Pressed` touch
+    mode: SyncMutex<DragMode>,
+    /// Scroll-drag baseline: (touch pos at drag start, scroll then)
+    scroll_drag: SyncMutex<Option<(Point, f32)>>,
 }
 
 pub type BaseEditPtr = Arc<BaseEdit>;
@@ -224,7 +195,7 @@ pub struct BaseEdit {
     sel_sender: SyncMutex<Option<async_channel::Sender<Option<(Point, Option<isize>)>>>>,
     scroll: Arc<AtomicF32>,
 
-    touch_info: SyncMutex<TouchInfo>,
+    touch_info: TouchInfo,
     is_phone_select: AtomicBool,
 
     parent_rect: Arc<SyncMutex<Option<Rectangle>>>,
@@ -403,7 +374,11 @@ impl BaseEdit {
             sel_sender: SyncMutex::new(None),
             scroll: scroll.clone(),
 
-            touch_info: SyncMutex::new(TouchInfo::new(scroll, behave.scroll_ctrl())),
+            touch_info: TouchInfo {
+                arm: SyncMutex::new(TouchArm::Inactive),
+                mode: SyncMutex::new(DragMode::Undecided),
+                scroll_drag: SyncMutex::new(None),
+            },
             is_phone_select: AtomicBool::new(false),
 
             parent_rect,
@@ -586,9 +561,7 @@ impl BaseEdit {
             'v' => {
                 if action_mod {
                     if let Some(txt) = clipboard::get() {
-                        self.editor.lock().insert(&txt, atom);
-                        // Maybe insert should call this?
-                        self.behave.apply_cursor_scroll();
+                        self.insert_text(&txt, atom);
                     }
                 }
             }
@@ -724,6 +697,17 @@ impl BaseEdit {
         true
     }
 
+    /// Insert text programmatically at the selection. Inserting
+    /// collapses the selection, so any phone-style selection (and its
+    /// handles/action menu) must be finished too — the draw pass
+    /// asserts a non-collapsed selection while phone-select is
+    /// active. Keeps the cursor scrolled into view.
+    fn insert_text(&self, txt: &str, atom: &mut PropertyAtomicGuard) {
+        self.editor.lock().insert(txt, atom);
+        self.behave.apply_cursor_scroll();
+        self.finish_select(atom);
+    }
+
     /// This will select the entire word rather than move the cursor to that location
     fn start_touch_select(&self, touch_pos: Point, atom: &mut PropertyAtomicGuard) {
         ed!("start_touch_select({touch_pos:?}) before=[{}]", self.dbg_state());
@@ -740,8 +724,11 @@ impl BaseEdit {
         self.hide_cursor.store(true, Ordering::Relaxed);
     }
 
-    fn handle_touch_start(&self, touch_pos: Point) -> bool {
-        ed!("handle_touch_start({touch_pos:?}) before=[{}]", self.dbg_state());
+    /// `Down` passthrough: arm the touch. Grabbing a selection handle
+    /// is a zero-threshold action; the action menu consumes the press;
+    /// otherwise the press waits for recognition.
+    fn gesture_down(&self, touch_pos: Point) -> bool {
+        ed!("gesture_down({touch_pos:?}) before=[{}]", self.dbg_state());
 
         let rect = self.rect.get();
         let local_pos = touch_pos - rect.pos();
@@ -749,12 +736,14 @@ impl BaseEdit {
         // Grabbing a select handle must keep the action menu visible: the
         // user adjusts the selection, then taps Copy/Paste. Check the drag
         // BEFORE interact(), which consumes the menu.
-        if self.try_handle_drag(touch_pos) {
-            ed!("handle_touch_start: handled by drag handle");
+        if let Some(side) = self.select_handle_at(touch_pos) {
+            *self.touch_info.arm.lock() = TouchArm::Handle { side };
+            *self.touch_info.mode.lock() = DragMode::Undecided;
+            ed!("gesture_down: grabbing select handle side={side}");
             return true
         }
 
-        let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::handle_touch_start_action"));
+        let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::gesture_down_action"));
         if let Some(action_id) = self.action_mode.interact(local_pos) {
             match action_id {
                 ACTION_COPY => {
@@ -764,9 +753,7 @@ impl BaseEdit {
                 }
                 ACTION_PASTE => {
                     if let Some(txt) = clipboard::get() {
-                        self.editor.lock().insert(&txt, atom);
-                        self.behave.apply_cursor_scroll();
-                        self.finish_select(atom);
+                        self.insert_text(&txt, atom);
                     }
                 }
                 ACTION_SELALL => {
@@ -786,14 +773,13 @@ impl BaseEdit {
         }
 
         if !rect.contains(touch_pos) {
-            ed!("handle_touch_start: outside rect={rect:?}, ignoring");
-            t!("rect!cont rect={rect:?}, touch_pos={touch_pos:?}");
+            ed!("gesture_down: outside rect={rect:?}, ignoring");
             return false
         }
 
-        let mut touch_info = self.touch_info.lock();
-        touch_info.start(touch_pos);
-        ed!("handle_touch_start: touch started, waiting for move/end");
+        *self.touch_info.arm.lock() = TouchArm::Pressed;
+        *self.touch_info.mode.lock() = DragMode::Undecided;
+        ed!("gesture_down: touch started, waiting for move/end");
         true
     }
 
@@ -805,9 +791,12 @@ impl BaseEdit {
         endpoints
     }
 
-    fn try_handle_drag(&self, mut touch_pos: Point) -> bool {
+    /// Which selection handle (if any) is grabbable at `touch_pos`
+    /// (parent space). Non-mutating probe used for both arming and
+    /// gesture hit-testing.
+    fn select_handle_at(&self, mut touch_pos: Point) -> Option<isize> {
         let editor = self.editor.lock();
-        let Some((mut first, mut last)) = self.get_select_handles(&editor) else { return false };
+        let (mut first, mut last) = self.get_select_handles(&editor)?;
 
         self.abs_to_local(&mut touch_pos);
 
@@ -828,167 +817,211 @@ impl BaseEdit {
         let is_first = first_dist_sq <= TOUCH_RADIUS_SQ;
         let is_last = last_dist_sq <= TOUCH_RADIUS_SQ;
 
-        let mut side = 0;
-
         if is_first && is_last {
             // Are we closer to the first or last?
             // Break the tie
             if first_dist_sq < last_dist_sq {
-                side = -1;
+                Some(-1)
             } else {
-                side = 1;
+                Some(1)
             }
         } else if is_first {
-            side = -1;
+            Some(-1)
         } else if is_last {
-            side = 1;
+            Some(1)
+        } else {
+            None
         }
+    }
 
-        if side != 0 {
-            d!("start touch: DragSelectHandle state [side={side}]");
-            // Set touch_state status to enable begin dragging them
-            let mut touch_info = self.touch_info.lock();
-            touch_info.state = TouchStateAction::DragSelectHandle { side };
-            ed!("try_handle_drag: grabbing select handle side={side}");
-            return true
+    /// Selection-handle drag: follow the finger with autoscroll when
+    /// it leaves the rect.
+    fn drag_select_handle(&self, touch_pos: Point, side: isize) {
+        // The IME can collapse the selection (finishing phone-select
+        // mode) while a handle drag is in progress. Abort the drag
+        // instead of asserting on the stale state.
+        if !self.is_phone_select.load(Ordering::Relaxed) {
+            ed!("drag_select_handle: phone select finished mid-drag, aborting");
+            *self.touch_info.arm.lock() = TouchArm::Inactive;
+            return
         }
 
-        ed!("try_handle_drag: no handle grabbed");
-        false
-    }
+        let rect = self.rect.get();
+        let is_touch_hover = rect.contains(touch_pos);
 
-    fn handle_touch_move(&self, mut touch_pos: Point) -> bool {
-        if !self.is_active.get() {
-            return false
+        let sel_sender = self.sel_sender.lock().clone().unwrap();
+        // Finger outside rect?
+        // If so we gotta scroll it while selecting.
+        if !is_touch_hover {
+            // This process will begin selecting text and applying scroll too.
+            sel_sender.try_send(Some((touch_pos, Some(side)))).unwrap();
+        } else {
+            // Stop any existing select/scroll process
+            sel_sender.try_send(None).unwrap();
+            // Finger is inside so just select the text once and be done.
+            self.handle_select(touch_pos, Some(side));
         }
+    }
 
-        // We must update with non relative touch_pos bcos when doing vertical scrolling
-        // we will modify the scroll, which is used by abs_to_local(), which is used
-        // to then calculate the max scroll. So it ends up jumping around.
-        // We use the abs touch_pos without scroll adjust applied for vert scrolling.
-        let touch_state = {
-            let mut touch_info = self.touch_info.lock();
-            touch_info.update(&touch_pos);
-            touch_info.state.clone()
+    /// `Pressed`-touch drag movement: engage scrolling once the travel
+    /// crosses the threshold along the scroll direction, mirroring the
+    /// old gradient check.
+    fn drag_pressed(&self, touch_pos: Point) {
+        let (mode, scroll_drag) = {
+            let mode = self.touch_info.mode.lock().clone();
+            let scroll_drag = *self.touch_info.scroll_drag.lock();
+            (mode, scroll_drag)
         };
-        ed!("handle_touch_move({touch_pos:?}) touch_state={touch_state:?}");
-        //t!("handle_touch_move({touch_pos:?})  touch_state={touch_state:?}");
-        match &touch_state {
-            TouchStateAction::Inactive => return false,
-            TouchStateAction::StartSelect => {
-                let mut menu = action::Menu::new(
-                    self.font_size.get(),
-                    self.action_fg_color.get(),
-                    self.action_bg_color.get(),
-                    self.action_padding.get(),
-                    self.action_spacing.get(),
-                    self.window_scale.get(),
-                );
-
-                let atom =
-                    &mut self.redraw.make_guard(gfxtag!("BaseEdit::TouchStateAction::StartSelect"));
-
-                if self.text.get().is_empty() {
-                    menu.add("Paste", ACTION_PASTE);
-                    // Set the menu pos to the current cursor pos
-                    menu.pos = self.get_cursor_pos();
-
-                    self.touch_info.lock().state = TouchStateAction::Inactive;
-                } else {
-                    menu.add("Copy", ACTION_COPY);
-                    menu.add("Paste", ACTION_PASTE);
-                    menu.add("Select All", ACTION_SELALL);
-
-                    self.abs_to_local(&mut touch_pos);
-                    self.start_touch_select(touch_pos, atom);
-
-                    {
-                        let editor = self.editor.lock();
-                        let (curs_lhs, _) = self.get_select_handles(&editor).unwrap();
-                        // Set the menu pos to the LHS of the selection
-                        menu.pos = curs_lhs + self.behave.inner_pos();
-                    }
 
-                    // Adjust menu pos so RHS doesnt leave the RHS of this widget
-                    let rect = self.rect.get();
-                    if menu.pos.x + menu.total_width() > rect.w {
-                        menu.pos.x = rect.w - menu.total_width();
-                    }
+        match mode {
+            DragMode::Undecided => {
+                let Some((start_pos, scroll_start)) = scroll_drag else { return };
 
-                    d!("touch state: StartSelect -> Select");
-                    self.touch_info.lock().state = TouchStateAction::Select;
-                }
-                self.action_mode.set(menu);
-                ed!("handle_touch_move: StartSelect handled");
-            }
-            TouchStateAction::DragSelectHandle { side } => {
-                // The IME can collapse the selection (finishing phone-select
-                // mode) while a handle drag is in progress. Abort the drag
-                // instead of asserting on the stale state.
-                if !self.is_phone_select.load(Ordering::Relaxed) {
-                    ed!("handle_touch_move: phone select finished mid-drag, aborting");
-                    self.touch_info.lock().state = TouchStateAction::Inactive;
-                    return true
+                let travel_dist_sq = touch_pos.dist_sq(start_pos);
+                if travel_dist_sq < HOLD_TRAVEL_THRESHOLD_SQ {
+                    return
                 }
 
-                let rect = self.rect.get();
-                let is_touch_hover = rect.contains(touch_pos);
-
-                let sel_sender = self.sel_sender.lock().clone().unwrap();
-                // Mouse is outside rect?
-                // If so we gotta scroll it while selecting.
-                if !is_touch_hover {
-                    // This process will begin selecting text and applying scroll too.
-                    sel_sender.try_send(Some((touch_pos, Some(*side)))).unwrap();
+                let grad = (touch_pos.y - start_pos.y) / (touch_pos.x - start_pos.x);
+                if self.behave.scroll_ctrl().cmp(grad) {
+                    // Vertical movement engages content scrolling
+                    *self.touch_info.mode.lock() = DragMode::Scroll;
+                    *self.touch_info.scroll_drag.lock() = Some((start_pos, scroll_start));
                 } else {
-                    // Stop any existing select/scroll process
-                    sel_sender.try_send(None).unwrap();
-                    // Mouse is inside so just select the text once and be done.
-                    self.handle_select(touch_pos, Some(*side));
+                    // Off-direction movement; cursor is set on release
+                    *self.touch_info.mode.lock() = DragMode::Cursor;
                 }
             }
-            TouchStateAction::ScrollVert { start_pos, scroll_start } => {
-                let travel_dist = self.behave.scroll_ctrl().travel(*start_pos, touch_pos);
+            DragMode::Scroll => {
+                let Some((start_pos, scroll_start)) = scroll_drag else { return };
+
+                let travel_dist = self.behave.scroll_ctrl().travel(start_pos, touch_pos);
                 let mut scroll = scroll_start + travel_dist;
                 scroll = scroll.clamp(0., self.behave.max_scroll());
                 if (self.scroll.load(Ordering::Relaxed) - scroll).abs() < VERT_SCROLL_UPDATE_INC {
-                    return true
+                    return
                 }
                 self.scroll.store(scroll, Ordering::Release);
                 self.redraw.trigger();
             }
-            TouchStateAction::SetCursorPos => {
-                // TBH I can't even see the cursor under my thumb so I'll just
-                // comment this for now.
+            DragMode::Cursor => {
+                // Off-direction movement: nothing while dragging; the
+                // cursor is positioned at release (see gesture_up).
             }
-            _ => {}
         }
-        true
     }
-    async fn handle_touch_end(&self, mut touch_pos: Point) -> bool {
-        //t!("handle_touch_end({touch_pos:?})");
-        self.abs_to_local(&mut touch_pos);
 
-        let state = self.touch_info.lock().stop();
-        ed!("handle_touch_end({touch_pos:?}) final_state={state:?}");
-        match state {
-            TouchStateAction::Inactive => return false,
-            TouchStateAction::Started { pos: _, instant: _ } | TouchStateAction::SetCursorPos => {
-                let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::handle_touch_end"));
-                self.touch_set_cursor_pos(atom, touch_pos);
+    /// `LongPress` resolved by the recognizer: select the word under
+    /// the finger and show the copy/paste action menu while the finger
+    /// is still down.
+    fn gesture_long_press(&self, touch_pos: Point) {
+        ed!("gesture_long_press({touch_pos:?}) before=[{}]", self.dbg_state());
+
+        let mut menu = action::Menu::new(
+            self.font_size.get(),
+            self.action_fg_color.get(),
+            self.action_bg_color.get(),
+            self.action_padding.get(),
+            self.action_spacing.get(),
+            self.window_scale.get(),
+        );
+
+        let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::gesture_long_press"));
+
+        let mut local_pos = touch_pos;
+        self.abs_to_local(&mut local_pos);
+
+        if self.text.get().is_empty() {
+            menu.add("Paste", ACTION_PASTE);
+            // Set the menu pos to the current cursor pos
+            menu.pos = self.get_cursor_pos();
+
+            *self.touch_info.arm.lock() = TouchArm::Inactive;
+        } else {
+            menu.add("Copy", ACTION_COPY);
+            menu.add("Paste", ACTION_PASTE);
+            menu.add("Select All", ACTION_SELALL);
+
+            self.start_touch_select(local_pos, atom);
+
+            {
+                let editor = self.editor.lock();
+                let (curs_lhs, _) = self.get_select_handles(&editor).unwrap();
+                // Set the menu pos to the LHS of the selection
+                menu.pos = curs_lhs + self.behave.inner_pos();
+            }
+
+            // Adjust menu pos so RHS doesnt leave the RHS of this widget
+            let rect = self.rect.get();
+            if menu.pos.x + menu.total_width() > rect.w {
+                menu.pos.x = rect.w - menu.total_width();
+            }
+
+            // Word-selected: release must not set the cursor
+            *self.touch_info.arm.lock() = TouchArm::Inactive;
+        }
+        self.action_mode.set(menu);
+    }
+
+    /// `DragMove` driven by the precision drag recognizer: handle
+    /// drag, content scroll, or nothing, per the armed state.
+    fn gesture_drag_move(&self, touch_pos: Point) -> bool {
+        if !self.is_active.get() {
+            return false
+        }
+
+        let arm = self.touch_info.arm.lock().clone();
+        ed!("gesture_drag_move({touch_pos:?}) arm={arm:?}");
+
+        match arm {
+            TouchArm::Inactive => false,
+            TouchArm::Handle { side } => {
+                self.drag_select_handle(touch_pos, side);
+                true
             }
-            _ => {}
+            TouchArm::Pressed => {
+                self.drag_pressed(touch_pos);
+                true
+            }
+        }
+    }
+
+    /// `Up` passthrough: finalize the touch. A `Pressed` release that
+    /// did not engage scrolling positions the cursor (this covers the
+    /// tap, the tap/long-press dead zone, and off-direction drags —
+    /// exactly the old release behavior). Also stops selection
+    /// autoscroll and requests focus.
+    async fn gesture_up(&self, touch_pos: Point) -> bool {
+        let arm = std::mem::replace(&mut *self.touch_info.arm.lock(), TouchArm::Inactive);
+        let mode = std::mem::replace(&mut *self.touch_info.mode.lock(), DragMode::Undecided);
+        *self.touch_info.scroll_drag.lock() = None;
+        ed!("gesture_up({touch_pos:?}) final_arm={arm:?} mode={mode:?}");
+
+        if matches!(arm, TouchArm::Inactive) {
+            return false
+        }
+
+        if matches!(arm, TouchArm::Pressed) && !matches!(mode, DragMode::Scroll) {
+            let mut local_pos = touch_pos;
+            self.abs_to_local(&mut local_pos);
+            let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::gesture_up"));
+            self.touch_set_cursor_pos(atom, local_pos);
         }
 
         // Stop any selection scrolling
         let scroll_sender = self.sel_sender.lock().clone().unwrap();
         scroll_sender.try_send(None).unwrap();
 
-        let mut need_focus = !self.is_focused.get();
-        #[cfg(target_os = "android")]
-        if !is_ime_visible() {
-            need_focus = true;
-        }
+        let need_focus = {
+            #[cfg(target_os = "android")]
+            {
+                !self.is_focused.get() || !is_ime_visible()
+            }
+            #[cfg(not(target_os = "android"))]
+            {
+                !self.is_focused.get()
+            }
+        };
 
         if need_focus {
             let node = self.node();
@@ -1357,8 +1390,7 @@ impl BaseEdit {
         };
 
         let atom = &mut self_.redraw.make_guard(gfxtag!("BaseEdit::process_insert_text_method"));
-        self_.editor.lock().insert(&text, atom);
-        self_.action_mode.clear();
+        self_.insert_text(&text, atom);
         ed!("insert_text method: inserted {text:?} after=[{}]", self_.dbg_state());
         true
     }
@@ -1414,6 +1446,29 @@ impl BaseEdit {
         true
     }
 
+    /// Hides the IME soft keyboard without leaving the focused state:
+    /// the cursor stays visible. Used when an overlay (the emoji
+    /// picker panel) replaces the keyboard.
+    async fn process_hide_ime_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            debug!(target: "ui::chatedit", "Event relayer closed");
+            return false
+        };
+
+        t!("method called: hide_ime({method_call:?})");
+        assert!(method_call.send_res.is_none());
+        assert!(method_call.data.is_empty());
+
+        let Some(self_) = me.upgrade() else {
+            // Should not happen
+            panic!("self destroyed before hide_ime_method_task was stopped!");
+        };
+
+        self_.editor.lock().unfocus();
+        ed!("hide_ime method: done after=[{}]", self_.dbg_state());
+        true
+    }
+
     #[cfg(target_os = "android")]
     fn handle_android_event(&self, state: AndroidTextInputState) {
         if !self.is_active.get() {
@@ -1428,10 +1483,7 @@ impl BaseEdit {
         // selection and finish phone-select mode, making the handles
         // vanish. Drop it: the next drag update re-asserts the real
         // selection.
-        let drag_in_progress = {
-            let touch_info = self.touch_info.lock();
-            matches!(touch_info.state, TouchStateAction::DragSelectHandle { .. })
-        };
+        let drag_in_progress = { matches!(*self.touch_info.arm.lock(), TouchArm::Handle { .. }) };
         if drag_in_progress && state.select.0 == state.select.1 {
             ed!("handle_android_event: DROP collapsed IME echo mid-drag state={state:?}");
             return
@@ -1547,6 +1599,11 @@ impl UIObject for BaseEdit {
         let unfocus_task =
             ex.spawn(async move { while Self::process_unfocus_method(&me2, &method_sub).await {} });
 
+        let method_sub = node_ref.subscribe_method_call("hide_ime").unwrap();
+        let me2 = me.clone();
+        let hide_ime_task = ex
+            .spawn(async move { while Self::process_hide_ime_method(&me2, &method_sub).await {} });
+
         let mut on_modify = OnModify::new(ex.clone(), self.node.clone(), me.clone());
         on_modify.when_change_external(self.is_focused.prop(), Self::change_focus);
 
@@ -1660,7 +1717,7 @@ impl UIObject for BaseEdit {
             }
         });
 
-        let mut tasks = vec![insert_text_task, focus_task, unfocus_task, sel_task];
+        let mut tasks = vec![insert_text_task, focus_task, unfocus_task, hide_ime_task, sel_task];
         tasks.append(&mut on_modify.tasks);
 
         #[cfg(target_os = "android")]
@@ -1746,11 +1803,8 @@ impl UIObject for BaseEdit {
 
         t!("Key {:?} has {} actions", key, actions);
         let key_str = key.to_string().repeat(actions as usize);
-        self.editor.lock().insert(&key_str, atom);
-        self.behave.apply_cursor_scroll();
+        self.insert_text(&key_str, atom);
         self.pause_blinking();
-        // Any edit invalidates the action menu's selection
-        self.action_mode.clear();
         ed!("handle_char: inserted {key_str:?} after=[{}]", self.dbg_state());
         true
     }
@@ -1891,9 +1945,7 @@ impl UIObject for BaseEdit {
                 }
                 ACTION_PASTE => {
                     if let Some(txt) = clipboard::get() {
-                        self.editor.lock().insert(&txt, atom);
-                        self.behave.apply_cursor_scroll();
-                        self.finish_select(atom);
+                        self.insert_text(&txt, atom);
                     }
                 }
                 ACTION_SELALL => {
@@ -1963,40 +2015,56 @@ impl UIObject for BaseEdit {
         true
     }
 
-    /// Runs on the Stage thread inside the miniquad event callback.
-    /// Converted to mutate + trigger: state mutations happen inline and the
-    /// redraw is enqueued for the serialized draw pass (one extra hop of
-    /// latency for drag-handle feedback; see design.md D6).
-    fn handle_touch_sync(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::EDIT
+    }
+
+    fn gesture_hit_test(&self, pos: Point) -> bool {
         if !self.is_active.get() {
             return false
         }
 
-        // Ignore multi-touch
-        if id != 0 {
-            return false
+        let rect = self.rect.get();
+        if rect.contains(pos) {
+            return true
         }
 
-        match phase {
-            TouchPhase::Started => self.handle_touch_start(touch_pos),
-            TouchPhase::Moved => self.handle_touch_move(touch_pos),
-            TouchPhase::Ended | TouchPhase::Cancelled => false,
+        // The selection handles and the action menu render outside the
+        // rect but must stay grabbable.
+        if self.select_handle_at(pos).is_some() {
+            return true
         }
+
+        self.action_mode.hit(pos - rect.pos())
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
         if !self.is_active.get() {
             return false
         }
 
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
-
-        match phase {
-            TouchPhase::Started | TouchPhase::Moved | TouchPhase::Cancelled => false,
-            TouchPhase::Ended => self.handle_touch_end(touch_pos).await,
+        match gesture {
+            GestureAction::Down { pos } => self.gesture_down(pos),
+            GestureAction::DragStart { start } => {
+                // Scroll-drag baseline; handle drags ignore it
+                *self.touch_info.scroll_drag.lock() =
+                    Some((start, self.scroll.load(Ordering::Relaxed)));
+                true
+            }
+            GestureAction::DragMove { curr, .. } => self.gesture_drag_move(curr),
+            GestureAction::LongPress { pos } => {
+                self.gesture_long_press(pos);
+                true
+            }
+            GestureAction::Tap { pos } => {
+                let mut local_pos = pos;
+                self.abs_to_local(&mut local_pos);
+                let atom = &mut self.redraw.make_guard(gfxtag!("BaseEdit::gesture_tap"));
+                self.touch_set_cursor_pos(atom, local_pos);
+                true
+            }
+            GestureAction::Up { pos } => self.gesture_up(pos).await,
+            _ => false,
         }
     }
 }
@@ -2008,3 +2076,89 @@ impl std::fmt::Debug for BaseEdit {
         write!(f, "{:?}", self.node.upgrade().unwrap())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::{
+        app::node::create_multiline_edit,
+        gfx::Renderer,
+        prop::{Property, PropertySubType, PropertyType},
+        scene::{Pimpl, SceneNode, SceneNodeType},
+        ui::RedrawTrigger,
+    };
+
+    /// Inserting programmatically (emoji insert, paste) collapses the
+    /// selection. Any phone-style selection must be finished at the
+    /// same time, or the next draw pass asserts in
+    /// `get_select_handles` (phone-select handles over a collapsed
+    /// selection). See the on-device crash at edit/mod.rs:782.
+    #[test]
+    fn programmatic_insert_finishes_phone_select() {
+        smol::block_on(async {
+            let (redraw_tx, _redraw_rx) = RedrawTrigger::new();
+            let (method_tx, _method_rx) = async_channel::unbounded();
+            let renderer = Renderer::new(method_tx);
+            let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+
+            let node = create_multiline_edit("editz");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+                let rect = node.get_property("rect").unwrap();
+                for (i, v) in [0., 0., 400., 200.].into_iter().enumerate() {
+                    rect.set_f32(atom, Role::App, i, v).unwrap();
+                }
+                let height_range = node.get_property("height_range").unwrap();
+                height_range.set_f32(atom, Role::App, 0, 20.).unwrap();
+                height_range.set_f32(atom, Role::App, 1, 200.).unwrap();
+                node.set_property_f32(atom, Role::App, "font_size", 18.).unwrap();
+                node.set_property_f32(atom, Role::App, "lineheight", 1.).unwrap();
+                node.set_property_f32(atom, Role::App, "baseline", 14.).unwrap();
+                node.set_property_f32(atom, Role::App, "cursor_descent", 6.).unwrap();
+                let padding = node.get_property("padding").unwrap();
+                for i in 0..4 {
+                    padding.set_f32(atom, Role::App, i, 2.).unwrap();
+                }
+            }
+
+            let mut scratch = SceneNode::new("scratch", SceneNodeType::Layer);
+            let mut prop = Property::new("scale", PropertyType::Float32, PropertySubType::Null);
+            prop.set_defaults_f32(vec![1.]).unwrap();
+            scratch.add_property(prop).unwrap();
+            let window_scale = PropertyFloat32::wrap(&scratch, Role::App, "scale", 0).unwrap();
+
+            let node = node
+                .setup(|me| {
+                    BaseEdit::new(
+                        me,
+                        window_scale,
+                        renderer,
+                        redraw_tx,
+                        BaseEditType::MultiLine,
+                        ex,
+                    )
+                })
+                .await;
+            let Pimpl::Edit(edit) = node.pimpl() else { panic!() };
+
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                node.set_property_str(atom, Role::App, "text", "hello world").unwrap();
+            }
+            edit.on_text_prop_changed();
+
+            // Simulate an active phone-style selection
+            edit.is_phone_select.store(true, Ordering::Relaxed);
+            edit.hide_cursor.store(true, Ordering::Relaxed);
+
+            let atom = &mut PropertyAtomicGuard::none();
+            edit.insert_text("X", atom);
+
+            assert!(!edit.is_phone_select.load(Ordering::Relaxed), "phone select must finish");
+            assert!(!edit.hide_cursor.load(Ordering::Relaxed), "cursor must be re-enabled");
+            assert!(edit.select_text.is_null(0).unwrap(), "selection text must clear");
+        });
+    }
+}

+ 39 - 71
bin/app/src/ui/emoji_picker/mod.rs

@@ -18,7 +18,7 @@
 
 use async_trait::async_trait;
 use darkfi_serial::Encodable;
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::sync::{
@@ -36,7 +36,7 @@ use crate::{
     ExecutorPtr,
 };
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 mod default;
 use default::DEFAULT_EMOJI_LIST;
@@ -46,13 +46,6 @@ pub use emoji::{EmojiMeshes, EmojiMeshesPtr};
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::emoji_picker", $($arg)*) } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::emoji_picker", $($arg)*) } }
 
-#[derive(Clone)]
-struct TouchInfo {
-    start_pos: Point,
-    start_scroll: f32,
-    is_scroll: bool,
-}
-
 pub type EmojiPickerPtr = Arc<EmojiPicker>;
 
 pub struct EmojiPicker {
@@ -79,7 +72,9 @@ pub struct EmojiPicker {
     /// are evicted automatically.
     draw_cache: EpochCache<Vec<DrawInstruction>>,
     is_mouse_hover: AtomicBool,
-    touch_info: SyncMutex<Option<TouchInfo>>,
+    /// Active 1:1 scroll drag: (finger y at drag start, scroll at drag
+    /// start), both in the picker's parent space.
+    drag_state: SyncMutex<Option<(f32, f32)>>,
 }
 
 impl EmojiPicker {
@@ -120,7 +115,7 @@ impl EmojiPicker {
             redraw,
             draw_cache,
             is_mouse_hover: AtomicBool::new(false),
-            touch_info: SyncMutex::new(None),
+            drag_state: SyncMutex::new(None),
         });
 
         Pimpl::EmojiPicker(self_)
@@ -368,70 +363,43 @@ impl UIObject for EmojiPicker {
         true
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::SCROLL_VERT
+    }
 
-        let atom = &mut self.redraw.make_guard(gfxtag!("EmojiPicker::handle_touch"));
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.rect.get().contains(pos)
+    }
 
-        let rect = self.rect.get();
-        let pos = touch_pos - Point::new(rect.x, rect.y);
-
-        // We need this cos you cannot hold mutex and call async fn
-        // todo: clean this up
-        let mut emoji_is_clicked = false;
-        {
-            match phase {
-                TouchPhase::Started => {
-                    let mut touch_info = self.touch_info.lock();
-                    if !rect.contains(touch_pos) {
-                        return false
-                    }
-
-                    *touch_info = Some(TouchInfo {
-                        start_pos: pos,
-                        start_scroll: self.scroll.get(),
-                        is_scroll: false,
-                    });
-                }
-                TouchPhase::Moved => {
-                    let (touch_info, y_diff) = {
-                        let mut touch_info = self.touch_info.lock();
-                        let Some(touch_info) = touch_info.as_mut() else {
-                            return false;
-                        };
-
-                        let y_diff = touch_info.start_pos.y - pos.y;
-                        if y_diff.abs() > 0.5 {
-                            touch_info.is_scroll = true;
-                        }
-                        (touch_info.clone(), y_diff)
-                    };
-
-                    if touch_info.is_scroll {
-                        let mut scroll = touch_info.start_scroll + y_diff;
-                        scroll = scroll.clamp(0., self.max_scroll());
-                        self.scroll.set(atom, scroll);
-
-                        self.draw_cache.clear();
-                    }
-                }
-                TouchPhase::Ended | TouchPhase::Cancelled => {
-                    let touch_info = std::mem::take(&mut *self.touch_info.lock());
-                    let Some(touch_info) = touch_info else { return false };
-                    if !touch_info.is_scroll {
-                        emoji_is_clicked = true;
-                    }
-                }
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        match gesture {
+            GestureAction::DragStart { start } => {
+                *self.drag_state.lock() = Some((start.y, self.scroll.get()));
+                true
             }
-        }
-        if emoji_is_clicked {
-            self.click_emoji(pos).await;
-        }
+            GestureAction::DragMove { curr, .. } => {
+                let Some((start_y, start_scroll)) = *self.drag_state.lock() else { return false };
 
-        true
+                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::DragEnd { .. } => {
+                // Flick inertia is deliberately not adopted: the picker
+                // keeps its dead-stop release.
+                *self.drag_state.lock() = None;
+                true
+            }
+            GestureAction::Tap { pos } => {
+                let rect = self.rect.get();
+                self.click_emoji(pos - rect.pos()).await;
+                true
+            }
+            _ => false,
+        }
     }
 }
 

+ 0 - 122
bin/app/src/ui/gesture.rs

@@ -1,122 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2026 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use async_trait::async_trait;
-use darkfi_serial::serialize;
-use miniquad::TouchPhase;
-use std::sync::{Arc, Mutex as SyncMutex};
-
-use crate::{
-    gfx::Point,
-    prop::{PropertyUint32, Role},
-    scene::{Pimpl, SceneNodeWeak},
-};
-
-use super::UIObject;
-
-macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::gesture", $($arg)*); } }
-
-/// Maximum number of simultaneous touch events.
-/// Put 3 here because any more is ridiculous.
-const MAX_TOUCH: usize = 3;
-
-#[derive(Clone)]
-struct GestureState {
-    start: [Option<Point>; MAX_TOUCH],
-    curr: [Option<Point>; MAX_TOUCH],
-}
-
-pub type GesturePtr = Arc<Gesture>;
-
-pub struct Gesture {
-    node: SceneNodeWeak,
-    priority: PropertyUint32,
-    state: SyncMutex<GestureState>,
-}
-
-impl Gesture {
-    pub async fn new(node: SceneNodeWeak) -> Pimpl {
-        let node_ref = &node.upgrade().unwrap();
-        let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
-
-        let state = GestureState { start: [None; MAX_TOUCH], curr: [None; MAX_TOUCH] };
-
-        let self_ = Arc::new(Self { node, priority, state: SyncMutex::new(state) });
-
-        Pimpl::Gesture(self_)
-    }
-
-    fn handle_update(&self, state: GestureState) -> Option<f32> {
-        let Some(start_1) = state.start[0] else { return None };
-        let curr_1 = state.curr[0].unwrap();
-
-        let Some(start_2) = state.start[1] else { return None };
-        let curr_2 = state.curr[1].unwrap();
-
-        let start_dist_sq = start_1.dist_sq(start_2);
-        let curr_dist_sq = curr_1.dist_sq(curr_2);
-        let r = (curr_dist_sq / start_dist_sq).sqrt();
-
-        Some(r)
-    }
-}
-
-#[async_trait]
-impl UIObject for Gesture {
-    fn priority(&self) -> u32 {
-        self.priority.get()
-    }
-
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        //t!("handle_touch({phase:?}, {id}, {touch_pos:?})");
-        let id = id as usize;
-        if id >= MAX_TOUCH {
-            return false
-        }
-
-        match phase {
-            TouchPhase::Started => {
-                let mut state = self.state.lock().unwrap();
-                state.start[id] = Some(touch_pos);
-                state.curr[id] = Some(touch_pos);
-                false
-            }
-            TouchPhase::Moved => {
-                let state = {
-                    let mut state = self.state.lock().unwrap();
-                    state.curr[id] = Some(touch_pos);
-                    state.clone()
-                };
-
-                if let Some(update) = self.handle_update(state) {
-                    let node = self.node.upgrade().unwrap();
-                    d!("Gesture invoked: {update}");
-                    node.trigger("gesture", serialize(&update)).await.unwrap();
-                }
-
-                false
-            }
-            TouchPhase::Ended | TouchPhase::Cancelled => {
-                let mut state = self.state.lock().unwrap();
-                state.start = [None; MAX_TOUCH];
-                state.curr = [None; MAX_TOUCH];
-                false
-            }
-        }
-    }
-}

+ 258 - 0
bin/app/src/ui/gesture/mod.rs

@@ -0,0 +1,258 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! Gesture recognition and delivery for the app UI.
+//!
+//! One recognition system with unified thresholds replaces the five
+//! hand-rolled per-widget recognizers. Recognition mechanics (distance,
+//! time, velocity state machines) live in [`recognizer`] exactly once;
+//! [`session`] owns the touch stream, target resolution, long-press
+//! timers, move throttling, and arbitration, and delivers the
+//! [`GestureAction`] stream to widgets through `UIObject::handle_gesture`.
+
+mod recognizer;
+pub use recognizer::{Arena, MoveThrottle, VelocityTracker};
+mod session;
+pub(crate) use session::scan_children;
+pub use session::{GestureSession, GestureSessionPtr, GestureTarget};
+
+use crate::gfx::{Point, Vector};
+
+use super::long_press_timeout;
+
+/// The single set of recognition thresholds used by every widget.
+///
+/// These replace the per-widget scatter (tap strictness ranged from
+/// 0.05px to 10px across widgets). Per-node config survives only where
+/// semantic: axis lock, drag direction, `min_travel: 0.` for precision
+/// drags (see [`GestureCfg`]).
+#[derive(Debug, Clone, Copy)]
+pub struct GestureConstants {
+    /// Maximum travel between down and up that still counts as a tap,
+    /// and the stationarity bound for long-press. Also the drag start
+    /// threshold for slop-bounded drags.
+    pub touch_slop: f32,
+    /// Maximum duration of a tap, in milliseconds.
+    pub tap_max_duration: u32,
+    /// System long-press timeout in milliseconds.
+    pub long_press_timeout: u32,
+    /// Minimum time between delivered `DragMove` events, in milliseconds.
+    /// Velocity sampling still observes every move.
+    pub move_delivery_period: u32,
+    /// Velocity sample window for `DragEnd` velocity, in milliseconds.
+    pub sample_window_ms: u32,
+}
+
+impl GestureConstants {
+    /// The platform-flavored constants: Android `ViewConfiguration`
+    /// long-press timeout (via [`long_press_timeout`]), otherwise the
+    /// usual defaults.
+    pub fn platform() -> Self {
+        Self {
+            touch_slop: 10.,
+            tap_max_duration: 300,
+            long_press_timeout: long_press_timeout(),
+            move_delivery_period: 20,
+            sample_window_ms: 40,
+        }
+    }
+}
+
+impl Default for GestureConstants {
+    fn default() -> Self {
+        Self::platform()
+    }
+}
+
+/// The gesture event stream delivered to widgets.
+///
+/// `Down`/`Up` are passthrough events delivered immediately at touch
+/// start/end without waiting for recognition. `DragEnd` carries the
+/// release velocity; flick is the consumer's threshold on it. All
+/// positions are in the receiving widget's parent coordinate space.
+#[derive(Debug, Clone, Copy)]
+pub enum GestureAction {
+    /// Passthrough: a touch began on this widget.
+    Down { pos: Point },
+    /// Passthrough: the touch ended or was cancelled. Delivered as a
+    /// teardown-only notification — no recognized gesture accompanies
+    /// it for a cancelled touch.
+    Up { pos: Point },
+    /// A quick touch within slop travel and the tap duration bound.
+    Tap { pos: Point },
+    /// Fired while the finger is still down, once per touch.
+    LongPress { pos: Point },
+    /// Travel beyond the drag threshold was detected.
+    DragStart { start: Point },
+    /// Drag movement, throttled to the move delivery period.
+    DragMove { start: Point, prev: Point, curr: Point },
+    /// The drag ended. `vel` is px/sec from the sample window.
+    DragEnd { start: Point, curr: Point, vel: Vector },
+}
+
+impl GestureAction {
+    /// Translate all carried positions by `v`. Used for coordinate
+    /// translation when delivering through layers.
+    pub fn translate(&mut self, v: Vector) {
+        fn shift(p: &mut Point, v: Vector) {
+            p.x += v.x;
+            p.y += v.y;
+        }
+
+        match self {
+            Self::Down { pos } | Self::Up { pos } | Self::Tap { pos } | Self::LongPress { pos } => {
+                shift(pos, v)
+            }
+            Self::DragStart { start } => shift(start, v),
+            Self::DragMove { start, prev, curr } => {
+                shift(start, v);
+                shift(prev, v);
+                shift(curr, v);
+            }
+            Self::DragEnd { start, curr, .. } => {
+                shift(start, v);
+                shift(curr, v);
+            }
+        }
+    }
+}
+
+/// Which axes a measurement is restricted to.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Axes {
+    Both,
+    Y,
+    X,
+}
+
+impl Axes {
+    /// Travel of `delta` projected onto these axes.
+    pub fn travel(&self, delta: Point) -> f32 {
+        match self {
+            Self::Both => Point::new(delta.x, delta.y).dist(Point::zero()),
+            Self::Y => delta.y.abs(),
+            Self::X => delta.x.abs(),
+        }
+    }
+}
+
+/// The required dominant direction before a drag can start.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+    Any,
+    Vertical,
+    Horizontal,
+}
+
+impl Direction {
+    /// Whether `delta` movement is dominantly along this direction.
+    pub fn matches(&self, delta: Point) -> bool {
+        match self {
+            Self::Any => true,
+            Self::Vertical => delta.y.abs() > delta.x.abs(),
+            Self::Horizontal => delta.x.abs() > delta.y.abs(),
+        }
+    }
+}
+
+/// Configuration of a tap recognizer. All numeric thresholds come from
+/// [`GestureConstants`].
+#[derive(Debug, Clone, Copy)]
+pub struct TapCfg {
+    /// Axes the slop travel is measured on.
+    pub axes: Axes,
+}
+
+/// Configuration of a long-press recognizer.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct LongPressCfg {}
+
+/// Configuration of a drag recognizer.
+#[derive(Debug, Clone, Copy)]
+pub struct DragCfg {
+    /// Axes the start travel is measured on.
+    pub axes: Axes,
+    /// Required dominant movement direction before starting.
+    pub direction: Direction,
+    /// Travel needed before the drag starts. `None` uses the touch
+    /// slop (the standard dead-zone before scrolling). `Some(0.)` is a
+    /// precision drag that starts on the first movement (selection
+    /// handles).
+    pub min_travel: Option<f32>,
+}
+
+impl DragCfg {
+    /// Travel threshold for starting.
+    pub fn threshold(&self, slop: f32) -> f32 {
+        self.min_travel.unwrap_or(slop)
+    }
+}
+
+/// The set of gestures a widget accepts.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct GestureSet {
+    /// Tap recognizer config, if taps are accepted.
+    pub tap: Option<TapCfg>,
+    /// Long-press recognizer config, if long-presses are accepted.
+    pub long_press: Option<LongPressCfg>,
+    /// Drag recognizer config, if drags are accepted.
+    pub drag: Option<DragCfg>,
+}
+
+impl GestureSet {
+    /// Accepts nothing. Non-participating widgets are inert.
+    pub const NONE: GestureSet = GestureSet { tap: None, long_press: None, drag: None };
+
+    /// Accepts taps anywhere in the hit region.
+    pub const TAP: GestureSet =
+        GestureSet { tap: Some(TapCfg { axes: Axes::Both }), long_press: None, drag: None };
+
+    /// Vertical scroller: 1:1 vertical drag after slop plus taps.
+    pub const SCROLL_VERT: GestureSet = GestureSet {
+        tap: Some(TapCfg { axes: Axes::Both }),
+        long_press: None,
+        drag: Some(DragCfg { axes: Axes::Y, direction: Direction::Any, min_travel: None }),
+    };
+
+    /// Chat view: scroll + flick, long-press select/URL, tap forward.
+    pub const CHATVIEW: GestureSet = GestureSet {
+        tap: Some(TapCfg { axes: Axes::Both }),
+        long_press: Some(LongPressCfg {}),
+        drag: Some(DragCfg { axes: Axes::Y, direction: Direction::Any, min_travel: None }),
+    };
+
+    /// Menu: long-press edit mode, tap select/delete, drag scroll/reorder.
+    pub const MENU: GestureSet = GestureSet {
+        tap: Some(TapCfg { axes: Axes::Both }),
+        long_press: Some(LongPressCfg {}),
+        drag: Some(DragCfg { axes: Axes::Y, direction: Direction::Any, min_travel: None }),
+    };
+
+    /// Text edit hybrid: tap cursor, long-press word select, precision
+    /// drag for selection handles (armed at `Down`) and content scroll.
+    pub const EDIT: GestureSet = GestureSet {
+        tap: Some(TapCfg { axes: Axes::Both }),
+        long_press: Some(LongPressCfg {}),
+        drag: Some(DragCfg { axes: Axes::Both, direction: Direction::Any, min_travel: Some(0.) }),
+    };
+
+    /// Whether any recognizer is configured.
+    pub fn is_empty(&self) -> bool {
+        self.tap.is_none() && self.long_press.is_none() && self.drag.is_none()
+    }
+}

+ 508 - 0
bin/app/src/ui/gesture/recognizer.rs

@@ -0,0 +1,508 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! Pure gesture recognizer state machines.
+//!
+//! Everything here is deterministic and driven by explicit timestamps
+//! so recognition behavior can be pinned by unit tests. Timers are not
+//! part of the library: the session drives `Arena::long_press_due`
+//! from its own timer task. Recognition decides *what* resolved and
+//! *which chain node* claimed it; the session owns positions delivered
+//! (start/prev/curr tracking), throttling, and coordinate translation.
+
+use std::time::Duration;
+
+use crate::gfx::{Point, Vector};
+
+use super::{Axes, GestureConstants, GestureSet};
+
+/// Tracks recent touch samples to derive a release velocity.
+///
+/// Every move is observed (`push`), regardless of delivery throttling,
+/// but samples older than the window are dropped so the velocity
+/// reflects the recent movement history only.
+pub struct VelocityTracker {
+    /// Sample window
+    window: Duration,
+    /// Samples inside the window, oldest first
+    samples: Vec<(Duration, Point)>,
+}
+
+impl VelocityTracker {
+    pub fn new(sample_window_ms: u32) -> Self {
+        Self { window: Duration::from_millis(sample_window_ms as u64), samples: vec![] }
+    }
+
+    /// Record a sample at `t` (time since touch start).
+    pub fn push(&mut self, t: Duration, pos: Point) {
+        self.samples.push((t, pos));
+
+        let cutoff = t.saturating_sub(self.window);
+        while let Some((t0, _)) = self.samples.first() {
+            if *t0 < cutoff {
+                self.samples.remove(0);
+            } else {
+                break
+            }
+        }
+    }
+
+    /// Velocity in px/sec across the sample window. Zero when there is
+    /// not enough time between the oldest and newest sample.
+    pub fn velocity(&self) -> Vector {
+        let (Some((t0, p0)), Some((t1, p1))) = (self.samples.first(), self.samples.last()) else {
+            return Vector { x: 0., y: 0. }
+        };
+
+        let dt = t1.saturating_sub(*t0).as_secs_f32();
+        if dt < 0.001 {
+            return Vector { x: 0., y: 0. }
+        }
+
+        Vector { x: (p1.x - p0.x) / dt, y: (p1.y - p0.y) / dt }
+    }
+}
+
+/// Gates `DragMove` delivery to at most one event per period.
+pub struct MoveThrottle {
+    /// Delivery period
+    period: Duration,
+    /// Time of the last delivered move
+    last: Option<Duration>,
+}
+
+impl MoveThrottle {
+    pub fn new(period_ms: u32) -> Self {
+        Self { period: Duration::from_millis(period_ms as u64), last: None }
+    }
+
+    /// Whether a move at `t` (time since touch start) may be delivered.
+    /// Records the delivery when it returns true.
+    pub fn should_deliver(&mut self, t: Duration) -> bool {
+        let due = match self.last {
+            Some(last) => t.saturating_sub(last) >= self.period,
+            None => true,
+        };
+
+        if due {
+            self.last = Some(t);
+        }
+
+        due
+    }
+}
+
+/// What a recognizer resolved, and the index of the chain node whose
+/// recognizer claimed it. Positions are composed by the session.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Recognition {
+    Tap,
+    LongPress,
+    DragStart,
+    DragMove,
+    DragEnd { vel: Vector },
+}
+
+/// Per-node recognizer state for one touch.
+struct NodeRec {
+    tap: Option<Axes>,
+    tap_alive: bool,
+    drag: Option<super::DragCfg>,
+    drag_alive: bool,
+    drag_started: bool,
+    long_press_alive: bool,
+    long_press_fired: bool,
+}
+
+/// The recognition arena for one touch: all recognizers configured by
+/// the resolved target chain observe the stream, and the first to
+/// resolve claims the gesture while competing recognizers are
+/// cancelled (first-resolved-wins arbitration).
+pub struct Arena {
+    /// Recognition thresholds
+    consts: GestureConstants,
+    /// Recognizer state per chain node, chain order (root first)
+    nodes: Vec<NodeRec>,
+    /// Index of the node whose drag recognizer claimed the touch
+    drag_claimant: Option<usize>,
+}
+
+impl Arena {
+    /// Build the arena from the chain's gesture sets (root first).
+    pub fn new(consts: GestureConstants, sets: &[GestureSet]) -> Self {
+        let nodes = sets
+            .iter()
+            .map(|set| NodeRec {
+                tap: set.tap.map(|cfg| cfg.axes),
+                tap_alive: set.tap.is_some(),
+                drag: set.drag,
+                drag_alive: set.drag.is_some(),
+                drag_started: false,
+                long_press_alive: set.long_press.is_some(),
+                long_press_fired: false,
+            })
+            .collect();
+
+        Self { consts, nodes, drag_claimant: None }
+    }
+
+    /// Whether any chain node accepts a long-press, i.e. whether the
+    /// session needs to arm a long-press timer for this touch.
+    pub fn wants_long_press(&self) -> bool {
+        self.nodes.iter().any(|n| n.long_press_alive)
+    }
+
+    /// Whether the claiming drag recognizer has started.
+    pub fn drag_started(&self) -> bool {
+        self.drag_claimant.is_some()
+    }
+
+    /// Observe a move. `t` is time since touch start, `delta` is the
+    /// movement since touch start. Returns recognitions in delivery
+    /// order (at most one).
+    pub fn on_move(&mut self, t: Duration, delta: Point) -> Vec<(usize, Recognition)> {
+        let _ = t;
+
+        if let Some(claimant) = self.drag_claimant {
+            return vec![(claimant, Recognition::DragMove)]
+        }
+
+        // Not dragging yet. Check drag starts deepest-first: between
+        // recognizers resolving at the same moment, the descendant
+        // wins ("the ancestor's drag wins over the descendant's
+        // pending tap" is the tap case below; among drags the deeper
+        // node is the more specific one).
+        for i in (0..self.nodes.len()).rev() {
+            let Some(cfg) = self.nodes[i].drag else { continue };
+
+            if !self.nodes[i].drag_alive {
+                continue
+            }
+
+            let travel = cfg.axes.travel(delta);
+            if travel > cfg.threshold(self.consts.touch_slop) && cfg.direction.matches(delta) {
+                self.claim_drag(i);
+                return vec![(i, Recognition::DragStart)]
+            }
+        }
+
+        // No drag started. Movement beyond slop cancels pending taps
+        // and long-presses.
+        let slop = self.consts.touch_slop;
+        for node in &mut self.nodes {
+            if node.tap_alive {
+                let axes = node.tap.unwrap_or(Axes::Both);
+                node.tap_alive = axes.travel(delta) <= slop;
+            }
+
+            if node.long_press_alive {
+                node.long_press_alive = Axes::Both.travel(delta) <= slop;
+            }
+        }
+
+        vec![]
+    }
+
+    /// Observe the touch ending at `t`. `vel` is the release velocity
+    /// from the session's sample tracker. Returns recognitions in
+    /// delivery order: `DragEnd` for an active drag, else `Tap`.
+    pub fn on_up(&mut self, t: Duration, vel: Vector) -> Vec<(usize, Recognition)> {
+        if let Some(claimant) = self.drag_claimant {
+            return vec![(claimant, Recognition::DragEnd { vel })]
+        }
+
+        if t > Duration::from_millis(self.consts.tap_max_duration as u64) {
+            return vec![]
+        }
+
+        // The descendant's tap wins over any pending ancestor tap.
+        for i in (0..self.nodes.len()).rev() {
+            if self.nodes[i].tap_alive {
+                return vec![(i, Recognition::Tap)]
+            }
+        }
+
+        vec![]
+    }
+
+    /// Called by the session's long-press timer at the timeout.
+    /// Long-press fires while the finger is still down, once per
+    /// touch, and cancels every pending tap.
+    pub fn long_press_due(&mut self) -> Option<(usize, Recognition)> {
+        if self.drag_claimant.is_some() {
+            return None
+        }
+
+        for i in (0..self.nodes.len()).rev() {
+            let node = &mut self.nodes[i];
+            if node.long_press_alive && !node.long_press_fired {
+                node.long_press_fired = true;
+
+                for n in &mut self.nodes {
+                    n.tap_alive = false;
+                }
+
+                return Some((i, Recognition::LongPress))
+            }
+        }
+
+        None
+    }
+
+    fn claim_drag(&mut self, claimant: usize) {
+        // Cascade cancellation: a resolved drag cancels every other
+        // pending recognizer, including other nodes' drags.
+        for node in &mut self.nodes {
+            node.tap_alive = false;
+            node.long_press_alive = false;
+            node.drag_alive = false;
+        }
+
+        self.nodes[claimant].drag_started = true;
+        self.drag_claimant = Some(claimant);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::ui::gesture::{Direction, DragCfg, LongPressCfg, TapCfg};
+
+    const CONSTS: GestureConstants = GestureConstants {
+        touch_slop: 10.,
+        tap_max_duration: 300,
+        long_press_timeout: 400,
+        move_delivery_period: 20,
+        sample_window_ms: 40,
+    };
+
+    fn delta(x: f32, y: f32) -> Point {
+        Point::new(x, y)
+    }
+
+    fn scroll_set() -> GestureSet {
+        GestureSet {
+            tap: Some(TapCfg { axes: Axes::Both }),
+            long_press: Some(LongPressCfg {}),
+            drag: Some(DragCfg { axes: Axes::Y, direction: Direction::Any, min_travel: None }),
+        }
+    }
+
+    fn tap_set() -> GestureSet {
+        GestureSet { tap: Some(TapCfg { axes: Axes::Both }), long_press: None, drag: None }
+    }
+
+    fn drag_set() -> GestureSet {
+        GestureSet {
+            tap: None,
+            long_press: None,
+            drag: Some(DragCfg { axes: Axes::Both, direction: Direction::Any, min_travel: None }),
+        }
+    }
+
+    #[test]
+    fn velocity_from_sample_window() {
+        let mut vel = VelocityTracker::new(40);
+
+        // Outside the window: ignored
+        vel.push(Duration::from_millis(0), Point::new(0., 0.));
+        vel.push(Duration::from_millis(100), Point::new(0., 0.));
+        vel.push(Duration::from_millis(120), Point::new(0., 30.));
+        vel.push(Duration::from_millis(140), Point::new(0., 60.));
+
+        // Window is [100, 140]: 60px over 40ms = 1500 px/s
+        let v = vel.velocity();
+        assert!((v.y - 1500.).abs() < 0.01, "vel.y = {}", v.y);
+        assert!(v.x.abs() < 0.01);
+    }
+
+    #[test]
+    fn velocity_zero_when_stale_samples() {
+        let mut vel = VelocityTracker::new(40);
+        vel.push(Duration::from_millis(0), Point::new(0., 0.));
+        assert_eq!(vel.velocity(), Vector { x: 0., y: 0. });
+
+        // All samples at (nearly) the same time: no measurable dt
+        let mut vel = VelocityTracker::new(40);
+        vel.push(Duration::from_millis(10), Point::new(0., 0.));
+        vel.push(Duration::from_millis(10), Point::new(0., 50.));
+        assert_eq!(vel.velocity(), Vector { x: 0., y: 0. });
+    }
+
+    #[test]
+    fn move_throttle_gates_delivery() {
+        let mut throttle = MoveThrottle::new(20);
+
+        assert!(throttle.should_deliver(Duration::from_millis(0)));
+        assert!(!throttle.should_deliver(Duration::from_millis(5)));
+        assert!(!throttle.should_deliver(Duration::from_millis(19)));
+        assert!(throttle.should_deliver(Duration::from_millis(20)));
+        assert!(throttle.should_deliver(Duration::from_millis(45)));
+    }
+
+    #[test]
+    fn tap_within_slop() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        let recs = arena.on_move(Duration::from_millis(50), delta(4., 4.));
+        assert!(recs.is_empty());
+
+        let recs = arena.on_up(Duration::from_millis(200), Vector { x: 0., y: 0. });
+        assert_eq!(recs, vec![(0, Recognition::Tap)]);
+    }
+
+    #[test]
+    fn tap_movement_beyond_slop_becomes_drag() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        let recs = arena.on_move(Duration::from_millis(50), delta(3., 12.));
+        assert_eq!(recs, vec![(0, Recognition::DragStart)]);
+
+        let recs = arena.on_up(Duration::from_millis(120), Vector { x: 0., y: 0. });
+        assert!(matches!(recs.as_slice(), [(0, Recognition::DragEnd { .. })]));
+        assert!(!matches!(recs.as_slice(), [(0, Recognition::Tap)]));
+    }
+
+    #[test]
+    fn tap_duration_bound() {
+        let mut arena = Arena::new(CONSTS, &[tap_set()]);
+
+        // Stationary but too slow
+        arena.on_move(Duration::from_millis(50), delta(0., 0.));
+        let recs = arena.on_up(Duration::from_millis(400), Vector { x: 0., y: 0. });
+        assert!(recs.is_empty());
+    }
+
+    #[test]
+    fn long_press_fires_during_hold() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+        assert!(arena.wants_long_press());
+
+        arena.on_move(Duration::from_millis(100), delta(2., 2.));
+        let rec = arena.long_press_due();
+        assert_eq!(rec, Some((0, Recognition::LongPress)));
+
+        // At most once per touch
+        assert_eq!(arena.long_press_due(), None);
+    }
+
+    #[test]
+    fn long_press_cancelled_by_movement() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        arena.on_move(Duration::from_millis(100), delta(0., 15.));
+        assert_eq!(arena.long_press_due(), None);
+    }
+
+    #[test]
+    fn long_press_cancels_tap() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        arena.long_press_due();
+        let recs = arena.on_up(Duration::from_millis(500), Vector { x: 0., y: 0. });
+        assert!(recs.is_empty());
+    }
+
+    #[test]
+    fn drag_cancels_pending_long_press() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        arena.on_move(Duration::from_millis(50), delta(0., 20.));
+        assert_eq!(arena.long_press_due(), None);
+    }
+
+    #[test]
+    fn descendant_tap_wins_within_slop() {
+        // Chain: ancestor scroller (root, idx 0), descendant tapper (idx 1)
+        let sets = [scroll_set(), tap_set()];
+        let mut arena = Arena::new(CONSTS, &sets);
+
+        let recs = arena.on_move(Duration::from_millis(50), delta(5., 5.));
+        assert!(recs.is_empty());
+
+        let recs = arena.on_up(Duration::from_millis(150), Vector { x: 0., y: 0. });
+        assert_eq!(recs, vec![(1, Recognition::Tap)]);
+    }
+
+    #[test]
+    fn ancestor_drag_wins_beyond_slop() {
+        let sets = [scroll_set(), tap_set()];
+        let mut arena = Arena::new(CONSTS, &sets);
+
+        let recs = arena.on_move(Duration::from_millis(50), delta(0., 20.));
+        assert_eq!(recs, vec![(0, Recognition::DragStart)]);
+
+        let recs = arena.on_up(Duration::from_millis(150), Vector { x: 0., y: 0. });
+        assert!(matches!(recs.as_slice(), [(0, Recognition::DragEnd { .. })]));
+    }
+
+    #[test]
+    fn precision_drag_starts_on_first_movement() {
+        let set = GestureSet {
+            tap: Some(TapCfg { axes: Axes::Both }),
+            long_press: None,
+            drag: Some(DragCfg {
+                axes: Axes::Both,
+                direction: Direction::Any,
+                min_travel: Some(0.),
+            }),
+        };
+        let mut arena = Arena::new(CONSTS, &[set]);
+
+        // 1px is beyond a zero threshold
+        let recs = arena.on_move(Duration::from_millis(10), delta(1., 0.));
+        assert_eq!(recs, vec![(0, Recognition::DragStart)]);
+
+        let recs = arena.on_up(Duration::from_millis(100), Vector { x: 0., y: 0. });
+        assert!(matches!(recs.as_slice(), [(0, Recognition::DragEnd { .. })]));
+    }
+
+    #[test]
+    fn axis_locked_drag_ignores_off_axis_movement() {
+        let mut arena = Arena::new(CONSTS, &[scroll_set()]);
+
+        // Horizontal travel never starts a y-locked drag
+        let recs = arena.on_move(Duration::from_millis(50), delta(25., 0.));
+        assert!(recs.is_empty());
+
+        // But it did cancel the tap
+        let recs = arena.on_up(Duration::from_millis(100), Vector { x: 0., y: 0. });
+        assert!(recs.is_empty());
+    }
+
+    #[test]
+    fn directional_gate_blocks_orthogonal_drag() {
+        let set = GestureSet {
+            tap: Some(TapCfg { axes: Axes::Both }),
+            long_press: None,
+            drag: Some(DragCfg {
+                axes: Axes::Both,
+                direction: Direction::Horizontal,
+                min_travel: None,
+            }),
+        };
+        let mut arena = Arena::new(CONSTS, &[set]);
+
+        let recs = arena.on_move(Duration::from_millis(50), delta(0., 25.));
+        assert!(recs.is_empty(), "vertical movement must not start a horizontal drag");
+
+        let recs = arena.on_move(Duration::from_millis(80), delta(25., 0.));
+        assert_eq!(recs, vec![(0, Recognition::DragStart)]);
+    }
+}

+ 850 - 0
bin/app/src/ui/gesture/session.rs

@@ -0,0 +1,850 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! The window-level gesture session.
+//!
+//! The session is fed from the miniquad Stage thread at the `gfx`
+//! touch entry, before any sync claiming, so recognition observes
+//! every touch. At touch start it resolves the sticky target chain by
+//! hit-testing the widget tree in priority order; all events for that
+//! touch go to that chain until the touch ends or is cancelled. The
+//! recognizer math runs inline on the Stage thread (pure, cheap);
+//! long-press timers are version-guarded executor tasks; delivery is
+//! async through a single-consumer channel so events arrive in
+//! recognition order.
+
+use miniquad::TouchPhase;
+use parking_lot::Mutex as SyncMutex;
+use std::{
+    sync::Arc,
+    time::{Duration, Instant},
+};
+
+use crate::{
+    gfx::{Point, Vector},
+    scene::{SceneNodePtr, SceneNodeWeak},
+    ExecutorPtr,
+};
+
+use super::recognizer::Recognition;
+use crate::ui::{get_children_ordered, get_ui_object_ptr, UIObject};
+
+use super::{Arena, GestureAction, GestureConstants, GestureSet, MoveThrottle, VelocityTracker};
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::gesture::session", $($arg)*); } }
+macro_rules! e { ($($arg:tt)*) => { error!(target: "ui::gesture::session", $($arg)*); } }
+
+/// One link of a resolved gesture target chain.
+#[derive(Clone)]
+pub struct GestureTarget {
+    /// The widget
+    pub obj: Arc<dyn UIObject + Send>,
+    /// Translation from window space into the widget's parent space,
+    /// accumulated from the layers above it.
+    pub offset: Point,
+}
+
+/// Extend the gesture chain under `pos` (given in the children's
+/// parent space) from `children`, which must be in priority order
+/// (highest priority first): the first child that passes hit-testing
+/// owns the touch and the chain descends into it.
+pub(crate) fn scan_children(
+    children: &[Arc<dyn UIObject + Send>],
+    pos: Point,
+    offset: Point,
+    chain: &mut Vec<GestureTarget>,
+) {
+    for child in children {
+        if child.gesture_hit_test(pos) {
+            chain.push(GestureTarget { obj: child.clone(), offset });
+            child.gesture_descend(pos, offset, chain);
+            break
+        }
+    }
+}
+
+/// A resolved gesture event awaiting delivery.
+struct Delivery {
+    /// Delivery target
+    target: GestureTarget,
+    /// The action, in window coordinates
+    action: GestureAction,
+}
+
+impl GestureTarget {
+    fn delivery(&self, action: GestureAction) -> Delivery {
+        Delivery { target: self.clone(), action }
+    }
+
+    /// Deliver `action` translated into this target's space.
+    async fn dispatch(&self, mut action: GestureAction) -> bool {
+        let off = self.offset;
+        action.translate(Vector { x: -off.x, y: -off.y });
+        self.obj.handle_gesture(action).await
+    }
+}
+
+/// Recognition state for the active (primary) touch.
+struct ActiveTouch {
+    /// Touch id that owns recognition
+    id: u64,
+    /// Sticky target chain resolved at touch start, root first
+    chain: Vec<GestureTarget>,
+    /// Recognizers observing this touch
+    arena: Arena,
+    /// Release velocity samples; observes every move
+    vel: VelocityTracker,
+    /// DragMove delivery gate
+    throttle: MoveThrottle,
+    /// Touch start, window space
+    start: Point,
+    /// Last position a DragMove was delivered for, window space
+    prev_move: Point,
+    /// Latest observed position, window space
+    curr: Point,
+    start_instant: Instant,
+}
+
+impl ActiveTouch {
+    fn elapsed(&self, now: Instant) -> Duration {
+        now.saturating_duration_since(self.start_instant)
+    }
+
+    /// The deepest chain entry: the hit-tested target receiving the
+    /// `Down`/`Up` passthrough.
+    fn deepest(&self) -> &GestureTarget {
+        self.chain.last().unwrap()
+    }
+
+    fn action_for(&self, rec: Recognition, pos: Point) -> GestureAction {
+        match rec {
+            Recognition::Tap => GestureAction::Tap { pos },
+            Recognition::LongPress => GestureAction::LongPress { pos },
+            Recognition::DragStart => GestureAction::DragStart { start: self.start },
+            Recognition::DragMove => {
+                GestureAction::DragMove { start: self.start, prev: self.prev_move, curr: pos }
+            }
+            Recognition::DragEnd { vel } => {
+                GestureAction::DragEnd { start: self.start, curr: pos, vel }
+            }
+        }
+    }
+
+    fn moved(&mut self, pos: Point, now: Instant) -> Vec<Delivery> {
+        let elapsed = self.elapsed(now);
+        self.vel.push(elapsed, pos);
+        self.curr = pos;
+
+        let recs = self.arena.on_move(elapsed, pos - self.start);
+        let mut out = vec![];
+
+        for (i, rec) in recs {
+            match rec {
+                Recognition::DragStart => {
+                    let action = self.action_for(rec, pos);
+                    out.push(self.chain[i].delivery(action));
+                }
+                Recognition::DragMove => {
+                    // Delivery is throttled to the move period; the
+                    // velocity tracker above still saw this sample.
+                    if self.throttle.should_deliver(elapsed) {
+                        let action = self.action_for(rec, pos);
+                        self.prev_move = pos;
+                        out.push(self.chain[i].delivery(action));
+                    }
+                }
+                _ => {}
+            }
+        }
+
+        out
+    }
+
+    fn ended(&mut self, pos: Point, now: Instant) -> Vec<Delivery> {
+        let elapsed = self.elapsed(now);
+        self.vel.push(elapsed, pos);
+        self.curr = pos;
+
+        let vel = self.vel.velocity();
+        let recs = self.arena.on_up(elapsed, vel);
+        let mut out = vec![];
+
+        for (i, rec) in recs {
+            let action = self.action_for(rec, pos);
+            out.push(self.chain[i].delivery(action));
+        }
+
+        out.push(self.deepest().delivery(GestureAction::Up { pos }));
+        out
+    }
+}
+
+/// Inner state guarded by the session lock.
+struct Inner {
+    /// Bumped whenever the active touch changes; long-press timers
+    /// validate against it so a stale timer cannot fire.
+    gen: u64,
+    /// The active touch, if any. Secondary touch ids are ignored.
+    active: Option<ActiveTouch>,
+}
+
+/// The window-owned gesture session: target resolution, recognition,
+/// timers, throttling, arbitration, and async delivery.
+///
+/// Lock ordering invariant: `inner` is the innermost lock. It is taken
+/// on the Stage thread and, while held, the tree walk takes scene and
+/// widget locks below it — so nothing may ever take `inner` from
+/// inside `handle_gesture` (or with widget locks held); widgets have
+/// no session handle today and must stay that way.
+pub struct GestureSession {
+    /// The window node; chain resolution starts at its children
+    node: SceneNodeWeak,
+    /// Executor for the long-press timers and the delivery consumer
+    ex: ExecutorPtr,
+    /// Recognition thresholds
+    consts: GestureConstants,
+    inner: SyncMutex<Inner>,
+    delivery_tx: async_channel::Sender<Delivery>,
+}
+
+pub type GestureSessionPtr = Arc<GestureSession>;
+
+impl GestureSession {
+    pub fn new(node: SceneNodeWeak, ex: ExecutorPtr) -> GestureSessionPtr {
+        let (delivery_tx, delivery_rx) = async_channel::unbounded::<Delivery>();
+
+        // Single consumer so deliveries arrive in recognition order. A
+        // panicking widget handler must not kill the input stream, so
+        // each dispatch is panic-isolated (the panic is reported by
+        // the default hook; delivery for that event is simply lost).
+        ex.spawn(async move {
+            loop {
+                let Ok(delivery) = delivery_rx.recv().await else {
+                    t!("Gesture delivery channel closed");
+                    break
+                };
+
+                let dispatched =
+                    futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(async {
+                        delivery.target.dispatch(delivery.action).await
+                    }))
+                    .await;
+
+                match dispatched {
+                    Ok(true) => {}
+                    Ok(false) => {
+                        t!("Gesture not claimed by target");
+                    }
+                    Err(panic) => {
+                        let msg = panic
+                            .downcast_ref::<&str>()
+                            .map(|s| s.to_string())
+                            .or_else(|| panic.downcast_ref::<String>().cloned())
+                            .unwrap_or_else(|| "unknown panic".to_string());
+                        e!("Gesture handler panicked: {msg}");
+                    }
+                }
+            }
+        })
+        .detach();
+
+        Arc::new(Self {
+            node,
+            ex,
+            consts: GestureConstants::platform(),
+            inner: SyncMutex::new(Inner { gen: 0, active: None }),
+            delivery_tx,
+        })
+    }
+
+    /// Stage-thread touch feed. Runs recognition inline and queues
+    /// gesture delivery onto the executor.
+    pub fn touch_event(self: &Arc<Self>, phase: TouchPhase, id: u64, pos: Point) {
+        let now = Instant::now();
+        let (deliveries, timer_gen) = {
+            let mut inner = self.inner.lock();
+            match phase {
+                TouchPhase::Started => {
+                    // Secondary touches never disturb the primary.
+                    if inner.active.is_some() {
+                        (vec![], None)
+                    } else {
+                        let chain = self.resolve_chain(pos);
+                        if chain.is_empty() {
+                            (vec![], None)
+                        } else {
+                            let sets: Vec<GestureSet> =
+                                chain.iter().map(|target| target.obj.gesture_set()).collect();
+                            let arena = Arena::new(self.consts, &sets);
+                            let timer_gen = arena.wants_long_press().then_some(inner.gen + 1);
+                            inner.gen += 1;
+
+                            let down = chain.last().unwrap().delivery(GestureAction::Down { pos });
+
+                            inner.active = Some(ActiveTouch {
+                                id,
+                                chain,
+                                arena,
+                                vel: VelocityTracker::new(self.consts.sample_window_ms),
+                                throttle: MoveThrottle::new(self.consts.move_delivery_period),
+                                start: pos,
+                                prev_move: pos,
+                                curr: pos,
+                                start_instant: now,
+                            });
+
+                            (vec![down], timer_gen)
+                        }
+                    }
+                }
+                TouchPhase::Moved => match &mut inner.active {
+                    Some(active) if active.id == id => (active.moved(pos, now), None),
+                    _ => (vec![], None),
+                },
+                TouchPhase::Ended => match inner.active.take() {
+                    Some(mut active) if active.id == id => {
+                        inner.gen += 1;
+                        (active.ended(pos, now), None)
+                    }
+                    stale => {
+                        inner.active = stale;
+                        (vec![], None)
+                    }
+                },
+                TouchPhase::Cancelled => {
+                    // Tear down all pending recognition state and
+                    // timers. No recognized gesture (Tap, LongPress,
+                    // DragEnd) is emitted for a cancelled touch, but
+                    // the `Up` passthrough is still delivered so
+                    // widgets can tear down their armed state — the
+                    // old handlers treated Ended and Cancelled alike.
+                    match inner.active.take() {
+                        Some(mut active) if active.id == id => {
+                            inner.gen += 1;
+                            (
+                                vec![active
+                                    .deepest()
+                                    .delivery(GestureAction::Up { pos: active.curr })],
+                                None,
+                            )
+                        }
+                        stale => {
+                            inner.active = stale;
+                            (vec![], None)
+                        }
+                    }
+                }
+            }
+        };
+
+        if let Some(gen) = timer_gen {
+            let timeout = self.consts.long_press_timeout;
+            let me = Arc::downgrade(self);
+            self.ex
+                .spawn(async move {
+                    darkfi::system::msleep(timeout as u64).await;
+                    let Some(session) = me.upgrade() else { return };
+                    session.fire_long_press(gen);
+                })
+                .detach();
+        }
+
+        for d in deliveries {
+            let _ = self.delivery_tx.try_send(d);
+        }
+    }
+
+    /// Resolve the sticky target chain at touch start: hit-test the
+    /// tree in priority order, descending through layers with
+    /// coordinate translation.
+    fn resolve_chain(&self, pos: Point) -> Vec<GestureTarget> {
+        let Some(node) = self.node.upgrade() else { return vec![] };
+        let children = ordered_objs(&node);
+        let mut chain = vec![];
+        scan_children(&children, pos, Point::zero(), &mut chain);
+        t!("Resolved gesture chain of {} nodes", chain.len());
+        chain
+    }
+
+    /// Long-press timer fire: validate the generation, let the arena
+    /// decide, and queue the delivery.
+    fn fire_long_press(&self, gen: u64) {
+        let deliveries = {
+            let mut inner = self.inner.lock();
+            if inner.gen != gen {
+                // The touch ended, was cancelled, or was replaced.
+                return
+            }
+
+            let Some(active) = &mut inner.active else { return };
+            let Some((i, rec)) = active.arena.long_press_due() else { return };
+            let pos = active.curr;
+            let action = active.action_for(rec, pos);
+            vec![active.chain[i].delivery(action)]
+        };
+
+        for d in deliveries {
+            let _ = self.delivery_tx.try_send(d);
+        }
+    }
+}
+
+/// Collect a node's children as UI objects in priority order.
+fn ordered_objs(node: &SceneNodePtr) -> Vec<Arc<dyn UIObject + Send>> {
+    get_children_ordered(node).iter().map(|child| get_ui_object_ptr(child)).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use async_trait::async_trait;
+    use std::sync::Mutex as StdMutex;
+
+    use crate::{
+        gfx::Rectangle,
+        prop::{PropertyAtomicGuard, Role},
+        scene::{SceneNode, Slot},
+        ui::{
+            gesture::{Axes, Direction, DragCfg, GestureConstants, LongPressCfg, TapCfg},
+            Button, Layer, RedrawTrigger, UIObject,
+        },
+        Renderer,
+    };
+
+    /// A recording gesture widget. Leaf probes hit-test their rect;
+    /// layer probes forward to children with an origin translation,
+    /// mirroring `Layer`.
+    struct Probe {
+        label: &'static str,
+        set: GestureSet,
+        hit: Option<Rectangle>,
+        origin: Point,
+        children: Vec<Arc<Probe>>,
+        log: Arc<StdMutex<Vec<(&'static str, GestureAction)>>>,
+    }
+
+    impl Probe {
+        fn leaf(
+            label: &'static str,
+            set: GestureSet,
+            hit: Rectangle,
+            log: Arc<StdMutex<Vec<(&'static str, GestureAction)>>>,
+        ) -> Arc<Self> {
+            Arc::new(Self {
+                label,
+                set,
+                hit: Some(hit),
+                origin: Point::zero(),
+                children: vec![],
+                log,
+            })
+        }
+
+        fn layer(
+            label: &'static str,
+            origin: Point,
+            children: Vec<Arc<Probe>>,
+            log: Arc<StdMutex<Vec<(&'static str, GestureAction)>>>,
+        ) -> Arc<Self> {
+            Arc::new(Self { label, set: GestureSet::NONE, hit: None, origin, children, log })
+        }
+    }
+
+    #[async_trait]
+    impl UIObject for Probe {
+        fn priority(&self) -> u32 {
+            0
+        }
+
+        fn gesture_set(&self) -> GestureSet {
+            self.set
+        }
+
+        fn gesture_hit_test(&self, pos: Point) -> bool {
+            if let Some(rect) = self.hit {
+                return rect.contains(pos)
+            }
+
+            let local = pos - self.origin;
+            self.children.iter().any(|child| child.gesture_hit_test(local))
+        }
+
+        fn gesture_descend(&self, pos: Point, offset: Point, chain: &mut Vec<GestureTarget>) {
+            let local = pos - self.origin;
+            let children: Vec<Arc<dyn UIObject + Send>> = self
+                .children
+                .iter()
+                .map(|child| child.clone() as Arc<dyn UIObject + Send>)
+                .collect();
+            scan_children(&children, local, offset + self.origin, chain);
+        }
+
+        async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+            self.log.lock().unwrap().push((self.label, gesture));
+            true
+        }
+    }
+
+    const TEST_CONSTS: GestureConstants = GestureConstants {
+        touch_slop: 10.,
+        tap_max_duration: 300,
+        long_press_timeout: 400,
+        move_delivery_period: 20,
+        sample_window_ms: 40,
+    };
+
+    fn scroller_set() -> GestureSet {
+        GestureSet {
+            tap: Some(TapCfg { axes: Axes::Both }),
+            long_press: Some(LongPressCfg {}),
+            drag: Some(DragCfg { axes: Axes::Y, direction: Direction::Any, min_travel: None }),
+        }
+    }
+
+    fn make_touch(chain: Vec<GestureTarget>, pos: Point) -> ActiveTouch {
+        let sets: Vec<GestureSet> = chain.iter().map(|target| target.obj.gesture_set()).collect();
+        ActiveTouch {
+            id: 0,
+            arena: Arena::new(TEST_CONSTS, &sets),
+            vel: VelocityTracker::new(TEST_CONSTS.sample_window_ms),
+            throttle: MoveThrottle::new(TEST_CONSTS.move_delivery_period),
+            chain,
+            start: pos,
+            prev_move: pos,
+            curr: pos,
+            start_instant: Instant::now(),
+        }
+    }
+
+    fn deliver(deliveries: Vec<Delivery>) {
+        for d in deliveries {
+            smol::block_on(d.target.dispatch(d.action));
+        }
+    }
+
+    fn labels(log: &Arc<StdMutex<Vec<(&'static str, GestureAction)>>>) -> Vec<&'static str> {
+        log.lock().unwrap().iter().map(|(label, _)| *label).collect()
+    }
+
+    #[test]
+    fn scan_first_passer_in_priority_order_owns() {
+        let log = Arc::new(StdMutex::new(vec![]));
+        let hi =
+            Probe::leaf("hi", GestureSet::TAP, Rectangle::new(0., 0., 100., 100.), log.clone());
+        let lo =
+            Probe::leaf("lo", GestureSet::TAP, Rectangle::new(0., 0., 100., 100.), log.clone());
+
+        let children: Vec<Arc<dyn UIObject + Send>> =
+            vec![hi.clone() as Arc<dyn UIObject + Send>, lo.clone() as Arc<dyn UIObject + Send>];
+
+        let mut chain = vec![];
+        scan_children(&children, Point::new(50., 50.), Point::zero(), &mut chain);
+        assert_eq!(chain.len(), 1);
+
+        // The first child in (priority-ordered) input owns the touch
+        let handled =
+            smol::block_on(chain[0].dispatch(GestureAction::Tap { pos: Point::new(1., 1.) }));
+        assert!(handled);
+        assert_eq!(labels(&log), vec!["hi"]);
+    }
+
+    #[test]
+    fn scan_descends_with_translation() {
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf =
+            Probe::leaf("leaf", GestureSet::TAP, Rectangle::new(0., 0., 100., 50.), log.clone());
+        let layer = Probe::layer("layer", Point::new(100., 50.), vec![leaf.clone()], log.clone());
+
+        let children: Vec<Arc<dyn UIObject + Send>> = vec![layer as Arc<dyn UIObject + Send>];
+
+        let mut chain = vec![];
+        scan_children(&children, Point::new(150., 80.), Point::zero(), &mut chain);
+        assert_eq!(chain.len(), 2);
+        assert_eq!(chain[1].offset, Point::new(100., 50.));
+    }
+
+    #[test]
+    fn tap_delivered_in_local_coords_to_deepest() {
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf =
+            Probe::leaf("leaf", GestureSet::TAP, Rectangle::new(0., 0., 100., 50.), log.clone());
+        let target = GestureTarget {
+            obj: leaf.clone() as Arc<dyn UIObject + Send>,
+            offset: Point::new(100., 50.),
+        };
+
+        let mut touch = make_touch(vec![target], Point::new(150., 80.));
+        let down = touch.deepest().delivery(GestureAction::Down { pos: Point::new(150., 80.) });
+        let mut up = touch.ended(Point::new(150., 80.), Instant::now());
+        let mut deliveries = vec![down];
+        deliveries.append(&mut up);
+        deliver(deliveries);
+
+        // Only the deepest chain entry receives anything, and the
+        // positions are translated into its space
+        assert_eq!(labels(&log), vec!["leaf", "leaf", "leaf"]);
+        let events = log.lock().unwrap();
+        assert!(matches!(events[0].1, GestureAction::Down { pos } if pos == Point::new(50., 30.)));
+        assert!(matches!(events[1].1, GestureAction::Tap { pos } if pos == Point::new(50., 30.)));
+        assert!(matches!(events[2].1, GestureAction::Up { pos } if pos == Point::new(50., 30.)));
+    }
+
+    #[test]
+    fn drag_lifecycle_throttle_and_velocity() {
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf = Probe::leaf(
+            "scroller",
+            scroller_set(),
+            Rectangle::new(0., 0., 100., 200.),
+            log.clone(),
+        );
+        let target = GestureTarget { obj: leaf as Arc<dyn UIObject + Send>, offset: Point::zero() };
+
+        let mut touch = make_touch(vec![target], Point::new(50., 100.));
+        let mut all = vec![];
+
+        // Beyond slop: DragStart
+        std::thread::sleep(Duration::from_millis(2));
+        all.append(&mut touch.moved(Point::new(50., 125.), Instant::now()));
+
+        // Moves 5ms apart, 10px apart: throttled to 20ms delivery
+        for i in 1..=5u32 {
+            std::thread::sleep(Duration::from_millis(5));
+            all.append(&mut touch.moved(Point::new(50., 125. + 10. * i as f32), Instant::now()));
+        }
+
+        // Let the sample window be dominated by the last two samples
+        std::thread::sleep(Duration::from_millis(45));
+        all.append(&mut touch.moved(Point::new(50., 225.), Instant::now()));
+        std::thread::sleep(Duration::from_millis(25));
+        all.append(&mut touch.ended(Point::new(50., 265.), Instant::now()));
+
+        deliver(all);
+
+        let events = log.lock().unwrap();
+        let starts =
+            events.iter().filter(|(_, a)| matches!(a, GestureAction::DragStart { .. })).count();
+        let moves =
+            events.iter().filter(|(_, a)| matches!(a, GestureAction::DragMove { .. })).count();
+        let ends =
+            events.iter().filter(|(_, a)| matches!(a, GestureAction::DragEnd { .. })).count();
+        assert_eq!(starts, 1, "exactly one DragStart");
+        assert_eq!(ends, 1, "exactly one DragEnd");
+        assert_eq!(moves, 3, "6 post-start moves throttled to 20ms delivery: {moves}");
+
+        let end_ev = events
+            .iter()
+            .find(|(_, a)| matches!(a, GestureAction::DragEnd { .. }))
+            .expect("no DragEnd");
+        let GestureAction::DragEnd { vel, curr, .. } = end_ev.1 else { unreachable!() };
+        assert!(curr == Point::new(50., 265.));
+
+        // Last samples: 25ms between (225 -> 265): ~1600 px/s
+        assert!((vel.y - 1600.).abs() < 500., "vel.y = {}", vel.y);
+        assert!(vel.x.abs() < 1.);
+    }
+
+    #[test]
+    fn drag_move_prev_chaining() {
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf = Probe::leaf(
+            "scroller",
+            scroller_set(),
+            Rectangle::new(0., 0., 100., 200.),
+            log.clone(),
+        );
+        let target = GestureTarget { obj: leaf as Arc<dyn UIObject + Send>, offset: Point::zero() };
+
+        let mut touch = make_touch(vec![target], Point::new(50., 0.));
+        let mut all = vec![];
+
+        std::thread::sleep(Duration::from_millis(2));
+        all.append(&mut touch.moved(Point::new(50., 30.), Instant::now()));
+        std::thread::sleep(Duration::from_millis(25));
+        all.append(&mut touch.moved(Point::new(50., 60.), Instant::now()));
+        deliver(all);
+
+        let events = log.lock().unwrap();
+        let move_ev =
+            events.iter().find(|(_, a)| matches!(a, GestureAction::DragMove { .. })).unwrap();
+        let GestureAction::DragMove { start, prev, curr } = move_ev.1 else { panic!() };
+        // prev is the position the previous delivered event carried;
+        // for the first DragMove that is the touch start
+        assert!(prev == Point::new(50., 0.));
+        assert!(curr == Point::new(50., 60.));
+        assert!(start == Point::new(50., 0.));
+    }
+
+    fn plant_active(session: &GestureSession, chain: Vec<GestureTarget>, pos: Point) {
+        let mut inner = session.inner.lock();
+        inner.gen += 1;
+        inner.active = Some(make_touch(chain, pos));
+    }
+
+    /// Drive the executor long enough for queued deliveries to reach
+    /// their targets, then snapshot the log.
+    fn drained_log(
+        ex: &ExecutorPtr,
+        log: &Arc<StdMutex<Vec<(&'static str, GestureAction)>>>,
+    ) -> Vec<(&'static str, GestureAction)> {
+        smol::block_on(async {
+            ex.run(async { smol::Timer::after(Duration::from_millis(30)).await }).await
+        });
+        log.lock().unwrap().clone()
+    }
+
+    #[test]
+    fn secondary_touch_ids_are_inert() {
+        let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+        let session = GestureSession::new(Arc::downgrade(&SceneNode::root()), ex.clone());
+
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf = Probe::leaf(
+            "scroller",
+            scroller_set(),
+            Rectangle::new(0., 0., 100., 200.),
+            log.clone(),
+        );
+        let chain =
+            vec![GestureTarget { obj: leaf as Arc<dyn UIObject + Send>, offset: Point::zero() }];
+
+        plant_active(&session, chain, Point::new(50., 0.));
+
+        // A second finger landing and moving must not disturb the
+        // primary touch or deliver anything
+        session.touch_event(TouchPhase::Started, 1, Point::new(10., 10.));
+        session.touch_event(TouchPhase::Moved, 1, Point::new(10., 150.));
+        let events = drained_log(&ex, &log);
+        assert!(events.is_empty(), "secondary touch produced {events:?}");
+
+        // The primary still recognizes its drag
+        std::thread::sleep(Duration::from_millis(2));
+        session.touch_event(TouchPhase::Moved, 0, Point::new(50., 50.));
+        let events = drained_log(&ex, &log);
+        assert!(events.iter().any(|(_, a)| matches!(a, GestureAction::DragStart { .. })));
+    }
+
+    #[test]
+    fn cancellation_tears_down_and_invalidates_timer() {
+        let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+        let session = GestureSession::new(Arc::downgrade(&SceneNode::root()), ex.clone());
+
+        let log = Arc::new(StdMutex::new(vec![]));
+        let leaf = Probe::leaf(
+            "scroller",
+            scroller_set(),
+            Rectangle::new(0., 0., 100., 200.),
+            log.clone(),
+        );
+        let chain =
+            vec![GestureTarget { obj: leaf as Arc<dyn UIObject + Send>, offset: Point::zero() }];
+
+        plant_active(&session, chain, Point::new(50., 0.));
+        let gen = session.inner.lock().gen;
+
+        session.touch_event(TouchPhase::Cancelled, 0, Point::new(50., 5.));
+
+        // The Up passthrough is delivered so widgets can tear down
+        // their armed state, but no recognized gesture fires.
+        let events = drained_log(&ex, &log);
+        assert_eq!(events.len(), 1, "cancel must deliver only the Up passthrough");
+        assert!(matches!(events[0], ("scroller", GestureAction::Up { .. })));
+
+        // A stale long-press timer firing after cancellation must be
+        // ignored: no further events for the cancelled touch.
+        session.fire_long_press(gen);
+        let events = drained_log(&ex, &log);
+        assert_eq!(events.len(), 1, "stale timer emitted {events:?}");
+    }
+
+    /// End-to-end: a real `Layer` wrapping a real `Button`, driven
+    /// through the session. Pins chain resolution through a nested
+    /// layer with coordinate translation and tap delivery.
+    #[test]
+    fn tap_through_real_layer_and_button() {
+        smol::block_on(async {
+            let ex: ExecutorPtr = Arc::new(smol::Executor::new());
+
+            let (redraw_tx, _redraw_rx) = RedrawTrigger::new();
+            let (method_tx, _method_rx) = async_channel::unbounded();
+            let renderer = Renderer::new(method_tx);
+
+            let root = SceneNode::root();
+
+            let layer_node = crate::app::node::create_layer("layer");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                layer_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+                for (i, v) in [100., 50., 400., 300.].into_iter().enumerate() {
+                    layer_node
+                        .get_property("rect")
+                        .unwrap()
+                        .set_f32(atom, Role::App, i, v)
+                        .unwrap();
+                }
+            }
+            let layer_node =
+                layer_node.setup(|me| Layer::new(me, renderer.clone(), redraw_tx.clone())).await;
+            root.link(layer_node.clone());
+
+            let btn_node = crate::app::node::create_button("btn");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                btn_node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+                for (i, v) in [0., 0., 100., 50.].into_iter().enumerate() {
+                    btn_node.get_property("rect").unwrap().set_f32(atom, Role::App, i, v).unwrap();
+                }
+            }
+            let btn_node =
+                btn_node.setup(|me| Button::new(me, renderer.clone(), redraw_tx.clone())).await;
+            layer_node.link(btn_node.clone());
+
+            let (slot, click_rx) = Slot::new("test_click");
+            btn_node.register("click", slot).unwrap();
+
+            let session = GestureSession::new(Arc::downgrade(&root), ex.clone());
+
+            // Window-space (150, 80) == layer-local (50, 30) == inside
+            // the button rect
+            session.touch_event(TouchPhase::Started, 0, Point::new(150., 80.));
+            session.touch_event(TouchPhase::Ended, 0, Point::new(150., 80.));
+
+            let clicked = smol::block_on(async {
+                ex.run(async {
+                    // Wait for the async delivery chain to fire the signal
+                    click_rx.recv().await.is_ok()
+                })
+                .await
+            });
+            assert!(clicked, "emulated tap through nested layer did not click the button");
+
+            // And the button was the resolved target: a tap outside its
+            // rect (but inside the layer) must not click
+            let (slot2, click_rx2) = Slot::new("test_click2");
+            btn_node.register("click", slot2).unwrap();
+            session.touch_event(TouchPhase::Started, 0, Point::new(350., 80.));
+            session.touch_event(TouchPhase::Ended, 0, Point::new(350., 80.));
+
+            smol::block_on(async {
+                ex.run(async { smol::Timer::after(Duration::from_millis(30)).await }).await
+            });
+            assert!(click_rx2.try_recv().is_err(), "tap outside the button must not click");
+        });
+    }
+}

+ 29 - 9
bin/app/src/ui/layer.rs

@@ -17,7 +17,7 @@
  */
 
 use async_trait::async_trait;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::sync::Arc;
@@ -34,8 +34,8 @@ use crate::{
 };
 
 use super::{
-    get_children_ordered, get_ui_object3, get_ui_object_ptr, DrawUpdate, OnModify, RedrawTrigger,
-    UIObject,
+    gesture, get_children_ordered, get_ui_object3, get_ui_object_ptr, DrawUpdate, GestureTarget,
+    OnModify, RedrawTrigger, UIObject,
 };
 
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui:layer", $($arg)*); } }
@@ -283,31 +283,51 @@ impl UIObject for Layer {
         }
         false
     }
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, mut touch_pos: Point) -> bool {
+    fn gesture_hit_test(&self, pos: Point) -> bool {
         if !self.is_visible.get() {
             return false
         }
-        touch_pos -= self.rect.get().pos();
+
+        let local = pos - self.rect.get().pos();
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
-            if obj.handle_touch(phase, id, touch_pos).await {
+            if obj.gesture_hit_test(local) {
                 return true
             }
         }
+
         false
     }
 
-    fn handle_touch_sync(&self, phase: TouchPhase, id: u64, mut touch_pos: Point) -> bool {
+    fn gesture_descend(&self, pos: Point, offset: Point, chain: &mut Vec<GestureTarget>) {
+        if !self.is_visible.get() {
+            return
+        }
+
+        let rect_pos = self.rect.get().pos();
+        let local = pos - rect_pos;
+        let children: Vec<_> =
+            self.get_children().iter().map(|child| get_ui_object_ptr(child)).collect();
+        gesture::scan_children(&children, local, offset + rect_pos, chain);
+    }
+
+    async fn handle_gesture(&self, gesture: gesture::GestureAction) -> bool {
         if !self.is_visible.get() {
             return false
         }
-        touch_pos -= self.rect.get().pos();
+
+        let mut gesture = gesture;
+        let rect_pos = self.rect.get().pos();
+        gesture.translate(crate::gfx::Vector { x: -rect_pos.x, y: -rect_pos.y });
+
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
-            if obj.handle_touch_sync(phase, id, touch_pos) {
+            if obj.handle_gesture(gesture.clone()).await {
+                t!("handle_gesture swallowed by {child:?}");
                 return true
             }
         }
+
         false
     }
 

+ 81 - 175
bin/app/src/ui/menu/mod.rs

@@ -21,11 +21,11 @@ use async_trait::async_trait;
 use atomic_float::AtomicF32;
 use darkfi::system::CondVar;
 use darkfi_serial::{serialize, Decodable};
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::{
-    collections::{HashMap, HashSet, VecDeque},
+    collections::HashSet,
     io::Read,
     sync::{
         atomic::{AtomicBool, Ordering},
@@ -44,7 +44,7 @@ use crate::{
     text, ExecutorPtr,
 };
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 mod shape;
 
@@ -61,16 +61,6 @@ const MENU_ICON_OFFSET: f32 = 24.;
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::menu", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::menu", $($arg)*); } }
 
-#[derive(Clone)]
-struct TouchInfo {
-    start_scroll: f32,
-    start_pos: Point,
-    start_instant: std::time::Instant,
-    samples: VecDeque<(std::time::Instant, f32)>,
-    last_instant: std::time::Instant,
-    last_pos: Point,
-}
-
 #[derive(Clone)]
 struct MouseClickInfo {
     start_pos: Point,
@@ -83,34 +73,6 @@ struct DragInfo {
     insert_idx: usize,
 }
 
-impl TouchInfo {
-    fn new(start_scroll: f32, pos: Point) -> Self {
-        Self {
-            start_scroll,
-            start_pos: pos,
-            start_instant: std::time::Instant::now(),
-            samples: VecDeque::from([(std::time::Instant::now(), pos.y)]),
-            last_instant: std::time::Instant::now(),
-            last_pos: pos,
-        }
-    }
-
-    fn push_sample(&mut self, y: f32) {
-        self.samples.push_back((std::time::Instant::now(), y));
-
-        while let Some((instant, _)) = self.samples.front() {
-            if instant.elapsed().as_micros() <= 40_000 {
-                break
-            }
-            self.samples.pop_front();
-        }
-    }
-
-    fn first_sample(&self) -> Option<(f32, f32)> {
-        self.samples.front().map(|(t, s)| (t.elapsed().as_micros() as f32 / 1000., *s))
-    }
-}
-
 pub type MenuPtr = Arc<Menu>;
 
 pub struct Menu {
@@ -147,10 +109,12 @@ pub struct Menu {
     window_scale: PropertyFloat32,
 
     mouse_pos: SyncMutex<Point>,
-    touch_info: SyncMutex<Option<TouchInfo>>,
     mouse_click_info: SyncMutex<Option<MouseClickInfo>>,
     drag_info: SyncMutex<Option<DragInfo>>,
     long_press_task: SyncMutex<Option<smol::Task<()>>>,
+    /// Active 1:1 scroll drag: (finger y at drag start, scroll at drag
+    /// start), parent space.
+    drag_state: SyncMutex<Option<(f32, f32)>>,
     scroll_start_accel: PropertyFloat32,
     scroll_resist: PropertyFloat32,
     overscroll: PropertyFloat32,
@@ -239,10 +203,10 @@ impl Menu {
             fade_zone,
             window_scale,
             mouse_pos: SyncMutex::new(Point::new(0., 0.)),
-            touch_info: SyncMutex::new(None),
             mouse_click_info: SyncMutex::new(None),
             drag_info: SyncMutex::new(None),
             long_press_task: SyncMutex::new(None),
+            drag_state: SyncMutex::new(None),
             scroll_start_accel,
             scroll_resist,
             overscroll,
@@ -617,18 +581,6 @@ impl Menu {
         }
     }
 
-    fn end_touch_phase(&self, touch_y: f32) {
-        let touch_info = std::mem::take(&mut *self.touch_info.lock());
-        let info = touch_info.unwrap();
-
-        if let Some((dt, _)) = info.first_sample() {
-            if dt > EPSILON {
-                let velocity = (touch_y - info.start_pos.y) / dt;
-                self.start_scroll(-velocity);
-            }
-        }
-    }
-
     /// Cancels edit mode changes, reverting any modifications made during edit mode
     async fn process_cancel_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
         let Ok(method_call) = sub.receive().await else {
@@ -1018,30 +970,29 @@ impl UIObject for Menu {
         false
     }
 
-    fn handle_touch_sync(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        if id != 0 {
-            return false
-        }
-
-        match phase {
-            TouchPhase::Started => {
-                let rect = self.rect.get();
-                if !rect.contains(touch_pos) {
-                    *self.touch_info.lock() = None;
-                    return false
-                }
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::MENU
+    }
 
-                let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed);
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.rect.get().contains(pos)
+    }
 
-                if is_edit_mode {
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        match gesture {
+            GestureAction::Down { pos } => {
+                // Arm the item-reorder grab: touching a reorder handle
+                // is a zero-threshold action, not a recognized gesture.
+                if self.is_edit_mode.load(Ordering::Relaxed) {
+                    let rect = self.rect.get();
                     let font_size = self.font_size.get();
                     let hammy_half_size = font_size * 2.0;
                     let hammy_center = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
                     let hammy_min = hammy_center - hammy_half_size;
                     let hammy_max = hammy_center + hammy_half_size;
 
-                    if touch_pos.x >= hammy_min && touch_pos.x <= hammy_max {
-                        if let Some(item_idx) = self.get_selected_item_index(touch_pos.y) {
+                    if pos.x >= hammy_min && pos.x <= hammy_max {
+                        if let Some(item_idx) = self.get_selected_item_index(pos.y) {
                             *self.drag_info.lock() =
                                 Some(DragInfo { item_idx, insert_idx: item_idx });
                             info!(target: "app::menu", "Dragging item: {}", item_idx);
@@ -1049,111 +1000,59 @@ impl UIObject for Menu {
                     }
                 }
 
-                *self.touch_info.lock() =
-                    Some(TouchInfo::new(self.scroll.load(Ordering::Relaxed), touch_pos));
-
-                // Spawn a task to detect long press while the touch is
-                // still held, mirroring the mouse path.
-                let me = self.me.clone();
-                let start_pos = touch_pos;
-                let ex = self.ex.clone();
-                let long_press_task = ex.spawn(async move {
-                    darkfi::system::msleep(long_press_timeout() as u64).await;
-
-                    let Some(arc_self) = me.upgrade() else { return };
-
-                    let touch_info = arc_self.touch_info.lock().clone();
-                    let Some(info) = touch_info else { return };
-
-                    let movement_dist = ((info.last_pos.x - start_pos.x).powi(2) +
-                        (info.last_pos.y - start_pos.y).powi(2))
-                    .sqrt();
-
-                    if movement_dist < LONG_PRESS_EPSILON &&
-                        !arc_self.is_edit_mode.load(Ordering::Relaxed)
-                    {
-                        arc_self.save_items_layout();
-                        arc_self.is_edit_mode.store(true, Ordering::Release);
-                        let node = arc_self.node.upgrade().unwrap();
-                        node.trigger("edit_active", vec![]).await.unwrap();
-                        arc_self.invalidate_draw();
-                        arc_self.redraw.trigger();
-                    }
-                });
-
-                *self.long_press_task.lock() = Some(long_press_task);
-
                 true
             }
-
-            TouchPhase::Moved => {
-                let mut should_redraw = false;
-
+            GestureAction::DragStart { start } => {
+                *self.drag_state.lock() = Some((start.y, self.scroll.load(Ordering::Relaxed)));
+                true
+            }
+            GestureAction::DragMove { curr, .. } => {
+                // An armed reorder takes precedence over scrolling
                 if self.drag_info.lock().is_some() {
-                    if let Some(insert_idx) = self.get_selected_item_index(touch_pos.y) {
-                        let mut drag = self.drag_info.lock();
-                        if let Some(d) = drag.as_mut() {
-                            if d.insert_idx != insert_idx {
-                                d.insert_idx = insert_idx;
-                                info!(target: "app::menu", "insert_idx changed to: {}", insert_idx);
-                                should_redraw = true;
+                    if let Some(insert_idx) = self.get_selected_item_index(curr.y) {
+                        let should_redraw = {
+                            let mut drag = self.drag_info.lock();
+                            match drag.as_mut() {
+                                Some(d) if d.insert_idx != insert_idx => {
+                                    d.insert_idx = insert_idx;
+                                    info!(target: "app::menu", "insert_idx changed to: {}", insert_idx);
+                                    true
+                                }
+                                _ => false,
                             }
+                        };
+
+                        if should_redraw {
+                            self.invalidate_draw();
+                            self.redraw.trigger();
                         }
                     }
-                }
 
-                if should_redraw {
-                    self.invalidate_draw();
-                    self.redraw.trigger();
+                    return true
                 }
 
                 let scroll = {
-                    let mut touch_info = self.touch_info.lock();
-                    let Some(info) = &mut *touch_info else { return false };
-
-                    info.last_pos = touch_pos;
-                    info.push_sample(touch_pos.y);
-
-                    let last_elapsed = info.last_instant.elapsed().as_millis();
-                    if last_elapsed <= 20 {
-                        return true
-                    }
-                    info.last_instant = std::time::Instant::now();
-
-                    let dist = touch_pos.y - info.start_pos.y;
-                    if dist.abs() < BIG_EPSILON {
-                        return true
-                    }
-
-                    info.start_scroll - dist
+                    let drag_state = self.drag_state.lock();
+                    let Some((start_y, start_scroll)) = *drag_state else { return false };
+                    start_scroll + start_y - curr.y
                 };
 
                 self.scrollview(scroll);
                 self.redraw.trigger();
                 true
             }
-
-            // Use async handler instead
-            TouchPhase::Ended | TouchPhase::Cancelled => false,
-        }
-    }
-
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        if id != 0 {
-            return false
-        }
-
-        match phase {
-            // Should be handled by handle_touch_sync
-            TouchPhase::Started | TouchPhase::Moved => false,
-
-            TouchPhase::Ended | TouchPhase::Cancelled => {
-                // Cancel the long press detection task
-                let task = self.long_press_task.lock().take();
-                if let Some(task) = task {
-                    task.cancel().await;
-                }
-
+            GestureAction::DragEnd { vel, .. } => {
+                *self.drag_state.lock() = None;
+
+                // Flick inertia from the release velocity, reproducing
+                // the old dist-over-sample-window formula (px/ms).
+                let accel = self.scroll_start_accel.get() * -vel.y / 1000.;
+                self.speed.store(accel, Ordering::Relaxed);
+                self.motion_cv.notify();
+                true
+            }
+            GestureAction::Up { .. } => {
+                // Commit an armed reorder at touch end
                 let drag = self.drag_info.lock().take();
                 if let Some(drag_info) = drag {
                     if drag_info.item_idx != drag_info.insert_idx {
@@ -1167,22 +1066,29 @@ impl UIObject for Menu {
                     return true
                 }
 
-                let (is_tap, is_long_press_tap, elapsed) = {
-                    let touch_info = self.touch_info.lock();
-                    let Some(info) = &*touch_info else { return true };
-
-                    let is_tap = (touch_pos.y - info.start_pos.y).abs() < BIG_EPSILON;
-                    let movement_dist = ((touch_pos.x - info.start_pos.x).powi(2) +
-                        (touch_pos.y - info.start_pos.y).powi(2))
-                    .sqrt();
-                    let is_long_press_tap = movement_dist < LONG_PRESS_EPSILON;
-                    let elapsed = info.start_instant.elapsed().as_millis();
-                    (is_tap, is_long_press_tap, elapsed)
-                };
-
-                self.handle_interaction(touch_pos, is_tap, is_long_press_tap, elapsed).await;
+                false
+            }
+            GestureAction::LongPress { .. } => {
+                // Enter edit mode while the finger is still down; the
+                // recognizer fires once per touch by construction.
+                if !self.is_edit_mode.load(Ordering::Relaxed) {
+                    self.save_items_layout();
+                    self.is_edit_mode.store(true, Ordering::Release);
+                    let node = self.node.upgrade().unwrap();
+                    node.trigger("edit_active", vec![]).await.unwrap();
+                    self.invalidate_draw();
+                    self.redraw.trigger();
+                }
+                true
+            }
+            GestureAction::Tap { pos } => {
+                // A stationary grab on the reorder handle is a no-op,
+                // not a selection (the old armed path suppressed it)
+                if self.drag_info.lock().is_some() {
+                    return true
+                }
 
-                self.end_touch_phase(touch_pos.y);
+                self.handle_interaction(pos, true, false, 0).await;
                 true
             }
         }

+ 25 - 10
bin/app/src/ui/mod.rs

@@ -18,7 +18,7 @@
 
 use async_trait::async_trait;
 use futures::stream::{FuturesUnordered, StreamExt};
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use std::sync::{Arc, OnceLock, Weak};
 
 use crate::{
@@ -57,8 +57,15 @@ mod edit;
 pub use edit::{BaseEdit, BaseEditPtr, BaseEditType};
 pub mod emoji_picker;
 pub use emoji_picker::{EmojiPicker, EmojiPickerPtr};
-mod gesture;
-pub use gesture::GesturePtr;
+pub mod gesture;
+// The full config vocabulary is re-exported for widgets adopting
+// per-node recognizer configs (TapCfg/DragCfg axes + direction); the
+// crate is a binary so not every name has an in-crate use yet.
+#[allow(unused_imports)]
+pub use gesture::{
+    Axes, Direction, DragCfg, GestureAction, GestureConstants, GestureSession, GestureSessionPtr,
+    GestureSet, GestureTarget, LongPressCfg, TapCfg,
+};
 mod image;
 #[allow(unused_imports)]
 pub use image::{Image, ImagePtr};
@@ -83,7 +90,7 @@ pub use text::{Text, TextPtr};
 mod text_scramble;
 pub use text_scramble::{TextScramble, TextScramblePtr};
 mod win;
-pub use win::{GestureAction, Window, WindowPtr};
+pub use win::{Window, WindowPtr};
 
 macro_rules! e { ($($arg:tt)*) => { error!(target: "scene::on_modify", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "scene::on_modify", $($arg)*); } }
@@ -186,14 +193,24 @@ pub trait UIObject: Sync {
     async fn handle_mouse_wheel(&self, _wheel_pos: Point) -> bool {
         false
     }
-    async fn handle_touch(&self, _phase: TouchPhase, _id: u64, _touch_pos: Point) -> bool {
-        false
+    /// The gestures this widget accepts. Non-participating widgets
+    /// return [`GestureSet::NONE`] and are inert.
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::NONE
     }
-    async fn handle_gesture(&self, _gesture: GestureAction) -> bool {
+
+    /// Whether this widget is a gesture target at `pos` (given in the
+    /// widget's parent coordinate space, like `handle_gesture`).
+    fn gesture_hit_test(&self, _pos: Point) -> bool {
         false
     }
 
-    fn handle_touch_sync(&self, _phase: TouchPhase, _id: u64, _touch_pos: Point) -> bool {
+    /// Containers: descend the gesture chain under `pos` (the
+    /// container's parent space), translating coordinates. The default
+    /// is a no-op for leaf widgets.
+    fn gesture_descend(&self, _pos: Point, _offset: Point, _chain: &mut Vec<GestureTarget>) {}
+
+    async fn handle_gesture(&self, _gesture: GestureAction) -> bool {
         false
     }
 
@@ -327,7 +344,6 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::Button(obj) => obj.clone(),
         Pimpl::EmojiPicker(obj) => obj.clone(),
         Pimpl::Shortcut(obj) => obj.clone(),
-        Pimpl::Gesture(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::TokenTable(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
@@ -347,7 +363,6 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::Button(obj) => obj.as_ref(),
         Pimpl::EmojiPicker(obj) => obj.as_ref(),
         Pimpl::Shortcut(obj) => obj.as_ref(),
-        Pimpl::Gesture(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::TokenTable(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),

+ 10 - 6
bin/app/src/ui/scroll_layer.rs

@@ -17,7 +17,7 @@
  */
 
 use async_trait::async_trait;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use std::sync::Arc;
 
 use crate::{
@@ -28,7 +28,7 @@ use crate::{
     ExecutorPtr,
 };
 
-use super::{DrawUpdate, Layer, LayerPtr, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureTarget, Layer, LayerPtr, RedrawTrigger, UIObject};
 
 pub type ScrollLayerPtr = Arc<ScrollLayer>;
 
@@ -106,12 +106,16 @@ impl UIObject for ScrollLayer {
         self.inner.handle_mouse_wheel(wheel_pos).await
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        self.inner.handle_touch(phase, id, touch_pos).await
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.inner.gesture_hit_test(pos)
     }
 
-    fn handle_touch_sync(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        self.inner.handle_touch_sync(phase, id, touch_pos)
+    fn gesture_descend(&self, pos: Point, offset: Point, chain: &mut Vec<GestureTarget>) {
+        self.inner.gesture_descend(pos, offset, chain)
+    }
+
+    async fn handle_gesture(&self, gesture: crate::ui::gesture::GestureAction) -> bool {
+        self.inner.handle_gesture(gesture).await
     }
 
     fn set_i18n(&self, i18n_fish: &I18nBabelFish) {

+ 94 - 26
bin/app/src/ui/tokentable/mod.rs

@@ -19,7 +19,7 @@
 use async_trait::async_trait;
 use darkfi_money_contract::model::{TokenId, DARK_TOKEN_ID};
 use darkfi_serial::{Decodable, Encodable, SerialEncodable};
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use std::sync::{Arc, Weak};
@@ -36,7 +36,7 @@ use crate::{
     ExecutorPtr,
 };
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::tokentable", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::tokentable", $($arg)*); } }
@@ -211,6 +211,18 @@ impl TokenTable {
         }
     }
 
+    /// Emit the `row_click` signal for a tapped row.
+    async fn trigger_row_click(&self, row: TokenRow) {
+        let mut data = vec![];
+        if let Err(e) = row.encode(&mut data) {
+            error!(target: "ui::tokentable", "Failed to encode row: {e}");
+            return
+        }
+
+        let node_ref = self.node.upgrade().unwrap();
+        let _ = node_ref.trigger("row_click", data).await;
+    }
+
     fn get_meshes(&self, rect: &Rectangle) -> Vec<DrawInstruction> {
         let rows = self.rows.lock();
         let font_size = self.font_size.get();
@@ -405,36 +417,31 @@ impl UIObject for TokenTable {
             return false
         }
 
-        let mut data = vec![];
-        if let Err(e) = row.encode(&mut data) {
-            error!(target: "ui::tokentable", "Failed to encode row: {e}");
-            return false
-        }
-
-        let node_ref = self.node.upgrade().unwrap();
-        let _ = node_ref.trigger("row_click", data).await;
+        self.trigger_row_click(row).await;
 
         true
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
-        // Ignore multi-touch
-        if id != 0 {
-            return false
-        }
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::TAP
+    }
 
-        let rect = self.rect.get();
-        if !rect.contains(touch_pos) {
-            return false
-        }
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        // Only the rows are tappable. The table's rect spans the rest
+        // of the screen below it (it sizes to the layer), so a rect-only
+        // hit-test would own touches meant for widgets underneath —
+        // the old dispatch fell through to them when no row matched.
+        self.rect.get().contains(pos) && self.get_row_at_y(pos.y).is_some()
+    }
 
-        // Simulate mouse events
-        match phase {
-            TouchPhase::Started => self.handle_mouse_btn_down(MouseButton::Left, touch_pos).await,
-            TouchPhase::Moved => false,
-            TouchPhase::Ended => self.handle_mouse_btn_up(MouseButton::Left, touch_pos).await,
-            TouchPhase::Cancelled => false,
-        }
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        let GestureAction::Tap { pos } = gesture else { return false };
+
+        let Some(row) = self.get_row_at_y(pos.y) else { return false };
+
+        self.trigger_row_click(row).await;
+
+        true
     }
 }
 
@@ -449,3 +456,64 @@ impl std::fmt::Debug for TokenTable {
         write!(f, "{:?}", self.node.upgrade().unwrap())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::{
+        app::node::create_tokentable,
+        gfx::Renderer,
+        prop::{PropertyAtomicGuard, Role},
+        scene::SceneNode,
+        ui::RedrawTrigger,
+    };
+
+    /// The table's gesture hit-test region is its rows, not its whole
+    /// rect: the rect spans the remainder of the layer (widgets like
+    /// the wallet chat button live underneath it), and chain
+    /// resolution has no per-event sibling fallthrough.
+    #[test]
+    fn gesture_hit_test_only_passes_on_rows() {
+        smol::block_on(async {
+            let (redraw_tx, _redraw_rx) = RedrawTrigger::new();
+            let (method_tx, _method_rx) = async_channel::unbounded();
+            let renderer = Renderer::new(method_tx);
+
+            let node = create_tokentable("tokens_table");
+            {
+                let atom = &mut PropertyAtomicGuard::none();
+                let rect = node.get_property("rect").unwrap();
+                rect.set_f32(atom, Role::App, 0, 0.).unwrap();
+                rect.set_f32(atom, Role::App, 1, 100.).unwrap();
+                rect.set_f32(atom, Role::App, 2, 600.).unwrap();
+                rect.set_f32(atom, Role::App, 3, 1000.).unwrap();
+                node.set_property_f32(atom, Role::App, "font_size", 18.).unwrap();
+                node.set_property_f32(atom, Role::App, "padding_x", 8.).unwrap();
+                node.set_property_f32(atom, Role::App, "padding_y", 8.).unwrap();
+            }
+
+            let node = node.setup(|me| TokenTable::new(me, renderer, redraw_tx)).await;
+            let obj = node.pimpl();
+            let Pimpl::TokenTable(table) = obj else { panic!() };
+
+            // No rows yet: nothing passes, even inside the rect
+            assert!(!table.gesture_hit_test(Point::new(50., 110.)));
+
+            table.set_tokens(vec![TokenRow {
+                id: *DARK_TOKEN_ID,
+                symbol: "DRK".to_string(),
+                balance: "0".to_string(),
+            }]);
+
+            // Row height = padding_y * 2 + font_size + 1 = 35
+            // Inside row 0
+            assert!(table.gesture_hit_test(Point::new(50., 110.)));
+            // Inside the rect but below every row (where the chat
+            // button lives)
+            assert!(!table.gesture_hit_test(Point::new(50., 900.)));
+            // Outside the rect entirely
+            assert!(!table.gesture_hit_test(Point::new(50., 50.)));
+        });
+    }
+}

+ 0 - 205
bin/app/src/ui/win/gesture.rs

@@ -1,205 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2026 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use miniquad::TouchPhase;
-use std::{
-    collections::{HashMap, VecDeque},
-    time::Instant,
-};
-
-use crate::gfx::{Point, Segment, Vector};
-
-/// Gesture recognition thresholds
-const TAP_MAX_MOVEMENT: f32 = 10.0;
-const TAP_MAX_DURATION: f32 = 300.;
-const DRAG_MIN_MOVEMENT: f32 = 15.0;
-const FLICK_MIN_VELOCITY: f32 = 500.0;
-const LONG_PRESS_MIN_DURATION: f32 = 500.;
-const LONG_PRESS_MAX_MOVEMENT: f32 = 20.0;
-
-/// Types of gestures that can be recognized
-#[derive(Debug, Clone, Copy)]
-pub enum GestureAction {
-    /// A quick tap without significant movement
-    Tap(Point),
-    /// Continuous drag gesture
-    Drag(Segment),
-    /// Quick flick with velocity
-    Flick { start: Point, vel: Vector },
-    /// Long press without movement
-    LongPress(Point),
-}
-
-/// Internal state tracking for an active touch point
-struct TouchState {
-    start_pos: Point,
-    start_time: Instant,
-    curr_pos: Point,
-    is_dragging: bool,
-    long_press_emitted: bool,
-    /// Used for flick scrolling - stores (time, position) samples
-    samples: VecDeque<(Instant, Point)>,
-}
-
-impl TouchState {
-    fn push_sample(&mut self, pos: Point) {
-        self.samples.push_back((Instant::now(), pos));
-
-        // Drop all old samples older than 40ms
-        while let Some((instant, _)) = self.samples.front() {
-            if instant.elapsed().as_micros() <= 40_000 {
-                break;
-            }
-            self.samples.pop_front();
-        }
-    }
-
-    fn first_sample(&self) -> Option<(f32, Point)> {
-        self.samples.front().map(|(t, p)| (t.elapsed().as_micros() as f32 / 1000., *p))
-    }
-}
-
-/// Main gesture processor maintaining state for all touch points
-pub struct GestureProcessor {
-    touches: HashMap<u64, TouchState>,
-}
-
-impl GestureProcessor {
-    /// Create a new gesture processor with default thresholds
-    pub fn new() -> Self {
-        Self { touches: Default::default() }
-    }
-
-    pub fn process(&mut self, phase: TouchPhase, id: u64, pos: Point) -> Option<GestureAction> {
-        match phase {
-            TouchPhase::Started => self.handle_touch_start(id, pos),
-            TouchPhase::Moved => self.handle_touch_move(id, pos),
-            TouchPhase::Ended => self.handle_touch_end(id, pos),
-            TouchPhase::Cancelled => self.handle_touch_cancel(id),
-        }
-    }
-
-    fn handle_touch_start(&mut self, id: u64, pos: Point) -> Option<GestureAction> {
-        let state = TouchState {
-            start_pos: pos,
-            start_time: Instant::now(),
-            curr_pos: pos,
-            is_dragging: false,
-            long_press_emitted: false,
-            samples: VecDeque::new(),
-        };
-        self.touches.insert(id, state);
-        None
-    }
-
-    fn check_long_press(state: &mut TouchState, pos: Point) -> Option<GestureAction> {
-        if state.long_press_emitted {
-            return None;
-        }
-        // Once drag starts no long press can be emitted
-        if state.is_dragging {
-            return None;
-        }
-
-        let dur = state.start_time.elapsed().as_millis() as f32;
-        if dur >= LONG_PRESS_MIN_DURATION && pos.dist(state.start_pos) <= LONG_PRESS_MAX_MOVEMENT {
-            state.long_press_emitted = true;
-            return Some(GestureAction::LongPress(pos))
-        }
-
-        None
-    }
-
-    fn handle_touch_move(&mut self, id: u64, pos: Point) -> Option<GestureAction> {
-        let Some(state) = self.touches.get_mut(&id) else { return None };
-
-        if let Some(gesture) = Self::check_long_press(state, pos) {
-            return Some(gesture);
-        }
-
-        state.curr_pos = pos;
-
-        // Collect sample for flick detection
-        state.push_sample(pos);
-
-        let dist = pos.dist(state.start_pos);
-        if dist >= DRAG_MIN_MOVEMENT {
-            state.is_dragging = true;
-        }
-
-        if state.is_dragging {
-            return Some(GestureAction::Drag(Segment { start: state.start_pos, end: pos }))
-        }
-
-        None
-    }
-
-    fn handle_touch_end(&mut self, id: u64, pos: Point) -> Option<GestureAction> {
-        let mut state = self.touches.remove(&id)?;
-
-        // Update current position one last time
-        state.curr_pos = pos;
-        state.push_sample(pos);
-
-        // Calculate velocity from samples
-        if let Some((dt_ms, start_pos)) = state.first_sample() {
-            let dt_sec = dt_ms / 1000.0;
-
-            if dt_sec > 0.001 {
-                // Calculate velocity using Point operations, convert to Vector
-                let vel: Vector = ((pos - start_pos) / dt_sec).into();
-
-                // Check for flick (high velocity movement)
-                if vel.mag() >= FLICK_MIN_VELOCITY && !state.long_press_emitted {
-                    return Some(GestureAction::Flick { start: state.start_pos, vel });
-                }
-            }
-        }
-
-        // Then check for drag
-        if state.is_dragging {
-            return Some(GestureAction::Drag(Segment { start: state.start_pos, end: pos }))
-        }
-
-        // Then check for long press
-        if let Some(gesture) = Self::check_long_press(&mut state, pos) {
-            return Some(gesture);
-        }
-
-        // Finally check for tap
-        let dur = state.start_time.elapsed().as_millis() as f32;
-        let dist = pos.dist(state.start_pos);
-
-        if dist <= TAP_MAX_MOVEMENT && dur <= TAP_MAX_DURATION {
-            Some(GestureAction::Tap(pos))
-        } else {
-            None
-        }
-    }
-
-    fn handle_touch_cancel(&mut self, id: u64) -> Option<GestureAction> {
-        self.touches.remove(&id);
-        None
-    }
-}
-
-impl Default for GestureProcessor {
-    fn default() -> Self {
-        Self::new()
-    }
-}

+ 47 - 92
bin/app/src/ui/win/mod.rs

@@ -26,8 +26,8 @@ use crate::{
     gfx::{
         gfxtag, DrawCall, DrawInstruction, GraphicsEventCharSub, GraphicsEventKeyDownSub,
         GraphicsEventKeyUpSub, GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub,
-        GraphicsEventMouseMoveSub, GraphicsEventMouseWheelSub, GraphicsEventPublisherPtr,
-        GraphicsEventTouchSub, Point, Rectangle, RenderApi, Renderer,
+        GraphicsEventMouseMoveSub, GraphicsEventMouseWheelSub, GraphicsEventPublisherPtr, Point,
+        Rectangle, RenderApi, Renderer,
     },
     prop::{
         BatchGuardPtr, PropertyAtomicGuard, PropertyDimension, PropertyFloat32, PropertyStr, Role,
@@ -40,10 +40,10 @@ use crate::{
 #[cfg(target_os = "android")]
 use crate::{android, prop::PropertyRect};
 
-use super::{get_children_ordered, get_ui_object3, get_ui_object_ptr, OnModify, RedrawTrigger};
-
-mod gesture;
-pub use gesture::{GestureAction, GestureProcessor};
+use super::{
+    get_children_ordered, get_ui_object3, get_ui_object_ptr, GestureSession, GestureSessionPtr,
+    OnModify, RedrawTrigger,
+};
 
 macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::window", $($arg)*); } }
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::window", $($arg)*); } }
@@ -68,8 +68,8 @@ pub struct Window {
     scale: PropertyFloat32,
     #[cfg(target_os = "android")]
     insets: PropertyRect,
-    /// Gesture processor for recognizing gestures
-    gesture_proc: SyncMutex<GestureProcessor>,
+    /// Window-level gesture recognition and delivery
+    gesture_session: GestureSessionPtr,
     /// Sender side used by window-internal triggers to request a draw pass.
     redraw_tx: RedrawTrigger,
     /// Receiver consumed by the single draw-pass listener task in `start()`.
@@ -81,6 +81,7 @@ impl Window {
         node: SceneNodeWeak,
         renderer: Renderer,
         i18n_fish: I18nBabelFish,
+        ex: ExecutorPtr,
         redraw_tx: RedrawTrigger,
         redraw_rx: async_channel::Receiver<()>,
     ) -> Pimpl {
@@ -89,6 +90,8 @@ impl Window {
         let screen_size = PropertyDimension::wrap(node_ref, Role::Internal, "screen_size").unwrap();
         let scale = PropertyFloat32::wrap(node_ref, Role::Internal, "scale", 0).unwrap();
 
+        let gesture_session = GestureSession::new(node.clone(), ex);
+
         let self_ = Arc::new(Self {
             node,
             renderer,
@@ -100,7 +103,7 @@ impl Window {
             scale,
             #[cfg(target_os = "android")]
             insets: PropertyRect::wrap(node_ref, Role::Internal, "insets").unwrap(),
-            gesture_proc: SyncMutex::new(GestureProcessor::new()),
+            gesture_session,
             redraw_tx,
             redraw_rx,
         });
@@ -214,10 +217,6 @@ impl Window {
         let mouse_wheel_task =
             ex.spawn(async move { while Self::process_mouse_wheel(&me2, &ev_sub).await {} });
 
-        let ev_sub = event_pub.subscribe_touch();
-        let me2 = me.clone();
-        let touch_task = ex.spawn(async move { while Self::process_touch(&me2, &ev_sub).await {} });
-
         #[cfg(target_os = "android")]
         let insets_task = {
             let (insets_tx, insets_rx) = async_channel::unbounded();
@@ -264,7 +263,6 @@ impl Window {
             mouse_btn_up_task,
             mouse_move_task,
             mouse_wheel_task,
-            touch_task,
         ];
         tasks.append(&mut on_modify.tasks);
         #[cfg(target_os = "android")]
@@ -399,21 +397,6 @@ impl Window {
         true
     }
 
-    async fn process_touch(me: &Weak<Self>, ev_sub: &GraphicsEventTouchSub) -> bool {
-        let Ok((phase, id, touch_pos)) = ev_sub.recv().await else {
-            t!("Event relayer closed");
-            return false
-        };
-
-        let Some(self_) = me.upgrade() else {
-            // Should not happen
-            panic!("self destroyed before touch_task was stopped!");
-        };
-
-        self_.handle_touch(phase, id, touch_pos).await;
-        true
-    }
-
     fn get_children(&self) -> Vec<SceneNodePtr> {
         let node = self.node.upgrade().unwrap();
         get_children_ordered(&node)
@@ -453,14 +436,18 @@ impl Window {
     }
 
     async fn handle_mouse_btn_down(&self, btn: MouseButton, mut mouse_pos: Point) {
+        if EMULATE_TOUCH {
+            // Mouse-emulated touches produce real gestures through the
+            // session, exactly like device touches. feed_gesture()
+            // applies the window scale itself.
+            self.feed_gesture(TouchPhase::Started, 0, mouse_pos);
+        }
+
         self.local_scale(&mut mouse_pos);
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if EMULATE_TOUCH {
-                if obj.handle_touch(TouchPhase::Started, 0, mouse_pos).await {
-                    return
-                }
-            } else {
+
+        if !EMULATE_TOUCH {
+            for child in self.get_children() {
+                let obj = get_ui_object3(&child);
                 if obj.handle_mouse_btn_down(btn.clone(), mouse_pos).await {
                     return
                 }
@@ -469,14 +456,15 @@ impl Window {
     }
 
     async fn handle_mouse_btn_up(&self, btn: MouseButton, mut mouse_pos: Point) {
+        if EMULATE_TOUCH {
+            self.feed_gesture(TouchPhase::Ended, 0, mouse_pos);
+        }
+
         self.local_scale(&mut mouse_pos);
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if EMULATE_TOUCH {
-                if obj.handle_touch(TouchPhase::Ended, 0, mouse_pos).await {
-                    return
-                }
-            } else {
+
+        if !EMULATE_TOUCH {
+            for child in self.get_children() {
+                let obj = get_ui_object3(&child);
                 if obj.handle_mouse_btn_up(btn.clone(), mouse_pos).await {
                     return
                 }
@@ -485,14 +473,15 @@ impl Window {
     }
 
     async fn handle_mouse_move(&self, mut mouse_pos: Point) {
+        if EMULATE_TOUCH {
+            self.feed_gesture(TouchPhase::Moved, 0, mouse_pos);
+        }
+
         self.local_scale(&mut mouse_pos);
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if EMULATE_TOUCH {
-                if obj.handle_touch(TouchPhase::Moved, 0, mouse_pos).await {
-                    return
-                }
-            } else {
+
+        if !EMULATE_TOUCH {
+            for child in self.get_children() {
+                let obj = get_ui_object3(&child);
                 if obj.handle_mouse_move(mouse_pos).await {
                     return
                 }
@@ -510,51 +499,17 @@ impl Window {
         }
     }
 
-    async fn handle_touch(&self, phase: TouchPhase, id: u64, mut touch_pos: Point) {
-        self.local_scale(&mut touch_pos);
-
-        // Process through gesture recognizer
-        let gesture = {
-            let mut gesture_proc = self.gesture_proc.lock();
-            gesture_proc.process(phase, id, touch_pos)
-        };
-        d!("Touch generated gesture: {gesture:?}");
-
-        if let Some(gesture) = gesture {
-            if self.handle_gesture(gesture).await {
-                // Gesture was handled, stop propagation
-                return
-            }
-        }
-
-        // Fallback to raw touch event (backwards compat)
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if obj.handle_touch(phase, id, touch_pos).await {
-                return
-            }
-        }
+    /// The window-level gesture session.
+    pub fn gesture_session(&self) -> &GestureSessionPtr {
+        &self.gesture_session
     }
 
-    pub fn handle_touch_sync(&self, phase: TouchPhase, id: u64, mut touch_pos: Point) -> bool {
+    /// Feed a touch (screen coordinates) into gesture recognition.
+    /// Recognition observes every touch regardless of which widget
+    /// handles it downstream.
+    pub fn feed_gesture(&self, phase: TouchPhase, id: u64, mut touch_pos: Point) {
         self.local_scale(&mut touch_pos);
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if obj.handle_touch_sync(phase, id, touch_pos) {
-                return true
-            }
-        }
-        false
-    }
-
-    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
-        for child in self.get_children() {
-            let obj = get_ui_object3(&child);
-            if obj.handle_gesture(gesture.clone()).await {
-                return true
-            }
-        }
-        false
+        self.gesture_session.touch_event(phase, id, touch_pos);
     }
 
     #[instrument(target = "ui::win")]

+ 89 - 12
openspec/changes/app-chatview/design.md

@@ -11,15 +11,27 @@ cached parley layouts and mesh caches; `adjust_scroll`→
 clones instructions for every message from newest to the viewport top
 every frame; mesh caches are only cleared on rect/scale/epoch change
 (never on scroll); `chat::make()` builds a full screen per channel and
-switching toggles `is_visible`. Wheel/flick share one `speed: AtomicF32`
-decayed by a 10ms loop.
+switching toggles `is_visible`. Wheel/flick still share one `speed:
+AtomicF32` decayed by a 10ms loop, but touch input now arrives through
+the gesture subsystem (the applied `app-gesture` change):
+`handle_touch`/`handle_touch_sync` are gone from `UIObject`, and the
+migrated ChatView consumes the `GestureAction` stream — chatview2
+builds on that seam from day one.
 
 Scene/property system facts the design relies on: `Property*::wrap`
 returns a live handle to a property object on a node (cross-node wrapping
 already exists — `window_scale` from `/window`); nodes track parents;
 nodes carry signals (`register`/`trigger`), method-call subscriptions,
 `OnModify`, and task lists; `Pimpl` UIObjects implement `draw` and
-`handle_*` input.
+`handle_*` input. Gesture subsystem facts (applied `app-gesture` change,
+`src/ui/gesture/`): `UIObject` carries `gesture_set()`/
+`gesture_hit_test()`/`handle_gesture(GestureAction)`; the window
+`GestureSession` owns recognition — 10px touch slop, 300ms tap bound,
+timer-fired single long-press, 20ms-throttled `DragMove`, release
+velocity sampled over a 40ms window and carried on `DragEnd { vel }`
+(px/sec) — with sticky hit-test-chain ownership resolved in node
+priority order; wheel and keyboard remain `handle_mouse_wheel`/
+`handle_key_down`.
 
 ## Goals / Non-Goals
 
@@ -105,9 +117,12 @@ impl ChatView2 {
 // exposes insert_line/insert_unconf_line/confirm plus url/nick
 // interaction signals; filemsg exposes set_file_status plus its file
 // signals.
-// UIObject: draw() assembles only the visible window; handle_mouse_*,
-// handle_touch, handle_key_down dispatch to materialized instances
-// through the type registry.
+// UIObject: draw() assembles only the visible window. Touch input via
+// gesture_set()/gesture_hit_test()/handle_gesture(GestureAction) — see
+// the gesture input integration section; Tap/LongPress hit-dispatch to
+// materialized instances through the type registry;
+// handle_mouse_wheel (wheel) and handle_key_down (PageUp/PageDown)
+// feed the scroll controller's page_tick.
 ```
 
 `buffer.rs` — ordering + geometry, no rendering:
@@ -180,7 +195,10 @@ pub struct ScrollController {
 pub struct Anchor { pub msg: Option<MessageId>, pub dy: f32 }
 
 impl ScrollController {
-    /// Drag: 1:1, cancels Glide/Anim
+    /// Drag: 1:1, cancels Glide/Anim. Inputs are the session's
+    /// DragStart/DragMove events (already slop-gated and throttled);
+    /// drag_end consumes the session's DragEnd velocity — no local
+    /// sampling, timers, or thresholds live here.
     pub fn drag_start(&mut self, y: f32)
     pub fn drag_move(&mut self, y: f32) -> f32
     pub fn drag_end(&mut self, velocity: f32)
@@ -688,7 +706,8 @@ pub fn page_tick(&mut self, dir: f32, page: f32) {
         ScrollState::Anim { from: self.scroll, to, started: Instant::now() };
 }
 
-// Flick: hand the sampled velocity to a decaying glide
+// Flick: the session pre-samples release velocity (DragEnd.vel, px/sec
+// over its 40ms window); flick is this controller's threshold on it
 pub fn drag_end(&mut self, velocity: f32) {
     self.state = ScrollState::Glide { velocity };
 }
@@ -785,6 +804,53 @@ The method is `anchor()` (a snapshot), not `save_anchor()` — nothing
 is persisted by the call itself; persistence is the chatview storing
 the snapshot in its per-channel state map on exit.
 
+### Gesture input integration
+
+Touch input arrives through the gesture subsystem landed by
+`app-gesture` (`src/ui/gesture/`, applied): the window `GestureSession`
+owns recognition and delivers a `GestureAction` stream;
+`handle_touch` no longer exists on `UIObject`. ChatView2 declares a
+chatview-shaped `GestureSet` (tap + long-press + vertical drag after
+slop) and maps actions to its own machinery — it runs no recognizers,
+timers, or velocity sampling of its own:
+
+| GestureAction | ChatView2 handling |
+|---|---|
+| `Down` | reset long-press mode; pause inertia while the finger is down |
+| `DragStart { start }` | `scroll.drag_start(start.y)` — grab kills Glide/Anim |
+| `DragMove { curr, .. }` | 1:1 drag on the chat axis: `scroll = scroll0 + dy` (scroll grows back into history — the inverted-axis trap the old widget hit) — or, in long-press select mode, extend the selection instead of scrolling |
+| `DragEnd { vel, .. }` | flick threshold on `vel.y` → `drag_end(velocity)` → Glide |
+| `Tap { pos }` | hit-dispatch through the type registry (URL open, nick/file activation), else line-toggle selection |
+| `LongPress { pos }` | URL under the finger → toast copy; else select line + enter selection mode (single-fire, timer-driven — session-owned) |
+| `Up` | clear touch-active state; inertia eligible again |
+
+Session-provided (never re-implemented): 10px touch slop (the dead-zone
+before drag start), 300ms tap bound, long-press timeout (system,
+timer-fired, once per touch), 20ms move delivery throttle (velocity
+sampling still observes every move), release velocity (40ms window,
+px/sec). Physics stays controller-side — inertia, decay, grab-to-stop,
+clamping, anchoring — which is exactly the split `app-gesture` defers
+to chatview2's scroll controller.
+
+Two integration invariants from the applied session's post-review
+audit:
+
+- **Exact hit regions**: `gesture_hit_test` passes exactly the rect the
+  view acts on — chain resolution has no per-event sibling fallthrough,
+  so an over-broad region steals taps from overlaying buttons (the
+  TokenTable lesson).
+- **Explicit priorities**: the session walks children in node-priority
+  order; every interactive layer floating over the chatview (the
+  scroll-to-bottom arrow, the cmd-hint popup) carries `priority` +1
+  above it, or equal-priority tie-breaks can eat its taps.
+
+The migrated old ChatView (`handle_gesture` in `src/ui/chatview/mod.rs`)
+is the behavioral parity reference for this mapping until phase 19
+deletes it. Wheel and PageUp/PageDown stay desktop handlers
+(`handle_mouse_wheel`, `handle_key_down`) feeding `page_tick` — the
+gesture subsystem is touch-only; `EMULATE_TOUCH` desktop emulation
+routes through the session, so the same paths are testable on desktop.
+
 ### Reflow: width, scale, and styling changes
 
 Wrapping depends on viewport width, so any width change (window
@@ -1017,9 +1083,12 @@ Per-type copy text: privmsg contributes its rendered line
 (`<nick> text`, action/notice variants as displayed); filemsg
 contributes its file URL; datemsg contributes its date label; future
 types decide for themselves (a type MAY contribute nothing). Selection
-gestures (click toggle, drag sweep, selection-mode taps), `unselect`,
-and the `select_changed` transition signal are unchanged from the
-current chatview and operate uniformly over all types.
+gestures in gesture-stream terms (mouse click toggle, `Tap` toggle,
+`LongPress` entering selection mode with subsequent `DragMove`
+extending the selection instead of scrolling, drag sweep,
+selection-mode taps), `unselect`, and the `select_changed` transition
+signal are unchanged from the migrated chatview and operate uniformly
+over all types.
 
 ### Wire format
 
@@ -1086,7 +1155,10 @@ per-channel `chat::make()` loop in `schema/mod.rs` disappears; the
 channel label and relay paths in `main.rs` retarget the single chatview
 via `set_channel`. Unread highlighting in the menu keys off
 message-received events carrying the channel instead of per-screen
-layers.
+layers. Overlaying interactive layers (scroll-to-bottom arrow, cmd-hint
+popup) keep explicit `priority` above the chatview2 node — the gesture
+session resolves targets in priority order (see the gesture input
+integration section).
 
 ## Risks / Trade-offs
 
@@ -1111,6 +1183,11 @@ layers.
 - [Two chatviews during migration window (old screens + chatview2)] →
   switchover is a single cutover task in the sequence; old module deleted
   in the same change once parity tests pass.
+- [Over-broad gesture hit region or missing overlay priority steals
+  taps] → `gesture_hit_test` passes exactly the view rect; every
+  floating interactive layer carries `priority` +1 (the session resolves
+  targets in priority order, no sibling fallthrough) — verified on the
+  dev screen with the arrow layer overlaid.
 
 ## Migration Plan
 

+ 12 - 3
openspec/changes/app-chatview/proposal.md

@@ -35,9 +35,12 @@ serving as the functional spec (feature parity, no regressions).
   flag.
 - Scrolling: pixel scroll from bottom (scroll=0 is always the bottom),
   1:1 finger drag, animated half-page mouse-wheel jumps, flick inertia as
-  distinct states of a scroll controller; compensation rule for height
-  changes below the viewport; per-channel scroll restore (anchor msg id +
-  offset) on re-entry.
+  distinct states of a scroll controller fed by the new `ui/gesture`
+  subsystem (drag lifecycle events, release velocity on `DragEnd`); the
+  controller owns physics only — recognition, slop, long-press timers,
+  and velocity sampling are session-provided; compensation rule for
+  height changes below the viewport; per-channel scroll restore (anchor
+  msg id + offset) on re-entry.
 - Performance: visible-range lookup, total height, and position queries
   are O(log n) in buffer size; only visible messages (soft window + LRU
   budget) hold render resources; loading is a single async background
@@ -79,5 +82,11 @@ the `chatview` capability.)
 - Per-channel kv trees: value format gains a type ID tag; keys unchanged.
   Existing history remains readable (v1 privmsg payload is the current
   `nick, text` encoding plus a confirmed flag).
+- `bin/app/src/ui/gesture/` (applied by the `app-gesture` change) is
+  consumed as-is, not modified: chatview2 implements `gesture_set`/
+  `gesture_hit_test`/`handle_gesture` instead of the removed
+  `handle_touch`; overlaying interactive layers (scroll-to-bottom arrow,
+  cmd hints) need explicit node priority above the chatview for the
+  gesture session's priority-ordered targeting.
 - Build/test via `bin/app` Makefile targets (`make compile-dev`,
   `compile-apk`); no changes outside `bin/app`, no new dependencies.

+ 68 - 10
openspec/changes/app-chatview/specs/chatview/spec.md

@@ -118,19 +118,30 @@ distinguishes "at the live bottom" from "scrolled into history".
 
 ### Requirement: Direct-drag scrolling
 
-Touch or mouse-drag scrolling SHALL move content 1:1 with the pointer in
-pixels, without animation or smoothing on top. Starting a drag SHALL
-cancel any in-flight glide or scroll animation.
+Touch-drag scrolling, delivered by the app gesture subsystem as a drag
+lifecycle (`DragStart`/`DragMove`/`DragEnd`), SHALL move content 1:1
+with the pointer in pixels, without animation or smoothing on top. The
+slop dead-zone before the drag starts and the move delivery cadence
+SHALL come from the gesture session, not the view. Starting a drag —
+or touching down over the view — SHALL cancel any in-flight glide or
+scroll animation.
 
 #### Scenario: Finger tracking is pixel-exact
 
 - **WHEN** the finger moves up by N pixels during a drag
-- **THEN** the content scrolls exactly N pixels in the same frame cadence
-  as the input
+- **THEN** the content scrolls exactly N pixels, delivered at the
+  gesture session's move cadence, with no acceleration or smoothing
+  added by the view
+
+#### Scenario: Slop dead-zone precedes scroll
+
+- **WHEN** a touch travels less than the session's touch slop
+- **THEN** no scroll occurs and the touch remains eligible for
+  tap/long-press recognition
 
 #### Scenario: Grabbing stops motion
 
-- **WHEN** a drag starts while an animated scroll or glide is in progress
+- **WHEN** a touch begins while an animated scroll or glide is in progress
 - **THEN** the animation/glide stops immediately and the drag takes over
 
 ### Requirement: Animated page scrolling for wheel and keys
@@ -153,8 +164,11 @@ retarget/coalesce the animation rather than accumulate velocity.
 ### Requirement: Flick inertia
 
 Releasing a drag with sufficient velocity SHALL produce an inertial glide
-that decays over time and stops within the clamped range. A stationary
-hold during a glide-capable touch SHALL stop the glide.
+that decays over time and stops within the clamped range. Release
+velocity SHALL be taken from the gesture session's `DragEnd` velocity
+(the view does not sample its own). Touching down over the view during a
+glide SHALL stop it, including before the touch travels past the touch
+slop.
 
 #### Scenario: Flick decays and stops
 
@@ -162,6 +176,12 @@ hold during a glide-capable touch SHALL stop the glide.
 - **THEN** the content glides in the same direction, decaying, and comes
   to rest at or within the valid scroll range
 
+#### Scenario: Touchdown stops a glide
+
+- **WHEN** the user touches the view while an inertial glide is in motion
+- **THEN** the glide stops immediately, even if the touch never travels
+  past the touch slop
+
 ### Requirement: Scroll compensation for height changes
 
 When a message below the viewport bottom changes height, the scroll
@@ -382,8 +402,10 @@ right-click or long-press copying with the "copied link" toast overlay.
 
 ### Requirement: Selection across message types
 
-Any displayed message, regardless of type, SHALL be selectable (click
-toggle, drag sweep, tap toggling in selection mode). `copy_select`
+Any displayed message, regardless of type, SHALL be selectable (mouse
+click toggle, `Tap` toggle, drag sweep, long-press entering selection
+mode with subsequent drag extending the selection, tap toggling in
+selection mode). `copy_select`
 SHALL copy the selected messages' text in display order joined by
 newlines, where each selected message contributes copy text defined by
 its type; a type MAY contribute nothing. `unselect` SHALL clear all
@@ -405,6 +427,13 @@ and not having any selection.
 - **THEN** it becomes selected and shows the selection highlight,
   unlike the current chatview where separators are unselectable
 
+#### Scenario: Long-press selects and drag extends
+
+- **WHEN** a long-press lands on a message line and the finger then
+  drags without lifting
+- **THEN** the pressed line becomes selected, selection mode is entered,
+  and the drag extends the selection instead of scrolling
+
 #### Scenario: Selection transitions signal
 
 - **WHEN** the first line becomes selected, or the last selection is
@@ -497,3 +526,32 @@ leak to other UI elements while interacting with the chatview.
 
 - **WHEN** PageUp is pressed
 - **THEN** the view animates half a page up and the key event is consumed
+
+### Requirement: Gesture subsystem integration
+
+The chatview SHALL receive touch input exclusively through the app
+gesture subsystem: it SHALL declare its accepted gestures via
+`gesture_set` (tap, long-press, vertical drag), pass an exact
+`gesture_hit_test` matching its rect, and consume the `GestureAction`
+stream via `handle_gesture`. It SHALL NOT implement its own recognition
+thresholds, long-press timers, or velocity sampling; touch slop, tap
+bounds, long-press firing, move throttling, and release velocity SHALL
+come from the session. Interactive layers floating over the chatview
+(e.g. the scroll-to-bottom arrow) SHALL carry a higher node priority
+than the chatview so the session's priority-ordered target resolution
+delivers their gestures to them.
+
+#### Scenario: Overlaying arrow receives its taps
+
+- **WHEN** the scroll-to-bottom arrow is visible over the chatview and
+  tapped
+- **THEN** the arrow receives the tap (triggering scroll-to-bottom) and
+  the chatview does not treat it as selection or content activation
+
+#### Scenario: No local recognition
+
+- **WHEN** a touch stream is delivered to the chatview
+- **THEN** slop gating, tap/long-press discrimination, long-press
+  timing, and release-velocity sampling are those of the gesture
+  session; the chatview applies only scroll physics and content
+  dispatch

+ 42 - 24
openspec/changes/app-chatview/tasks.md

@@ -51,13 +51,16 @@ design.md → Development Protocol.
 
 - [ ] 5.1 Implement `src/ui/chatview2/scroll.rs`: internal
   pixels-from-bottom scroll (no scene property), Idle/Drag/Glide/Anim
-  state machine with intents (drag start/move/end, flick, page tick,
+  state machine with intents fed from the gesture subsystem's drag
+  lifecycle (drag start/move/end; flick = threshold on the session's
+  `DragEnd` velocity — no local sampling; page tick,
   scroll_to_bottom), clamping, is_at_bottom indication,
   height-change compensation application, anchor snapshot/resolve with
   bottom shortcut and clamped fallback; verify unit tests pass for
   state transitions (grab cancels motion, wheel coalescing extends the
-  target, flick decays to stop, clamps at 0 and top, scroll_to_bottom,
-  anchor round-trip with inserts above and below)
+  target, flick fed a `DragEnd`-style velocity decays to stop, clamps
+  at 0 and top, scroll_to_bottom, anchor round-trip with inserts above
+  and below)
 - [ ] 5.2 Gate: review + amendments + atomic commit
 
 ## 6. Chatview2 skeleton + dev schema screen
@@ -66,9 +69,11 @@ design.md → Development Protocol.
   `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   `copy_select`, `unselect`, `scroll_to_bottom`, `get_line_ids`,
-  `delete_line`), empty-buffer draw running the visible-window loop,
-  and the node factory; verify `make compile-dev` succeeds and the dev
-  screen renders an empty view
+  `delete_line`), the gesture contract (`gesture_set` chatview-shaped:
+  tap + long-press + vertical drag; exact-rect `gesture_hit_test`;
+  `handle_gesture` stub), empty-buffer draw running the visible-window
+  loop, and the node factory; verify `make compile-dev` succeeds and
+  the dev screen renders an empty view
 - [ ] 6.2 Create the `src/app/schema/test_chatview.rs` dev schema
   (modeled on `schema/test.rs`) hosting the chatview2 node for
   development, gated behind a new `schema-test-chatview` cargo feature
@@ -135,12 +140,14 @@ design.md → Development Protocol.
 ## 11. Selection across types
 
 - [ ] 11.1 Implement selection: chatview-owned selected set,
-  chatview-drawn highlight (no per-type cache invalidation), click
-  toggle, drag sweep, selection-mode taps, per-type `copy_text` joined
-  in display order, `unselect`, `select_changed` transitions; verify
-  unit test for mixed-type copy ordering passes, netdebug
-  `copy_select`/`unselect` behave per spec, and manual drag/toggle
-  selection works visually on the dev screen
+  chatview-drawn highlight (no per-type cache invalidation), mouse
+  click toggle, `Tap` toggle, long-press entering selection mode with
+  subsequent `DragMove` extending the selection instead of scrolling,
+  drag sweep, selection-mode taps, per-type `copy_text` joined in
+  display order, `unselect`, `select_changed` transitions; verify unit
+  test for mixed-type copy ordering passes, netdebug
+  `copy_select`/`unselect` behave per spec, and manual
+  drag/toggle/long-press selection works visually on the dev screen
 - [ ] 11.2 Gate: review + amendments + atomic commit
 
 ## 12. Date separators
@@ -155,15 +162,22 @@ design.md → Development Protocol.
 
 ## 13. Scroll input integration
 
-- [ ] 13.1 Wire the scroll controller to input on the dev screen:
-  1:1 touch/mouse drag, flick inertia, animated half-page wheel with
-  coalescing, PageUp/PageDown keys, clamps, a visible scroll-to-bottom
-  arrow driven by `is_at_bottom` calling `scroll_to_bottom`, and the
-  animator deadline-cadence task; verify trace logs show the intended
-  state transitions per gesture, netdebug `GetPropertyValue is_at_bottom`
-  flips as expected, and visual inspection confirms pixel-exact drag,
-  smooth wheel animation, correct stops at both clamps, and the arrow
-  toggling with position
+- [ ] 13.1 Wire input on the dev screen: touch via `handle_gesture`
+  mapping to the scroll controller per design's gesture integration
+  table (`Down` pauses inertia and resets long-press mode, `DragStart`
+  grabs (kills motion), `DragMove` 1:1 on the chat axis (scroll =
+  scroll0 + dy), `DragEnd` flick on the session velocity, `Up` clears
+  touch-active state), plus `handle_mouse_wheel` and PageUp/PageDown
+  keys feeding `page_tick` (animated half-page with coalescing),
+  clamps, a visible scroll-to-bottom arrow layer (priority +1 above
+  the view) driven by `is_at_bottom` calling `scroll_to_bottom`, and
+  the animator deadline-cadence task; verify the slop dead-zone, 20ms
+  move cadence, and single-fire long-press come from the session (no
+  local recognition), trace logs show the intended state transitions
+  per gesture, netdebug `GetPropertyValue is_at_bottom` flips as
+  expected, the overlaid arrow receives its taps (arbitration), and
+  visual inspection confirms pixel-exact drag, smooth wheel animation,
+  correct stops at both clamps, and the arrow toggling with position
 - [ ] 13.2 Gate: review + amendments + atomic commit
 
 ## 14. Materialization lifecycle and eviction
@@ -220,7 +234,10 @@ design.md → Development Protocol.
   `scroll_to_bottom`; remove the per-channel screen loop in
   `schema/mod.rs`; retarget relay paths in `main.rs` and plugins
   (darkirc insert/confirm to the privmsg node, fud status fan-out to
-  the filemsg node); verify on desktop with darkirc: receiving messages
+  the filemsg node); set explicit node priorities for every interactive
+  layer floating over the chatview (scroll-to-bottom arrow, cmd-hint
+  popup) above it, per the gesture session's priority-ordered
+  targeting; verify on desktop with darkirc: receiving messages
   updates the active channel, switching channels clears/reloads and
   restores each channel's position, and unread indication works via
   signals
@@ -233,8 +250,9 @@ design.md → Development Protocol.
   `make compile-dev` and `make compile-apk` both succeed
 - [ ] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
   (URLs, selection/copy incl. separators, file messages, actions/notices,
-  unconfirmed, date separators, keys, touch, wheel, restore, reflow) on
+  unconfirmed, date separators, keys, touch incl. slop dead-zone,
+  grab-to-stop, long-press select mode, wheel, restore, reflow) on
   desktop; performance pass scrolling a large history (no slowdown,
   bounded memory via renderer debug stats); android device smoke test
-  (drag, flick, long-press copy, channel switching)
+  (drag, flick, grab-stop, long-press select/copy, channel switching)
 - [ ] 19.3 Gate: final review + atomic commit closing the change

+ 0 - 2
openspec/changes/app-gesture/.openspec.yaml

@@ -1,2 +0,0 @@
-schema: spec-driven
-created: 2026-08-30

+ 0 - 517
openspec/changes/app-gesture/design.md

@@ -1,517 +0,0 @@
-## Context
-
-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
-
-Goals:
-
-- Recognition mechanics (distance/time/velocity state machines) exist exactly
-  once; gesture constants exist exactly once.
-- Widgets shrink to a declarative contract: which gestures they accept, where
-  they are hit, and what the gestures mean.
-- One dispatch path with one ordering and one coordinate translation.
-- Long-press that fires during hold (timer-driven), fixing the still-finger gap
-  in the processor and in BaseEdit's move-triggered check.
-
-Non-Goals:
-
-- Pinch/multi-finger gestures in v1. The stream is shaped so a `Pinch`
-  recognizer can be added without API break. Secondary touches keep today's
-  "ignored, cannot disturb the primary" semantics.
-- Mouse, wheel, and keyboard handling. Desktop keeps `handle_mouse_*`;
-  `EMULATE_TOUCH` only gets routed through the session so emulated touches
-  produce gestures.
-- Scroll physics. Inertia, decay, grab-to-stop, anchoring stay widget-side
-  (and become chatview2's scroll controller under `app-chatview`).
-- Rewriting ChatView itself (`app-chatview` owns that; this change supplies the
-  recognizer seam it is specified to consume).
-
-## Decisions
-
-### D1: Window-owned session + per-node configured recognizers
-
-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:
-
-- Window god-object with one hardcoded behavior (the landed attempt's shape):
-  legitimately different per-widget semantics (axis lock, drag direction,
-  zero-threshold precision drags) cannot be expressed.
-- Per-widget self-contained recognizers with no orchestration (Android
-  `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.
-
-### D2: The session is fed from the Stage thread
-
-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.
-
-### D3: One event stream, lifecycle included; flick is derived
-
-```
-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.
-
-### D4: Sticky ownership via hit-test chain resolved at touch start
-
-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.
-
-### D5: Arbitration is first-resolved-wins
-
-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.
-
-### D6: Unified constants, Android-flavored
-
-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.
-
-### D7: Async-only delivery; the sync path dies
-
-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.
-
-### D8: Deletions
-
-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.
-
-## The API
-
-Constants and stream:
-
-```rust
-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:
-
-```rust
-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:
-
-```rust
-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:
-
-```rust
-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
-}
-```
-
-## Migration samples
-
-### Button (tap)
-
-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:
-
-```rust
-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).
-
-### EmojiPicker (scroll + tap)
-
-Before: 65 lines of local `TouchInfo { start_pos, start_scroll, is_scroll }`
-deciding scroll-vs-tap at a 0.5px y threshold.
-
-After:
-
-```rust
-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.
-
-### ChatView (scroll + flick inertia + long-press select + tap)
-
-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:
-
-```rust
-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.
-
-### Menu (long-press edit mode + reorder drag + tap)
-
-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:
-
-```rust
-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,
-    }
-}
-```
-
-### BaseEdit (hybrid, deliberately partial)
-
-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):
-
-```rust
-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,
-    }
-}
-```
-
-## Risks / Trade-offs
-
-- [Async-only delivery regresses selection-handle drag latency] → All visual
-  feedback already waits on the serialized redraw pass; the sync path only
-  saves one executor hop. Verify on device during the BaseEdit migration
-  step; if measurable, add a `handle_gesture_sync` delivery variant for
-  `Down`/`DragMove` consumers as a contained fallback (D7 does not preclude
-  it).
-- [Slop-bounded taps feel different in ChatView/Menu (0.05px → 10px)] →
-  Accepted delta toward platform-standard feel; the mechanism for per-node
-  tightening exists (`TapCfg`) if field use disagrees.
-- [Mixed raw/gesture widgets during migration can double-act] → Migration is
-  per-widget and the raw path stays intact until D8; a migrated widget stops
-  claiming raw phases, and ownership stickiness (D4) prevents siblings from
-  seeing the strays. Each step ships green.
-- [Touch ownership stickiness changes edge behavior] → Called-out delta; a
-  touch that starts on widget A and ends over widget B now belongs to A
-  entirely. Matches iOS; more predictable than per-phase re-propagation.
-- [Sequencing collision with `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.
-- [Recognizer regressions] → Recognizers are pure state machines; synthetic
-  touch-stream unit tests pin tap/drag/long-press/velocity behavior before
-  any widget migrates.
-
-## Migration Plan
-
-1. Delete the dead code (both old gesture files, see D8), then land the gesture
-   module (constants, `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.
-2. Migrate leaf widgets: Button, TokenTable, EmojiPicker. Desktop verify via
-   `make compile-dev` + `EMULATE_TOUCH`.
-3. Migrate Menu.
-4. Migrate ChatView (or hand to chatview2 — see Risks).
-5. Migrate BaseEdit; on-device latency check (Risks).
-6. Deletions (D8) + `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.
-
-## Open Questions
-
-- EmojiPicker flick inertia: adopt for feel-consistency or preserve its
-  current dead-stop? Decide during its migration task; input (`DragEnd
-  { vel }`) is available either way.
-- Menu reorder: drive updates from `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: resurrect the dead node's behavior as a `Pinch` recognizer when a
-  consumer appears (image zoom is the plausible one). Deferred by design.

+ 0 - 82
openspec/changes/app-gesture/proposal.md

@@ -1,82 +0,0 @@
-## Why
-
-The app UI has five overlapping hand-rolled gesture recognizers (`GestureProcessor`,
-`ChatView::TouchInfo`, `Menu::TouchInfo`+`DragInfo`, `BaseEdit::TouchStateAction`,
-`EmojiPicker::TouchInfo`) plus a dead pinch node, each re-deriving movement, dwell
-time, and velocity with wildly divergent thresholds (tap strictness ranges from
-0.05px to 10px across widgets). Touch dispatch is split-brained: a sync Stage-thread
-path that can swallow events before the async path — and before the window gesture
-processor — ever sees them, no hit-testing, and `handle_gesture` cannot propagate
-past the first `Layer`. Widget touch code is the largest block of incidental
-complexity in `bin/app/src/ui/`.
-
-## What Changes
-
-- New gesture subsystem in `bin/app/src/ui/gesture/`: a window-level
-  `GestureSession` owning the touch stream, target resolution, long-press timers,
-  version-guarded cancellation, move throttling, and recognizer arbitration; plus a
-  recognizer library (tap, long-press, drag lifecycle, flick) that exists exactly
-  once.
-- Unified gesture constants (touch slop, tap duration, long-press timeout from the
-  system `long_press_timeout()`, flick velocity, move delivery period) replacing the
-  per-widget scatter. Per-widget config survives only where semantic: axis lock,
-  drag direction, `min_travel: 0.` for precision drags.
-- `UIObject` gains `gesture_set()` and `gesture_hit_test()`; the existing
-  (dead-dispatch) `handle_gesture()` is re-pointed to the new `GestureAction`;
-  `Layer`/`ScrollLayer` forward gestures with coordinate translation.
-- New `GestureAction` stream: `Down`/`Up` passthrough for immediate feedback,
-  `Tap`, `LongPress`, `DragStart`/`DragMove`/`DragEnd { vel }` (flick is derived
-  from `DragEnd` velocity by the consumer).
-- The session is fed from the Stage thread (`gfx` touch entry) so it observes all
-  touches regardless of sync claiming; `EMULATE_TOUCH` mouse emulation routes
-  through the same session so desktop development produces gestures.
-- All touch widgets migrate to the new model: Button, TokenTable, EmojiPicker,
-  Menu, ChatView, BaseEdit (hybrid: `Down`-armed precision drags for selection
-  handles). Recognition semantics live in recognizers; controller physics (scroll
-  inertia, grab-to-stop) stay widget-side.
-- **BREAKING** (internal app API): `handle_touch` and `handle_touch_sync` are
-  removed from `UIObject` once every widget is migrated; the four widget
-  `TouchInfo` state machines and `win/gesture.rs` (`GestureProcessor` and the
-  old `GestureAction`) are deleted at the end; the dead `ui/gesture.rs` pinch
-  node is removed up front — the new `ui/gesture/` directory takes its module
-  path (pinch returns later as a recognizer if wanted).
-- Accepted behavior deltas toward platform-standard feel: slop-bounded taps
-  everywhere (replaces 0.05px strictness in ChatView/Menu), slop dead-zone before
-  scroll starts, long-press fires once during hold by timer, touch ownership
-  sticks to the Started target, EmojiPicker may gain flick inertia, all gesture
-  delivery is async (the sync path dies; on-device latency to be verified during
-  migration with a sync hatch as fallback).
-
-## Capabilities
-
-### New Capabilities
-- `gesture`: the gesture recognition and delivery system for the app UI — session
-  semantics (stream ownership, targeting, timers, throttling, arbitration),
-  recognizer contracts and unified constants, the `GestureAction` event stream,
-  the `UIObject` gesture contract
-  (`gesture_set`/`gesture_hit_test`/`handle_gesture`),
-  coordinate-translation forwarding, and the migrated behavior of each widget
-  under the new system (button taps, scrollers, chat selection/scroll/flick,
-  edit hybrid handling, menu reorder/edit-mode) including the accepted behavior
-  deltas above.
-
-### Modified Capabilities
-
-(none — no main specs exist yet; widget behavior deltas are captured as
-requirements of the new `gesture` capability)
-
-## Impact
-
-- Code: `bin/app/src/ui/` (new `gesture/` module; rewrites of `win/mod.rs` touch
-  entry, `layer.rs`, `scroll_layer.rs`, `button.rs`, `tokentable/`,
-  `emoji_picker/`, `menu/`, `chatview/`, `edit/`; deletion of the dead
-  `ui/gesture.rs` and `win/gesture.rs` up front), and `bin/app/src/gfx/mod.rs`
-  (Stage-thread session feed).
-  No workspace crates, no dependencies, no consensus/ZK surfaces.
-- Parallel work: `app-chatview` (chatview2) is specified to consume drag/flick as
-  scroll-controller inputs — this change should land its session and recognizers
-  first so chatview2 builds on it; ChatView's own migration is the proof of
-  composition (or is absorbed by chatview2 if it lands first).
-- Verification: `make compile-dev` (desktop), `make compile-apk` (Android), and
-  on-device touch-feel verification for the async-only delivery (edit selection
-  handles are the latency-sensitive case).

+ 0 - 222
openspec/changes/app-gesture/specs/gesture/spec.md

@@ -1,222 +0,0 @@
-## Purpose
-
-Defines how touch input is recognized into gesture events and delivered to UI
-widgets of the app: a single recognition system with unified thresholds, the
-widget contract for receiving gestures, and the touch behavior of every
-migrated widget.
-
-## ADDED Requirements
-
-### Requirement: Gesture event stream
-The system SHALL recognize touch input into a stream of gesture events:
-`Down` and `Up` passthrough events delivered immediately at touch start and
-end, `Tap`, `LongPress`, and a drag lifecycle of `DragStart`, `DragMove`, and
-`DragEnd` carrying the release velocity. All gesture positions SHALL be
-delivered in the receiving widget's local coordinate space.
-
-#### Scenario: Immediate feedback at touch start
-- **WHEN** a touch begins on a widget that hit-tests at that position
-- **THEN** the widget receives a `Down` event without waiting for any gesture
-  to resolve
-
-#### Scenario: Drag lifecycle completes exactly once
-- **WHEN** a touch moves beyond the drag threshold and is later released
-- **THEN** the target widget receives one `DragStart`, zero or more
-  `DragMove`, and exactly one `DragEnd` whose velocity reflects the recent
-  movement history at release
-
-#### Scenario: Coordinates are local
-- **WHEN** a gesture is delivered to a widget nested inside layers
-- **THEN** event positions are translated into the widget's own coordinate
-  space
-
-### Requirement: Unified recognition thresholds
-The system SHALL use one set of recognition constants for all widgets: touch
-slop bounding tap travel and long-press stationarity, a tap duration bound,
-the system long-press timeout, a drag start threshold equal to touch slop, a
-move delivery period of 20ms, and a velocity sample window of 40ms. A `Tap`
-SHALL require travel within slop and duration within the bound.
-
-#### Scenario: Tap within slop
-- **WHEN** a touch goes down and up within slop travel and within the tap
-  duration bound
-- **THEN** a `Tap` is delivered
-
-#### Scenario: Movement beyond slop cancels tap
-- **WHEN** travel exceeds slop before release
-- **THEN** no `Tap` fires and drag recognition proceeds instead
-
-#### Scenario: Move delivery is throttled
-- **WHEN** touch moves arrive faster than the move delivery period
-- **THEN** `DragMove` is delivered at most once per period while velocity
-  sampling still observes every move
-
-### Requirement: Long-press fires during hold
-`LongPress` SHALL fire while the finger is still down, once the system
-long-press timeout elapses with travel within slop. It SHALL fire at most
-once per touch and SHALL be cancelled by travel beyond slop before the
-timeout or by touch cancellation.
-
-#### Scenario: Stationary hold fires during contact
-- **WHEN** a touch is held past the long-press timeout without exceeding slop
-- **THEN** `LongPress` is delivered while the touch is still down
-
-#### Scenario: Movement cancels long-press
-- **WHEN** travel exceeds slop before the timeout elapses
-- **THEN** no `LongPress` fires for that touch
-
-### Requirement: Touch ownership
-The target of a touch SHALL be resolved by hit-testing the widget tree in
-priority order at touch start. All gesture events for that touch SHALL be
-delivered only to the resolved target chain until the touch ends or is
-cancelled. A touch that moves over a different widget mid-gesture SHALL NOT
-hand off ownership.
-
-#### Scenario: Ownership is sticky
-- **WHEN** a touch starts on widget A and moves over sibling widget B before
-  release
-- **THEN** only widget A's chain receives events for that touch
-
-#### Scenario: Priority ordering
-- **WHEN** two overlapping widgets both hit-test at the touch start position
-- **THEN** the higher-priority widget owns the touch
-
-### Requirement: Recognition observes all touches
-Gesture recognition SHALL observe every touch event regardless of which
-widget handles it, including touches that begin on widgets that previously
-claimed events synchronously. Secondary touches (additional simultaneous
-touch ids) SHALL NOT alter or cancel recognition of the primary touch.
-
-#### Scenario: Touch on a latency-sensitive widget still recognized
-- **WHEN** a touch begins on a widget whose interaction previously suppressed
-  event delivery to the recognition system
-- **THEN** gesture events for that touch are still recognized and delivered
-  to its target chain
-
-#### Scenario: Second finger is inert
-- **WHEN** a second touch id appears during an active drag
-- **THEN** the active touch's recognition and delivery are unaffected
-
-### Requirement: Gesture arbitration
-When multiple widgets in the target chain accept competing gestures, the
-first recognizer to resolve SHALL claim the gesture and competing
-recognizers SHALL be cancelled. Within slop the descendant's tap wins over an
-ancestor's drag; beyond slop the ancestor's drag wins over the descendant's
-pending tap.
-
-#### Scenario: Row tap inside a scrollable menu
-- **WHEN** a touch on a menu row is released within slop
-- **THEN** the row receives the `Tap` and the menu's scroll is not engaged
-
-#### Scenario: Scroll wins on movement
-- **WHEN** the same touch instead travels beyond slop
-- **THEN** the menu's drag claims the gesture and the row's pending tap is
-  cancelled
-
-### Requirement: Widget gesture contract
-Widgets SHALL declare the gestures they accept and a hit-test region.
-Widgets that declare no gestures SHALL receive only `Down`/`Up` passthrough;
-widgets whose hit-test excludes a position SHALL receive no gesture events
-for that touch.
-
-#### Scenario: Non-participating widget is inert
-- **WHEN** a touch passes over a widget that declares no gestures
-- **THEN** that widget receives no recognized gesture events
-
-### Requirement: Touch cancellation
-Touch cancellation SHALL tear down all pending recognition state and timers
-for that touch, and no further gesture events SHALL be emitted for it after
-cancellation.
-
-#### Scenario: Cancelled touch emits nothing further
-- **WHEN** the system cancels an active touch mid-gesture
-- **THEN** pending long-press timers are invalidated and no `Tap`,
-  `LongPress`, or `DragEnd` fires for it
-
-### Requirement: Emulated touch parity
-Mouse-emulated touches on desktop SHALL produce the same gesture recognition
-and delivery as real touches on Android.
-
-#### Scenario: Desktop emulated tap
-- **WHEN** a click is performed through mouse emulation of touch
-- **THEN** the same `Tap` is recognized and delivered as on device
-
-### Requirement: Button and token table activation
-The button SHALL emit its click signal on `Tap` within its hit region, and
-the token table SHALL emit its row click signal on `Tap` within a row.
-Activation by mouse remains unchanged.
-
-#### Scenario: Button tap activates
-- **WHEN** a `Tap` lands inside the button's hit region
-- **THEN** the button's click signal fires
-
-### Requirement: Emoji picker scroll and selection
-The emoji picker SHALL scroll one-to-one with vertical drag after slop,
-clamped to its scroll bounds, and SHALL activate the emoji under a `Tap`.
-
-#### Scenario: Drag scrolls, tap selects
-- **WHEN** a vertical drag moves within the picker
-- **THEN** scroll follows the finger clamped to bounds and no emoji is
-  activated; on `Tap` within slop the emoji under the position is activated
-
-### Requirement: Menu edit mode, selection, and reorder
-The menu SHALL enter edit mode on `LongPress`, SHALL select or delete items
-on `Tap`, and SHALL reorder items via a drag armed by touching the reorder
-handle at touch start.
-
-#### Scenario: Long-press enters edit mode
-- **WHEN** a touch is held on the menu past the long-press timeout within
-  slop
-- **THEN** edit mode activates while the finger is still down
-
-#### Scenario: Reorder drag
-- **WHEN** a touch starts on an item's reorder handle in edit mode and moves
-- **THEN** the item's insertion index follows the drag and the reorder
-  commits at touch end
-
-### Requirement: Chat view scroll, selection, and taps
-The chat view SHALL scroll one-to-one with vertical drag, SHALL drive its
-scroll inertia from the `DragEnd` velocity, SHALL stop inertia when a new
-drag starts, SHALL start line selection or show a URL toast on `LongPress`,
-and on `Tap` SHALL forward to the message under the touch (opening URLs,
-downloading files) or toggle line selection when selection is active.
-
-#### Scenario: Flick continues after release
-- **WHEN** a fast vertical drag is released
-- **THEN** the view keeps scrolling with inertia derived from the release
-  velocity and decays to a stop
-
-#### Scenario: Grab stops inertia
-- **WHEN** a new touch begins during inertial scrolling
-- **THEN** inertia stops and the new drag owns the scroll
-
-#### Scenario: Tap forwards to message content
-- **WHEN** a `Tap` lands on a message URL
-- **THEN** the URL opens and the view does not treat it as a scroll
-
-### Requirement: Text edit hybrid gestures
-The text edit SHALL arm selection-handle dragging at `Down` by grabbing a
-handle within its radius, SHALL select the word under the touch and show the
-action menu on `LongPress`, SHALL set the cursor and request focus on `Tap`,
-and SHALL scroll vertically on vertical drag while fingers move text-selection
-handles.
-
-#### Scenario: Long-press selects word
-- **WHEN** a touch is held on the edit past the long-press timeout within
-  slop
-- **THEN** the word under the touch is selected and the copy/paste menu is
-  shown while the finger is still down
-
-#### Scenario: Handle drag adjusts selection
-- **WHEN** a touch starts within a selection handle's grab radius and moves
-- **THEN** the selection endpoint follows the finger from the first movement
-
-### Requirement: Touch interaction expressed only through gestures
-After migration, widget touch interaction SHALL be expressed exclusively
-through the gesture stream. The per-widget raw phase handlers and the
-separate synchronous touch dispatch path SHALL NOT exist.
-
-#### Scenario: Single dispatch path
-- **WHEN** any touch event is processed
-- **THEN** it feeds one recognition system and gestures are delivered through
-  one path in one ordering

+ 0 - 89
openspec/changes/app-gesture/tasks.md

@@ -1,89 +0,0 @@
-## 1. Gesture core
-
-- [ ] 1.1 Delete the dead code up front: `ui/gesture.rs` (pinch node, with its
-       `create_gesture` registration and `Pimpl::Gesture` variant — the new
-       module takes over its path) and `win/gesture.rs` (`GestureProcessor`
-       and the old `GestureAction`, with the dead `gesture_proc` dispatch in
-       `Window::handle_touch`); then create `bin/app/src/ui/gesture/` module
-       with `GestureConstants` (slop 10px, tap 300ms, sys long-press timeout,
-       20ms move period, 40ms sample window), the `GestureAction` stream
-       (`Down`/`Up`/`Tap`/`LongPress`/`DragStart`/`DragMove`/`DragEnd { vel }`),
-       and the `GestureSet`/config types (`Axes`, `Direction`, `min_travel`);
-       repoint the `ui::GestureAction` re-export and the dead
-       `UIObject::handle_gesture` signature to the new types; verify
-       `make compile-dev` in `bin/app`
-- [ ] 1.2 Implement the recognizer library as pure state machines (tap,
-       long-press, drag lifecycle with 40ms velocity sampling) taking
-       `GestureConstants`; verify unit tests pass covering: tap within/beyond
-       slop, tap duration bound, long-press firing during hold, long-press
-       cancelled by movement, throttled delivery vs full sampling, and
-       `DragEnd` velocity from the sample window
-- [ ] 1.3 Implement gesture arbitration rules (first-resolved-wins, tap vs
-       drag via slop, cascade cancellation); verify unit tests cover
-       descendant-tap-wins-within-slop and ancestor-drag-wins-beyond-slop
-
-## 2. Session and dispatch
-
-- [ ] 2.1 Implement `GestureSession`: fed from the Stage thread at the `gfx`
-       touch entry before any sync claiming; resolves the hit-test target
-       chain at `Down` (priority order, sticky ownership until `Up`/cancel);
-       runs version-guarded long-press timers on the executor; throttles
-       `DragMove` delivery to 20ms; ignores secondary touch ids; verify unit
-       tests pass for sticky ownership, priority ordering, and cancellation
-       teardown
-- [ ] 2.2 Add `gesture_set()`/`gesture_hit_test()` defaults to `UIObject` and
-       implement `Layer`/`ScrollLayer` gesture forwarding with
-       coordinate translation (mirroring `handle_touch` translation); verify
-       `make compile-dev` and a unit test pinning local-coordinate delivery
-       through a nested layer
-- [ ] 2.3 Route `EMULATE_TOUCH` mouse-emulated touches through the session so
-       desktop emulation produces real gestures; verify an emulated tap
-       triggers the gesture path on a desktop build with `emulate-android`
-       enabled
-
-## 3. Widget migrations
-
-- [ ] 3.1 Migrate Button: `Tap` fires the click signal; delete
-       `handle_touch`/`handle_touch_sync` and the `mouse_btn_held` gate; mouse
-       path unchanged; verify emulated tap clicks on desktop with no double
-       activation
-- [ ] 3.2 Migrate TokenTable: row click on `Tap`; delete the touch-to-mouse
-       simulation; verify emulated row tap fires `row_click`
-- [ ] 3.3 Migrate EmojiPicker: 1:1 clamped scroll on vertical drag, emoji
-       activation on `Tap`; resolve the flick-inertia open question (adopt or
-       preserve dead-stop) and record the choice; verify scroll clamps at
-       bounds and tap selects under emulation
-- [ ] 3.4 Migrate Menu: `LongPress` enters edit mode (single fire during
-       hold), `Tap` selects/deletes, reorder drag armed at `Down` on the
-       reorder handle; delete `TouchInfo`/`DragInfo` and the long-press task
-       juggling; verify edit mode, reorder commit, and item selection under
-       emulation
-- [ ] 3.5 Migrate ChatView: 1:1 scroll on drag, inertia fed from `DragEnd`
-       velocity, grab-stops-inertia on `DragStart`, `LongPress` for line
-       select / URL toast, `Tap` forwarding to message content (URLs, file
-       downloads) and line-toggle; delete `TouchInfo`, the
-       `touch_hold_version` timer, and `end_touch_phase`; first coordinate
-       with `app-chatview` whether this task or chatview2 performs the
-       migration; verify scroll, flick, grab-stop, and URL tap under
-       emulation
-- [ ] 3.6 Migrate BaseEdit (hybrid): `Down` arms selection-handle grab and
-       word-select/cursor state, `min_travel: 0.` drag drives handle movement
-       and vertical scroll, `LongPress` selects word + shows action menu,
-       `Tap` sets cursor + requests focus, `Up` finalizes; delete
-       `TouchStateAction` and the sync/async phase split; verify word-select,
-       handle drag, cursor tap, and focus under emulation
-
-## 4. Cleanup and verification
-
-- [ ] 4.1 Delete the old paths: `handle_touch`/`handle_touch_sync` from
-       `UIObject` and all implementors, and the four widget `TouchInfo`
-       state machines; verify `make compile-dev` with no remaining
-       references to the removed APIs or the old `GestureAction`
-- [ ] 4.2 Verify `make compile-apk` succeeds
-- [ ] 4.3 On-device feel pass: chat scroll/flick/grab-stop, URL tap, line
-       selection, edit word-select and selection handles (latency check —
-       if handle drag feels laggy, implement the documented
-       `handle_gesture_sync` fallback for `Down`/`DragMove`), menu
-       edit-mode/reorder, emoji scroll, button taps
-- [ ] 4.4 Cross-check every scenario in `specs/gesture/spec.md` against unit
-       tests and the on-device pass; record any scenario lacking coverage