Sfoglia il codice sorgente

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 settimane fa
parent
commit
731154c340
36 ha cambiato i file con 2731 aggiunte e 2254 eliminazioni
  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;
 pub mod locale;
 use locale::read_locale_ftl;
 use locale::read_locale_ftl;
-mod node;
+pub mod node;
 use node::create_window;
 use node::create_window;
 pub mod schema;
 pub mod schema;
 
 
@@ -136,6 +136,7 @@ impl App {
                     me,
                     me,
                     self.renderer.clone(),
                     self.renderer.clone(),
                     i18n_fish.clone(),
                     i18n_fish.clone(),
+                    self.ex.clone(),
                     self.redraw_trigger.clone(),
                     self.redraw_trigger.clone(),
                     self.redraw_rx.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
     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)]
 #[allow(dead_code)]
 pub fn create_image(name: &str) -> SceneNode {
 pub fn create_image(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::Image);
     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("insert_text", vec![("text", "Text", CallArgType::Str)], None).unwrap();
     node.add_method("focus", vec![], None).unwrap();
     node.add_method("focus", vec![], None).unwrap();
     node.add_method("unfocus", vec![], None).unwrap();
     node.add_method("unfocus", vec![], None).unwrap();
+    node.add_method("hide_ime", vec![], None).unwrap();
 
 
     node
     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();
     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_bool(atom, Role::App, "is_visible", false).unwrap();
     down_layer.set_property_u32(atom, Role::App, "z_index", 3).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;
     let down_layer = down_layer.setup(|me| Layer::new(me, renderer.clone(), redraw.clone())).await;
     layer_node.link(down_layer.clone());
     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, 2, EMOJIBTN_BOX[2]).unwrap();
     prop.set_f32(atom, Role::App, 3, EMOJIBTN_BOX[3]).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");
     let (slot, recvr) = Slot::new("reqkeyb");
     chatedit_node.register("focus_request", slot).unwrap();
     chatedit_node.register("focus_request", slot).unwrap();
     let chatedit_node2 = chatedit_node.clone();
     let chatedit_node2 = chatedit_node.clone();
@@ -1397,8 +1402,10 @@ pub async fn make(
             }
             }
 
 
             if emoji_btn_is_visible.get() {
             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_close_is_visible.get());
                 assert!(emoji_h_prop.get() < 0.001);
                 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");
     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_bool(atom, Role::App, "is_visible", false).unwrap();
     cmd_layer_node.set_property_u32(atom, Role::App, "z_index", 3).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 =
     let cmd_layer_node =
         cmd_layer_node.setup(|me| Layer::new(me, renderer.clone(), redraw.clone())).await;
         cmd_layer_node.setup(|me| Layer::new(me, renderer.clone(), redraw.clone())).await;
     layer_node.link(cmd_layer_node.clone());
     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_expr(atom, Role::App, 2, code).unwrap();
     prop.set_f32(atom, Role::App, 3, MENU_BTN_H).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_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();
     node.set_property_u32(atom, Role::App, "priority", 1).unwrap();
     let editlayer_node =
     let editlayer_node =
         node.setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone())).await;
         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");
     let node = create_button("chat_btn");
     node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
     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 prop = node.get_property("rect").unwrap();
     let code = cc.compile(format!("w - {CHAT_BTN_SIZE} - {CHAT_BTN_MARGIN}")).unwrap();
     let code = cc.compile(format!("w - {CHAT_BTN_SIZE} - {CHAT_BTN_MARGIN}")).unwrap();
     prop.set_expr(atom, Role::App, 0, code).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/>.
  * 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 std::sync::Arc;
 
 
 use super::{Dimension, Point};
 use super::{Dimension, Point};
@@ -53,7 +53,6 @@ pub struct GraphicsEventPublisher {
     mouse_btn_up: EventChannel<(MouseButton, Point)>,
     mouse_btn_up: EventChannel<(MouseButton, Point)>,
     mouse_move: EventChannel<Point>,
     mouse_move: EventChannel<Point>,
     mouse_wheel: EventChannel<Point>,
     mouse_wheel: EventChannel<Point>,
-    touch: EventChannel<(TouchPhase, u64, Point)>,
 }
 }
 
 
 pub type GraphicsEventScreenSub = async_channel::Receiver<bool>;
 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 GraphicsEventMouseButtonUpSub = async_channel::Receiver<(MouseButton, Point)>;
 pub type GraphicsEventMouseMoveSub = async_channel::Receiver<Point>;
 pub type GraphicsEventMouseMoveSub = async_channel::Receiver<Point>;
 pub type GraphicsEventMouseWheelSub = async_channel::Receiver<Point>;
 pub type GraphicsEventMouseWheelSub = async_channel::Receiver<Point>;
-pub type GraphicsEventTouchSub = async_channel::Receiver<(TouchPhase, u64, Point)>;
 
 
 impl GraphicsEventPublisher {
 impl GraphicsEventPublisher {
     pub fn new() -> Arc<Self> {
     pub fn new() -> Arc<Self> {
@@ -79,7 +77,6 @@ impl GraphicsEventPublisher {
             mouse_btn_up: EventChannel::new(),
             mouse_btn_up: EventChannel::new(),
             mouse_move: EventChannel::new(),
             mouse_move: EventChannel::new(),
             mouse_wheel: 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) {
     pub(super) fn notify_mouse_wheel(&self, wheel_pos: Point) {
         self.mouse_wheel.notify(wheel_pos);
         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 {
     pub fn subscribe_screen_changed(&self) -> GraphicsEventScreenSub {
         self.screen_changed.clone_recvr()
         self.screen_changed.clone_recvr()
     }
     }
@@ -150,7 +142,4 @@ impl GraphicsEventPublisher {
     pub fn subscribe_mouse_wheel(&self) -> GraphicsEventMouseWheelSub {
     pub fn subscribe_mouse_wheel(&self) -> GraphicsEventMouseWheelSub {
         self.mouse_wheel.clone_recvr()
         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 struct Point {
     pub x: f32,
     pub x: f32,
     pub y: f32,
     pub y: f32,
@@ -395,7 +395,7 @@ pub struct Segment {
     pub end: Point,
     pub end: Point,
 }
 }
 
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq)]
 pub struct Vector {
 pub struct Vector {
     pub x: f32,
     pub x: f32,
     pub y: f32,
     pub y: f32,

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

@@ -52,7 +52,7 @@ pub use ev::{
     GraphicsEventCharSub, GraphicsEventKeyDownSub, GraphicsEventKeyUpSub,
     GraphicsEventCharSub, GraphicsEventKeyDownSub, GraphicsEventKeyUpSub,
     GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub, GraphicsEventMouseMoveSub,
     GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub, GraphicsEventMouseMoveSub,
     GraphicsEventMouseWheelSub, GraphicsEventPublisher, GraphicsEventPublisherPtr,
     GraphicsEventMouseWheelSub, GraphicsEventPublisher, GraphicsEventPublisherPtr,
-    GraphicsEventResizeSub, GraphicsEventTouchSub,
+    GraphicsEventResizeSub,
 };
 };
 mod favico;
 mod favico;
 mod prune;
 mod prune;
@@ -1301,19 +1301,16 @@ impl EventHandler for Stage {
             self.window_node = god.app.sg_root.lookup_node("/window");
             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 {
         if let Some(window_node) = &self.window_node {
             match window_node.pimpl() {
             match window_node.pimpl() {
                 Pimpl::Window(win) => {
                 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!(),
                 _ => panic!(),
             }
             }
         }
         }
-
-        self.event_pub.notify_touch(phase, id, pos);
     }
     }
 
 
     fn quit_requested_event(&mut self) {
     fn quit_requested_event(&mut self) {

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

@@ -116,7 +116,6 @@ pub enum SceneNodeType {
     Image = 15,
     Image = 15,
     Button = 16,
     Button = 16,
     Shortcut = 17,
     Shortcut = 17,
-    Gesture = 18,
     EmojiPicker = 19,
     EmojiPicker = 19,
     Setting = 21,
     Setting = 21,
     Menu = 22,
     Menu = 22,
@@ -609,7 +608,6 @@ pub enum Pimpl {
     Video(ui::VideoPtr),
     Video(ui::VideoPtr),
     Button(ui::ButtonPtr),
     Button(ui::ButtonPtr),
     Shortcut(ui::ShortcutPtr),
     Shortcut(ui::ShortcutPtr),
-    Gesture(ui::GesturePtr),
     EmojiPicker(ui::EmojiPickerPtr),
     EmojiPicker(ui::EmojiPickerPtr),
     Menu(ui::MenuPtr),
     Menu(ui::MenuPtr),
     TokenTable(ui::TokenTablePtr),
     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) {
     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.select = (cursor_idx, cursor_idx);
         self.state.compose = None;
         self.state.compose = None;
         self.input.set_state(self.state.clone());
         self.input.set_state(self.state.clone());
@@ -275,3 +284,12 @@ impl Editor {
         self.input.set_input_type(input_type);
         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 async_trait::async_trait;
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::sync::{
 use std::sync::{
@@ -34,7 +34,7 @@ use crate::{
     ExecutorPtr,
     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! d { ($($arg:tt)*) => { debug!(target: "ui::button", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::button", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::button", $($arg)*); } }
@@ -188,56 +188,22 @@ impl UIObject for Button {
         true
         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/>.
  * 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_lock::Mutex as AsyncMutex;
 use async_trait::async_trait;
 use async_trait::async_trait;
 use atomic_float::AtomicF32;
 use atomic_float::AtomicF32;
@@ -29,7 +27,6 @@ use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use regex::Regex;
 use regex::Regex;
 use std::{
 use std::{
-    collections::VecDeque,
     io::Cursor,
     io::Cursor,
     sync::{
     sync::{
         atomic::{AtomicBool, AtomicU32, Ordering},
         atomic::{AtomicBool, AtomicU32, Ordering},
@@ -56,7 +53,7 @@ use crate::{
     ExecutorPtr,
     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! d { ($($arg:tt)*) => { debug!(target: "ui::chatview", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(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).
 /// (which only selects) rather than a click (which toggles).
 const SELECT_DRAG_THRESHOLD: f32 = 2.;
 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
 /// Tracks an in-progress mouse selection gesture so we can distinguish a
 /// stationary click (toggles the line) from a drag (only ever selects).
 /// stationary click (toggles the line) from a drag (only ever selects).
 struct SelectDrag {
 struct SelectDrag {
@@ -122,50 +115,15 @@ impl std::fmt::Display for MessageId {
 
 
 const PRELOAD_PAGES: usize = 1;
 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>;
 pub type ChatViewPtr = Arc<ChatView>;
@@ -229,8 +187,10 @@ pub struct ChatView {
 
 
     /// Used for detecting when scrolling view
     /// Used for detecting when scrolling view
     mouse_pos: SyncMutex<Point>,
     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,
     touch_is_active: AtomicBool,
 
 
     rect: PropertyRect,
     rect: PropertyRect,
@@ -279,9 +239,6 @@ pub struct ChatView {
     /// Re-arm counter so a stale dismiss task won't clear a newer toast.
     /// Re-arm counter so a stale dismiss task won't clear a newer toast.
     toast_version: AtomicU32,
     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.
     /// Weak self-reference so handlers can spawn detached tasks.
     me: Weak<Self>,
     me: Weak<Self>,
     ex: ExecutorPtr,
     ex: ExecutorPtr,
@@ -381,7 +338,8 @@ impl ChatView {
             dc_key: OsRng.gen(),
             dc_key: OsRng.gen(),
 
 
             mouse_pos: SyncMutex::new(Point::from([0., 0.])),
             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),
             touch_is_active: AtomicBool::new(false),
 
 
             rect,
             rect,
@@ -416,7 +374,6 @@ impl ChatView {
             url_copy_duration,
             url_copy_duration,
             link_toast: SyncMutex::new(None),
             link_toast: SyncMutex::new(None),
             toast_version: AtomicU32::new(0),
             toast_version: AtomicU32::new(0),
-            touch_hold_version: AtomicU32::new(0),
             me: me.clone(),
             me: me.clone(),
             ex,
             ex,
         });
         });
@@ -657,41 +614,6 @@ impl ChatView {
         self.notify_select_changed(has, had).await;
         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(
     async fn add_line_to_db(
         &self,
         &self,
         timest: Timestamp,
         timest: Timestamp,
@@ -1069,51 +991,54 @@ impl ChatView {
         .detach();
         .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();
         let rect = self.rect.get();
 
 
         // URL under the finger takes priority: copy it, don't select.
         // 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 mut msgbuf = self.msgbuf.lock().await;
         let on_url = msgbuf.url_at(&rect, msgbuf_pos.x, msgbuf_pos.y).await;
         let on_url = msgbuf.url_at(&rect, msgbuf_pos.x, msgbuf_pos.y).await;
         drop(msgbuf);
         drop(msgbuf);
 
 
         if let Some(url) = on_url {
         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
             return
         }
         }
 
 
         // Not on a URL: start text selection.
         // 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
         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_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
                     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;
                 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
         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 {
         if phase != TouchPhase::Ended {
             return false
             return false
         }
         }
@@ -1101,7 +1104,12 @@ impl UIObject for Message {
             Self::File(m) => m.handle_mouse_btn_up(btn, mouse_pos).await,
             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 {
         match self {
             Self::Priv(m) => m.handle_touch(phase, touch_pos).await,
             Self::Priv(m) => m.handle_touch(phase, touch_pos).await,
             Self::Date(_) => false,
             Self::Date(_) => false,

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

@@ -138,6 +138,17 @@ impl ActionMode {
         None
         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
     /// Called by the parent layout
     pub fn get_instrs(&self) -> Vec<DrawInstruction> {
     pub fn get_instrs(&self) -> Vec<DrawInstruction> {
         let Some(menu) = &*self.menu.lock() else { return vec![] };
         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/>.
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
  */
 
 
-use super::long_press_timeout;
 use async_trait::async_trait;
 use async_trait::async_trait;
 use atomic_float::AtomicF32;
 use atomic_float::AtomicF32;
 use darkfi::system::msleep;
 use darkfi::system::msleep;
 use darkfi_serial::Decodable;
 use darkfi_serial::Decodable;
 use futures::FutureExt;
 use futures::FutureExt;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::{
 use std::{
@@ -57,14 +56,14 @@ const ACTION_COPY: u32 = 0;
 const ACTION_PASTE: u32 = 1;
 const ACTION_PASTE: u32 = 1;
 const ACTION_SELALL: u32 = 2;
 const ACTION_SELALL: u32 = 2;
 
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 
 mod action;
 mod action;
 mod filter;
 mod filter;
 use filter::{ALLOWED_KEYCODES, DISALLOWED_CHARS};
 use filter::{ALLOWED_KEYCODES, DISALLOWED_CHARS};
 mod behave;
 mod behave;
 pub use behave::BaseEditType;
 pub use behave::BaseEditType;
-use behave::{EditorBehavior, MultiLine, ScrollDir, SingleLine};
+use behave::{EditorBehavior, MultiLine, SingleLine};
 mod repeat;
 mod repeat;
 use repeat::{PressedKey, PressedKeysSmoothRepeat};
 use repeat::{PressedKey, PressedKeysSmoothRepeat};
 
 
@@ -97,66 +96,38 @@ macro_rules! ed {
     };
     };
 }
 }
 
 
+/// What the touch armed at `Down`.
 #[derive(Debug, Clone)]
 #[derive(Debug, Clone)]
-enum TouchStateAction {
+enum TouchArm {
+    /// Nothing armed (no touch, or the action menu consumed the press)
     Inactive,
     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>;
 pub type BaseEditPtr = Arc<BaseEdit>;
@@ -224,7 +195,7 @@ pub struct BaseEdit {
     sel_sender: SyncMutex<Option<async_channel::Sender<Option<(Point, Option<isize>)>>>>,
     sel_sender: SyncMutex<Option<async_channel::Sender<Option<(Point, Option<isize>)>>>>,
     scroll: Arc<AtomicF32>,
     scroll: Arc<AtomicF32>,
 
 
-    touch_info: SyncMutex<TouchInfo>,
+    touch_info: TouchInfo,
     is_phone_select: AtomicBool,
     is_phone_select: AtomicBool,
 
 
     parent_rect: Arc<SyncMutex<Option<Rectangle>>>,
     parent_rect: Arc<SyncMutex<Option<Rectangle>>>,
@@ -403,7 +374,11 @@ impl BaseEdit {
             sel_sender: SyncMutex::new(None),
             sel_sender: SyncMutex::new(None),
             scroll: scroll.clone(),
             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),
             is_phone_select: AtomicBool::new(false),
 
 
             parent_rect,
             parent_rect,
@@ -586,9 +561,7 @@ impl BaseEdit {
             'v' => {
             'v' => {
                 if action_mod {
                 if action_mod {
                     if let Some(txt) = clipboard::get() {
                     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
         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
     /// 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) {
     fn start_touch_select(&self, touch_pos: Point, atom: &mut PropertyAtomicGuard) {
         ed!("start_touch_select({touch_pos:?}) before=[{}]", self.dbg_state());
         ed!("start_touch_select({touch_pos:?}) before=[{}]", self.dbg_state());
@@ -740,8 +724,11 @@ impl BaseEdit {
         self.hide_cursor.store(true, Ordering::Relaxed);
         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 rect = self.rect.get();
         let local_pos = touch_pos - rect.pos();
         let local_pos = touch_pos - rect.pos();
@@ -749,12 +736,14 @@ impl BaseEdit {
         // Grabbing a select handle must keep the action menu visible: the
         // Grabbing a select handle must keep the action menu visible: the
         // user adjusts the selection, then taps Copy/Paste. Check the drag
         // user adjusts the selection, then taps Copy/Paste. Check the drag
         // BEFORE interact(), which consumes the menu.
         // 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
             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) {
         if let Some(action_id) = self.action_mode.interact(local_pos) {
             match action_id {
             match action_id {
                 ACTION_COPY => {
                 ACTION_COPY => {
@@ -764,9 +753,7 @@ impl BaseEdit {
                 }
                 }
                 ACTION_PASTE => {
                 ACTION_PASTE => {
                     if let Some(txt) = clipboard::get() {
                     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 => {
                 ACTION_SELALL => {
@@ -786,14 +773,13 @@ impl BaseEdit {
         }
         }
 
 
         if !rect.contains(touch_pos) {
         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
             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
         true
     }
     }
 
 
@@ -805,9 +791,12 @@ impl BaseEdit {
         endpoints
         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 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);
         self.abs_to_local(&mut touch_pos);
 
 
@@ -828,167 +817,211 @@ impl BaseEdit {
         let is_first = first_dist_sq <= TOUCH_RADIUS_SQ;
         let is_first = first_dist_sq <= TOUCH_RADIUS_SQ;
         let is_last = last_dist_sq <= TOUCH_RADIUS_SQ;
         let is_last = last_dist_sq <= TOUCH_RADIUS_SQ;
 
 
-        let mut side = 0;
-
         if is_first && is_last {
         if is_first && is_last {
             // Are we closer to the first or last?
             // Are we closer to the first or last?
             // Break the tie
             // Break the tie
             if first_dist_sq < last_dist_sq {
             if first_dist_sq < last_dist_sq {
-                side = -1;
+                Some(-1)
             } else {
             } else {
-                side = 1;
+                Some(1)
             }
             }
         } else if is_first {
         } else if is_first {
-            side = -1;
+            Some(-1)
         } else if is_last {
         } 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 {
                 } 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;
                 let mut scroll = scroll_start + travel_dist;
                 scroll = scroll.clamp(0., self.behave.max_scroll());
                 scroll = scroll.clamp(0., self.behave.max_scroll());
                 if (self.scroll.load(Ordering::Relaxed) - scroll).abs() < VERT_SCROLL_UPDATE_INC {
                 if (self.scroll.load(Ordering::Relaxed) - scroll).abs() < VERT_SCROLL_UPDATE_INC {
-                    return true
+                    return
                 }
                 }
                 self.scroll.store(scroll, Ordering::Release);
                 self.scroll.store(scroll, Ordering::Release);
                 self.redraw.trigger();
                 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
         // Stop any selection scrolling
         let scroll_sender = self.sel_sender.lock().clone().unwrap();
         let scroll_sender = self.sel_sender.lock().clone().unwrap();
         scroll_sender.try_send(None).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 {
         if need_focus {
             let node = self.node();
             let node = self.node();
@@ -1357,8 +1390,7 @@ impl BaseEdit {
         };
         };
 
 
         let atom = &mut self_.redraw.make_guard(gfxtag!("BaseEdit::process_insert_text_method"));
         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());
         ed!("insert_text method: inserted {text:?} after=[{}]", self_.dbg_state());
         true
         true
     }
     }
@@ -1414,6 +1446,29 @@ impl BaseEdit {
         true
         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")]
     #[cfg(target_os = "android")]
     fn handle_android_event(&self, state: AndroidTextInputState) {
     fn handle_android_event(&self, state: AndroidTextInputState) {
         if !self.is_active.get() {
         if !self.is_active.get() {
@@ -1428,10 +1483,7 @@ impl BaseEdit {
         // selection and finish phone-select mode, making the handles
         // selection and finish phone-select mode, making the handles
         // vanish. Drop it: the next drag update re-asserts the real
         // vanish. Drop it: the next drag update re-asserts the real
         // selection.
         // 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 {
         if drag_in_progress && state.select.0 == state.select.1 {
             ed!("handle_android_event: DROP collapsed IME echo mid-drag state={state:?}");
             ed!("handle_android_event: DROP collapsed IME echo mid-drag state={state:?}");
             return
             return
@@ -1547,6 +1599,11 @@ impl UIObject for BaseEdit {
         let unfocus_task =
         let unfocus_task =
             ex.spawn(async move { while Self::process_unfocus_method(&me2, &method_sub).await {} });
             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());
         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);
         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);
         tasks.append(&mut on_modify.tasks);
 
 
         #[cfg(target_os = "android")]
         #[cfg(target_os = "android")]
@@ -1746,11 +1803,8 @@ impl UIObject for BaseEdit {
 
 
         t!("Key {:?} has {} actions", key, actions);
         t!("Key {:?} has {} actions", key, actions);
         let key_str = key.to_string().repeat(actions as usize);
         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();
         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());
         ed!("handle_char: inserted {key_str:?} after=[{}]", self.dbg_state());
         true
         true
     }
     }
@@ -1891,9 +1945,7 @@ impl UIObject for BaseEdit {
                 }
                 }
                 ACTION_PASTE => {
                 ACTION_PASTE => {
                     if let Some(txt) = clipboard::get() {
                     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 => {
                 ACTION_SELALL => {
@@ -1963,40 +2015,56 @@ impl UIObject for BaseEdit {
         true
         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() {
         if !self.is_active.get() {
             return false
             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() {
         if !self.is_active.get() {
             return false
             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())
         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 async_trait::async_trait;
 use darkfi_serial::Encodable;
 use darkfi_serial::Encodable;
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::sync::{
 use std::sync::{
@@ -36,7 +36,7 @@ use crate::{
     ExecutorPtr,
     ExecutorPtr,
 };
 };
 
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 
 mod default;
 mod default;
 use default::DEFAULT_EMOJI_LIST;
 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! d { ($($arg:tt)*) => { debug!(target: "ui::emoji_picker", $($arg)*) } }
 macro_rules! t { ($($arg:tt)*) => { trace!(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 type EmojiPickerPtr = Arc<EmojiPicker>;
 
 
 pub struct EmojiPicker {
 pub struct EmojiPicker {
@@ -79,7 +72,9 @@ pub struct EmojiPicker {
     /// are evicted automatically.
     /// are evicted automatically.
     draw_cache: EpochCache<Vec<DrawInstruction>>,
     draw_cache: EpochCache<Vec<DrawInstruction>>,
     is_mouse_hover: AtomicBool,
     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 {
 impl EmojiPicker {
@@ -120,7 +115,7 @@ impl EmojiPicker {
             redraw,
             redraw,
             draw_cache,
             draw_cache,
             is_mouse_hover: AtomicBool::new(false),
             is_mouse_hover: AtomicBool::new(false),
-            touch_info: SyncMutex::new(None),
+            drag_state: SyncMutex::new(None),
         });
         });
 
 
         Pimpl::EmojiPicker(self_)
         Pimpl::EmojiPicker(self_)
@@ -368,70 +363,43 @@ impl UIObject for EmojiPicker {
         true
         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 async_trait::async_trait;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::sync::Arc;
 use std::sync::Arc;
@@ -34,8 +34,8 @@ use crate::{
 };
 };
 
 
 use super::{
 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)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui:layer", $($arg)*); } }
@@ -283,31 +283,51 @@ impl UIObject for Layer {
         }
         }
         false
         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() {
         if !self.is_visible.get() {
             return false
             return false
         }
         }
-        touch_pos -= self.rect.get().pos();
+
+        let local = pos - self.rect.get().pos();
         for child in self.get_children() {
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
             let obj = get_ui_object3(&child);
-            if obj.handle_touch(phase, id, touch_pos).await {
+            if obj.gesture_hit_test(local) {
                 return true
                 return true
             }
             }
         }
         }
+
         false
         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() {
         if !self.is_visible.get() {
             return false
             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() {
         for child in self.get_children() {
             let obj = get_ui_object3(&child);
             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
                 return true
             }
             }
         }
         }
+
         false
         false
     }
     }
 
 

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

@@ -21,11 +21,11 @@ use async_trait::async_trait;
 use atomic_float::AtomicF32;
 use atomic_float::AtomicF32;
 use darkfi::system::CondVar;
 use darkfi::system::CondVar;
 use darkfi_serial::{serialize, Decodable};
 use darkfi_serial::{serialize, Decodable};
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::{
 use std::{
-    collections::{HashMap, HashSet, VecDeque},
+    collections::HashSet,
     io::Read,
     io::Read,
     sync::{
     sync::{
         atomic::{AtomicBool, Ordering},
         atomic::{AtomicBool, Ordering},
@@ -44,7 +44,7 @@ use crate::{
     text, ExecutorPtr,
     text, ExecutorPtr,
 };
 };
 
 
-use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureAction, GestureSet, OnModify, RedrawTrigger, UIObject};
 
 
 mod shape;
 mod shape;
 
 
@@ -61,16 +61,6 @@ const MENU_ICON_OFFSET: f32 = 24.;
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::menu", $($arg)*); } }
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::menu", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(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)]
 #[derive(Clone)]
 struct MouseClickInfo {
 struct MouseClickInfo {
     start_pos: Point,
     start_pos: Point,
@@ -83,34 +73,6 @@ struct DragInfo {
     insert_idx: usize,
     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 type MenuPtr = Arc<Menu>;
 
 
 pub struct Menu {
 pub struct Menu {
@@ -147,10 +109,12 @@ pub struct Menu {
     window_scale: PropertyFloat32,
     window_scale: PropertyFloat32,
 
 
     mouse_pos: SyncMutex<Point>,
     mouse_pos: SyncMutex<Point>,
-    touch_info: SyncMutex<Option<TouchInfo>>,
     mouse_click_info: SyncMutex<Option<MouseClickInfo>>,
     mouse_click_info: SyncMutex<Option<MouseClickInfo>>,
     drag_info: SyncMutex<Option<DragInfo>>,
     drag_info: SyncMutex<Option<DragInfo>>,
     long_press_task: SyncMutex<Option<smol::Task<()>>>,
     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_start_accel: PropertyFloat32,
     scroll_resist: PropertyFloat32,
     scroll_resist: PropertyFloat32,
     overscroll: PropertyFloat32,
     overscroll: PropertyFloat32,
@@ -239,10 +203,10 @@ impl Menu {
             fade_zone,
             fade_zone,
             window_scale,
             window_scale,
             mouse_pos: SyncMutex::new(Point::new(0., 0.)),
             mouse_pos: SyncMutex::new(Point::new(0., 0.)),
-            touch_info: SyncMutex::new(None),
             mouse_click_info: SyncMutex::new(None),
             mouse_click_info: SyncMutex::new(None),
             drag_info: SyncMutex::new(None),
             drag_info: SyncMutex::new(None),
             long_press_task: SyncMutex::new(None),
             long_press_task: SyncMutex::new(None),
+            drag_state: SyncMutex::new(None),
             scroll_start_accel,
             scroll_start_accel,
             scroll_resist,
             scroll_resist,
             overscroll,
             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
     /// Cancels edit mode changes, reverting any modifications made during edit mode
     async fn process_cancel_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
     async fn process_cancel_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
         let Ok(method_call) = sub.receive().await else {
         let Ok(method_call) = sub.receive().await else {
@@ -1018,30 +970,29 @@ impl UIObject for Menu {
         false
         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 font_size = self.font_size.get();
                     let hammy_half_size = font_size * 2.0;
                     let hammy_half_size = font_size * 2.0;
                     let hammy_center = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
                     let hammy_center = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
                     let hammy_min = hammy_center - hammy_half_size;
                     let hammy_min = hammy_center - hammy_half_size;
                     let hammy_max = 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() =
                             *self.drag_info.lock() =
                                 Some(DragInfo { item_idx, insert_idx: item_idx });
                                 Some(DragInfo { item_idx, insert_idx: item_idx });
                             info!(target: "app::menu", "Dragging item: {}", 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
                 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 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 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.scrollview(scroll);
                 self.redraw.trigger();
                 self.redraw.trigger();
                 true
                 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();
                 let drag = self.drag_info.lock().take();
                 if let Some(drag_info) = drag {
                 if let Some(drag_info) = drag {
                     if drag_info.item_idx != drag_info.insert_idx {
                     if drag_info.item_idx != drag_info.insert_idx {
@@ -1167,22 +1066,29 @@ impl UIObject for Menu {
                     return true
                     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
                 true
             }
             }
         }
         }

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

@@ -18,7 +18,7 @@
 
 
 use async_trait::async_trait;
 use async_trait::async_trait;
 use futures::stream::{FuturesUnordered, StreamExt};
 use futures::stream::{FuturesUnordered, StreamExt};
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use std::sync::{Arc, OnceLock, Weak};
 use std::sync::{Arc, OnceLock, Weak};
 
 
 use crate::{
 use crate::{
@@ -57,8 +57,15 @@ mod edit;
 pub use edit::{BaseEdit, BaseEditPtr, BaseEditType};
 pub use edit::{BaseEdit, BaseEditPtr, BaseEditType};
 pub mod emoji_picker;
 pub mod emoji_picker;
 pub use emoji_picker::{EmojiPicker, EmojiPickerPtr};
 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;
 mod image;
 #[allow(unused_imports)]
 #[allow(unused_imports)]
 pub use image::{Image, ImagePtr};
 pub use image::{Image, ImagePtr};
@@ -83,7 +90,7 @@ pub use text::{Text, TextPtr};
 mod text_scramble;
 mod text_scramble;
 pub use text_scramble::{TextScramble, TextScramblePtr};
 pub use text_scramble::{TextScramble, TextScramblePtr};
 mod win;
 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! e { ($($arg:tt)*) => { error!(target: "scene::on_modify", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(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 {
     async fn handle_mouse_wheel(&self, _wheel_pos: Point) -> bool {
         false
         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
         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
         false
     }
     }
 
 
@@ -327,7 +344,6 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::Button(obj) => obj.clone(),
         Pimpl::Button(obj) => obj.clone(),
         Pimpl::EmojiPicker(obj) => obj.clone(),
         Pimpl::EmojiPicker(obj) => obj.clone(),
         Pimpl::Shortcut(obj) => obj.clone(),
         Pimpl::Shortcut(obj) => obj.clone(),
-        Pimpl::Gesture(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
         Pimpl::TokenTable(obj) => obj.clone(),
         Pimpl::TokenTable(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
         _ => 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::Button(obj) => obj.as_ref(),
         Pimpl::EmojiPicker(obj) => obj.as_ref(),
         Pimpl::EmojiPicker(obj) => obj.as_ref(),
         Pimpl::Shortcut(obj) => obj.as_ref(),
         Pimpl::Shortcut(obj) => obj.as_ref(),
-        Pimpl::Gesture(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
         Pimpl::TokenTable(obj) => obj.as_ref(),
         Pimpl::TokenTable(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
         _ => 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 async_trait::async_trait;
-use miniquad::{KeyCode, KeyMods, MouseButton, TouchPhase};
+use miniquad::{KeyCode, KeyMods, MouseButton};
 use std::sync::Arc;
 use std::sync::Arc;
 
 
 use crate::{
 use crate::{
@@ -28,7 +28,7 @@ use crate::{
     ExecutorPtr,
     ExecutorPtr,
 };
 };
 
 
-use super::{DrawUpdate, Layer, LayerPtr, RedrawTrigger, UIObject};
+use super::{DrawUpdate, GestureTarget, Layer, LayerPtr, RedrawTrigger, UIObject};
 
 
 pub type ScrollLayerPtr = Arc<ScrollLayer>;
 pub type ScrollLayerPtr = Arc<ScrollLayer>;
 
 
@@ -106,12 +106,16 @@ impl UIObject for ScrollLayer {
         self.inner.handle_mouse_wheel(wheel_pos).await
         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) {
     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 async_trait::async_trait;
 use darkfi_money_contract::model::{TokenId, DARK_TOKEN_ID};
 use darkfi_money_contract::model::{TokenId, DARK_TOKEN_ID};
 use darkfi_serial::{Decodable, Encodable, SerialEncodable};
 use darkfi_serial::{Decodable, Encodable, SerialEncodable};
-use miniquad::{MouseButton, TouchPhase};
+use miniquad::MouseButton;
 use parking_lot::Mutex as SyncMutex;
 use parking_lot::Mutex as SyncMutex;
 use rand::{rngs::OsRng, Rng};
 use rand::{rngs::OsRng, Rng};
 use std::sync::{Arc, Weak};
 use std::sync::{Arc, Weak};
@@ -36,7 +36,7 @@ use crate::{
     ExecutorPtr,
     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! d { ($($arg:tt)*) => { debug!(target: "ui::tokentable", $($arg)*); } }
 macro_rules! t { ($($arg:tt)*) => { trace!(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> {
     fn get_meshes(&self, rect: &Rectangle) -> Vec<DrawInstruction> {
         let rows = self.rows.lock();
         let rows = self.rows.lock();
         let font_size = self.font_size.get();
         let font_size = self.font_size.get();
@@ -405,36 +417,31 @@ impl UIObject for TokenTable {
             return false
             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
         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())
         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::{
     gfx::{
         gfxtag, DrawCall, DrawInstruction, GraphicsEventCharSub, GraphicsEventKeyDownSub,
         gfxtag, DrawCall, DrawInstruction, GraphicsEventCharSub, GraphicsEventKeyDownSub,
         GraphicsEventKeyUpSub, GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub,
         GraphicsEventKeyUpSub, GraphicsEventMouseButtonDownSub, GraphicsEventMouseButtonUpSub,
-        GraphicsEventMouseMoveSub, GraphicsEventMouseWheelSub, GraphicsEventPublisherPtr,
-        GraphicsEventTouchSub, Point, Rectangle, RenderApi, Renderer,
+        GraphicsEventMouseMoveSub, GraphicsEventMouseWheelSub, GraphicsEventPublisherPtr, Point,
+        Rectangle, RenderApi, Renderer,
     },
     },
     prop::{
     prop::{
         BatchGuardPtr, PropertyAtomicGuard, PropertyDimension, PropertyFloat32, PropertyStr, Role,
         BatchGuardPtr, PropertyAtomicGuard, PropertyDimension, PropertyFloat32, PropertyStr, Role,
@@ -40,10 +40,10 @@ use crate::{
 #[cfg(target_os = "android")]
 #[cfg(target_os = "android")]
 use crate::{android, prop::PropertyRect};
 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! i { ($($arg:tt)*) => { info!(target: "ui::window", $($arg)*); } }
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::window", $($arg)*); } }
 macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::window", $($arg)*); } }
@@ -68,8 +68,8 @@ pub struct Window {
     scale: PropertyFloat32,
     scale: PropertyFloat32,
     #[cfg(target_os = "android")]
     #[cfg(target_os = "android")]
     insets: PropertyRect,
     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.
     /// Sender side used by window-internal triggers to request a draw pass.
     redraw_tx: RedrawTrigger,
     redraw_tx: RedrawTrigger,
     /// Receiver consumed by the single draw-pass listener task in `start()`.
     /// Receiver consumed by the single draw-pass listener task in `start()`.
@@ -81,6 +81,7 @@ impl Window {
         node: SceneNodeWeak,
         node: SceneNodeWeak,
         renderer: Renderer,
         renderer: Renderer,
         i18n_fish: I18nBabelFish,
         i18n_fish: I18nBabelFish,
+        ex: ExecutorPtr,
         redraw_tx: RedrawTrigger,
         redraw_tx: RedrawTrigger,
         redraw_rx: async_channel::Receiver<()>,
         redraw_rx: async_channel::Receiver<()>,
     ) -> Pimpl {
     ) -> Pimpl {
@@ -89,6 +90,8 @@ impl Window {
         let screen_size = PropertyDimension::wrap(node_ref, Role::Internal, "screen_size").unwrap();
         let screen_size = PropertyDimension::wrap(node_ref, Role::Internal, "screen_size").unwrap();
         let scale = PropertyFloat32::wrap(node_ref, Role::Internal, "scale", 0).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 {
         let self_ = Arc::new(Self {
             node,
             node,
             renderer,
             renderer,
@@ -100,7 +103,7 @@ impl Window {
             scale,
             scale,
             #[cfg(target_os = "android")]
             #[cfg(target_os = "android")]
             insets: PropertyRect::wrap(node_ref, Role::Internal, "insets").unwrap(),
             insets: PropertyRect::wrap(node_ref, Role::Internal, "insets").unwrap(),
-            gesture_proc: SyncMutex::new(GestureProcessor::new()),
+            gesture_session,
             redraw_tx,
             redraw_tx,
             redraw_rx,
             redraw_rx,
         });
         });
@@ -214,10 +217,6 @@ impl Window {
         let mouse_wheel_task =
         let mouse_wheel_task =
             ex.spawn(async move { while Self::process_mouse_wheel(&me2, &ev_sub).await {} });
             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")]
         #[cfg(target_os = "android")]
         let insets_task = {
         let insets_task = {
             let (insets_tx, insets_rx) = async_channel::unbounded();
             let (insets_tx, insets_rx) = async_channel::unbounded();
@@ -264,7 +263,6 @@ impl Window {
             mouse_btn_up_task,
             mouse_btn_up_task,
             mouse_move_task,
             mouse_move_task,
             mouse_wheel_task,
             mouse_wheel_task,
-            touch_task,
         ];
         ];
         tasks.append(&mut on_modify.tasks);
         tasks.append(&mut on_modify.tasks);
         #[cfg(target_os = "android")]
         #[cfg(target_os = "android")]
@@ -399,21 +397,6 @@ impl Window {
         true
         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> {
     fn get_children(&self) -> Vec<SceneNodePtr> {
         let node = self.node.upgrade().unwrap();
         let node = self.node.upgrade().unwrap();
         get_children_ordered(&node)
         get_children_ordered(&node)
@@ -453,14 +436,18 @@ impl Window {
     }
     }
 
 
     async fn handle_mouse_btn_down(&self, btn: MouseButton, mut mouse_pos: Point) {
     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);
         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 {
                 if obj.handle_mouse_btn_down(btn.clone(), mouse_pos).await {
                     return
                     return
                 }
                 }
@@ -469,14 +456,15 @@ impl Window {
     }
     }
 
 
     async fn handle_mouse_btn_up(&self, btn: MouseButton, mut mouse_pos: Point) {
     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);
         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 {
                 if obj.handle_mouse_btn_up(btn.clone(), mouse_pos).await {
                     return
                     return
                 }
                 }
@@ -485,14 +473,15 @@ impl Window {
     }
     }
 
 
     async fn handle_mouse_move(&self, mut mouse_pos: Point) {
     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);
         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 {
                 if obj.handle_mouse_move(mouse_pos).await {
                     return
                     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);
         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")]
     #[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
 clones instructions for every message from newest to the viewport top
 every frame; mesh caches are only cleared on rect/scale/epoch change
 every frame; mesh caches are only cleared on rect/scale/epoch change
 (never on scroll); `chat::make()` builds a full screen per channel and
 (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`
 Scene/property system facts the design relies on: `Property*::wrap`
 returns a live handle to a property object on a node (cross-node wrapping
 returns a live handle to a property object on a node (cross-node wrapping
 already exists — `window_scale` from `/window`); nodes track parents;
 already exists — `window_scale` from `/window`); nodes track parents;
 nodes carry signals (`register`/`trigger`), method-call subscriptions,
 nodes carry signals (`register`/`trigger`), method-call subscriptions,
 `OnModify`, and task lists; `Pimpl` UIObjects implement `draw` and
 `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
 ## Goals / Non-Goals
 
 
@@ -105,9 +117,12 @@ impl ChatView2 {
 // exposes insert_line/insert_unconf_line/confirm plus url/nick
 // exposes insert_line/insert_unconf_line/confirm plus url/nick
 // interaction signals; filemsg exposes set_file_status plus its file
 // interaction signals; filemsg exposes set_file_status plus its file
 // signals.
 // 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:
 `buffer.rs` — ordering + geometry, no rendering:
@@ -180,7 +195,10 @@ pub struct ScrollController {
 pub struct Anchor { pub msg: Option<MessageId>, pub dy: f32 }
 pub struct Anchor { pub msg: Option<MessageId>, pub dy: f32 }
 
 
 impl ScrollController {
 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_start(&mut self, y: f32)
     pub fn drag_move(&mut self, y: f32) -> f32
     pub fn drag_move(&mut self, y: f32) -> f32
     pub fn drag_end(&mut self, velocity: 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() };
         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) {
 pub fn drag_end(&mut self, velocity: f32) {
     self.state = ScrollState::Glide { velocity };
     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
 is persisted by the call itself; persistence is the chatview storing
 the snapshot in its per-channel state map on exit.
 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
 ### Reflow: width, scale, and styling changes
 
 
 Wrapping depends on viewport width, so any width change (window
 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
 (`<nick> text`, action/notice variants as displayed); filemsg
 contributes its file URL; datemsg contributes its date label; future
 contributes its file URL; datemsg contributes its date label; future
 types decide for themselves (a type MAY contribute nothing). Selection
 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
 ### 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
 channel label and relay paths in `main.rs` retarget the single chatview
 via `set_channel`. Unread highlighting in the menu keys off
 via `set_channel`. Unread highlighting in the menu keys off
 message-received events carrying the channel instead of per-screen
 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
 ## Risks / Trade-offs
 
 
@@ -1111,6 +1183,11 @@ layers.
 - [Two chatviews during migration window (old screens + chatview2)] →
 - [Two chatviews during migration window (old screens + chatview2)] →
   switchover is a single cutover task in the sequence; old module deleted
   switchover is a single cutover task in the sequence; old module deleted
   in the same change once parity tests pass.
   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
 ## Migration Plan
 
 

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

@@ -35,9 +35,12 @@ serving as the functional spec (feature parity, no regressions).
   flag.
   flag.
 - Scrolling: pixel scroll from bottom (scroll=0 is always the bottom),
 - Scrolling: pixel scroll from bottom (scroll=0 is always the bottom),
   1:1 finger drag, animated half-page mouse-wheel jumps, flick inertia as
   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
 - Performance: visible-range lookup, total height, and position queries
   are O(log n) in buffer size; only visible messages (soft window + LRU
   are O(log n) in buffer size; only visible messages (soft window + LRU
   budget) hold render resources; loading is a single async background
   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.
 - Per-channel kv trees: value format gains a type ID tag; keys unchanged.
   Existing history remains readable (v1 privmsg payload is the current
   Existing history remains readable (v1 privmsg payload is the current
   `nick, text` encoding plus a confirmed flag).
   `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`,
 - Build/test via `bin/app` Makefile targets (`make compile-dev`,
   `compile-apk`); no changes outside `bin/app`, no new dependencies.
   `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
 ### 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
 #### Scenario: Finger tracking is pixel-exact
 
 
 - **WHEN** the finger moves up by N pixels during a drag
 - **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
 #### 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
 - **THEN** the animation/glide stops immediately and the drag takes over
 
 
 ### Requirement: Animated page scrolling for wheel and keys
 ### Requirement: Animated page scrolling for wheel and keys
@@ -153,8 +164,11 @@ retarget/coalesce the animation rather than accumulate velocity.
 ### Requirement: Flick inertia
 ### Requirement: Flick inertia
 
 
 Releasing a drag with sufficient velocity SHALL produce an inertial glide
 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
 #### 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
 - **THEN** the content glides in the same direction, decaying, and comes
   to rest at or within the valid scroll range
   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
 ### Requirement: Scroll compensation for height changes
 
 
 When a message below the viewport bottom changes height, the scroll
 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
 ### 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
 SHALL copy the selected messages' text in display order joined by
 newlines, where each selected message contributes copy text defined by
 newlines, where each selected message contributes copy text defined by
 its type; a type MAY contribute nothing. `unselect` SHALL clear all
 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,
 - **THEN** it becomes selected and shows the selection highlight,
   unlike the current chatview where separators are unselectable
   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
 #### Scenario: Selection transitions signal
 
 
 - **WHEN** the first line becomes selected, or the last selection is
 - **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
 - **WHEN** PageUp is pressed
 - **THEN** the view animates half a page up and the key event is consumed
 - **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
 - [ ] 5.1 Implement `src/ui/chatview2/scroll.rs`: internal
   pixels-from-bottom scroll (no scene property), Idle/Drag/Glide/Anim
   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,
   scroll_to_bottom), clamping, is_at_bottom indication,
   height-change compensation application, anchor snapshot/resolve with
   height-change compensation application, anchor snapshot/resolve with
   bottom shortcut and clamped fallback; verify unit tests pass for
   bottom shortcut and clamped fallback; verify unit tests pass for
   state transitions (grab cancels motion, wheel coalescing extends the
   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
 - [ ] 5.2 Gate: review + amendments + atomic commit
 
 
 ## 6. Chatview2 skeleton + dev schema screen
 ## 6. Chatview2 skeleton + dev schema screen
@@ -66,9 +69,11 @@ design.md → Development Protocol.
   `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
   `ChatView2` UIObject: properties (rect, shared styling, is_at_bottom
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   bool), view-wide method stubs (`set_channel`, `set_filter`,
   `copy_select`, `unselect`, `scroll_to_bottom`, `get_line_ids`,
   `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
 - [ ] 6.2 Create the `src/app/schema/test_chatview.rs` dev schema
   (modeled on `schema/test.rs`) hosting the chatview2 node for
   (modeled on `schema/test.rs`) hosting the chatview2 node for
   development, gated behind a new `schema-test-chatview` cargo feature
   development, gated behind a new `schema-test-chatview` cargo feature
@@ -135,12 +140,14 @@ design.md → Development Protocol.
 ## 11. Selection across types
 ## 11. Selection across types
 
 
 - [ ] 11.1 Implement selection: chatview-owned selected set,
 - [ ] 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
 - [ ] 11.2 Gate: review + amendments + atomic commit
 
 
 ## 12. Date separators
 ## 12. Date separators
@@ -155,15 +162,22 @@ design.md → Development Protocol.
 
 
 ## 13. Scroll input integration
 ## 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
 - [ ] 13.2 Gate: review + amendments + atomic commit
 
 
 ## 14. Materialization lifecycle and eviction
 ## 14. Materialization lifecycle and eviction
@@ -220,7 +234,10 @@ design.md → Development Protocol.
   `scroll_to_bottom`; remove the per-channel screen loop in
   `scroll_to_bottom`; remove the per-channel screen loop in
   `schema/mod.rs`; retarget relay paths in `main.rs` and plugins
   `schema/mod.rs`; retarget relay paths in `main.rs` and plugins
   (darkirc insert/confirm to the privmsg node, fud status fan-out to
   (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
   updates the active channel, switching channels clears/reloads and
   restores each channel's position, and unread indication works via
   restores each channel's position, and unread indication works via
   signals
   signals
@@ -233,8 +250,9 @@ design.md → Development Protocol.
   `make compile-dev` and `make compile-apk` both succeed
   `make compile-dev` and `make compile-apk` both succeed
 - [ ] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
 - [ ] 19.2 Full validation: parity checklist from `specs/chatview/spec.md`
   (URLs, selection/copy incl. separators, file messages, actions/notices,
   (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,
   desktop; performance pass scrolling a large history (no slowdown,
   bounded memory via renderer debug stats); android device smoke test
   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
 - [ ] 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