Skip to main content

ftui_core/
gesture.rs

1#![forbid(unsafe_code)]
2
3//! Gesture recognition: transforms raw terminal events into semantic events.
4//!
5//! [`GestureRecognizer`] is a stateful processor that converts raw [`Event`]
6//! sequences (mouse clicks, key presses, etc.) into high-level [`SemanticEvent`]s
7//! (double-click, drag, chord, etc.).
8//!
9//! # State Machine
10//!
11//! The recognizer tracks several concurrent state machines:
12//!
13//! - **Click detector**: Tracks consecutive clicks at the same position to emit
14//!   `Click`, `DoubleClick`, or `TripleClick`.
15//! - **Drag detector**: Monitors mouse-down → move → mouse-up sequences,
16//!   emitting `DragStart` / `DragMove` / `DragEnd` / `DragCancel`.
17//! - **Long press detector**: Fires when mouse is held stationary beyond a threshold.
18//! - **Chord detector**: Accumulates modifier+key sequences within a timeout window.
19//!
20//! # Invariants
21//!
22//! 1. Drag and Click never both emit for the same mouse-down → mouse-up interaction.
23//!    If a drag starts, the mouse-up produces `DragEnd`, not `Click`.
24//! 2. Click multiplicity is monotonically increasing within a multi-click window:
25//!    `Click` → `DoubleClick` → `TripleClick`.
26//! 3. `Chord` sequences are always non-empty.
27//! 4. After `reset()`, all state machines return to their initial idle state.
28//! 5. `DragCancel` is emitted if Escape is pressed during a drag.
29//!
30//! # Failure Modes
31//!
32//! - If the chord timeout expires mid-sequence, the chord buffer is cleared and
33//!   no `Chord` event is emitted (raw keys are still delivered by the caller).
34//! - If focus is lost during a drag, the caller should call `reset()` which will
35//!   not emit `DragCancel` (the caller handles focus-loss cancellation).
36
37use std::time::{Duration, Instant};
38
39use crate::event::{Event, KeyCode, KeyEventKind, Modifiers, MouseButton, MouseEventKind};
40use crate::semantic_event::{ChordKey, Position, SemanticEvent};
41
42// ---------------------------------------------------------------------------
43// Configuration
44// ---------------------------------------------------------------------------
45
46/// Thresholds and timeouts for gesture recognition.
47#[derive(Debug, Clone)]
48pub struct GestureConfig {
49    /// Time window for double/triple click detection (default: 300ms).
50    pub multi_click_timeout: Duration,
51    /// Duration before a stationary mouse-down triggers long press (default: 500ms).
52    pub long_press_threshold: Duration,
53    /// Minimum manhattan distance (cells) before a drag starts (default: 3).
54    pub drag_threshold: u16,
55    /// Time window for chord key sequence completion (default: 1000ms).
56    pub chord_timeout: Duration,
57    /// Minimum velocity (cells/sec) for swipe detection (default: 50.0).
58    pub swipe_velocity_threshold: f32,
59    /// Position tolerance for multi-click detection (manhattan distance, default: 1).
60    pub click_tolerance: u16,
61}
62
63impl Default for GestureConfig {
64    fn default() -> Self {
65        Self {
66            multi_click_timeout: Duration::from_millis(300),
67            long_press_threshold: Duration::from_millis(500),
68            drag_threshold: 3,
69            chord_timeout: Duration::from_millis(1000),
70            swipe_velocity_threshold: 50.0,
71            click_tolerance: 1,
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Internal state
78// ---------------------------------------------------------------------------
79
80/// Tracks click timing for multi-click detection.
81#[derive(Debug, Clone)]
82struct ClickState {
83    pos: Position,
84    button: MouseButton,
85    time: Instant,
86    count: u8,
87}
88
89/// Tracks an ongoing or potential drag.
90#[derive(Debug, Clone)]
91struct DragTracker {
92    start_pos: Position,
93    button: MouseButton,
94    last_pos: Position,
95    started: bool,
96}
97
98// ---------------------------------------------------------------------------
99// GestureRecognizer
100// ---------------------------------------------------------------------------
101
102/// Stateful gesture recognizer that transforms raw events into semantic events.
103///
104/// Call [`process`](GestureRecognizer::process) for each incoming [`Event`].
105/// Call [`check_long_press`](GestureRecognizer::check_long_press) periodically
106/// (e.g., on tick) to detect long-press gestures.
107pub struct GestureRecognizer {
108    config: GestureConfig,
109
110    // Click tracking
111    last_click: Option<ClickState>,
112
113    // Drag tracking
114    mouse_down: Option<(Position, MouseButton, Instant)>,
115    drag: Option<DragTracker>,
116
117    // Long press tracking
118    long_press_pos: Option<(Position, Instant)>,
119    long_press_fired: bool,
120
121    // Chord tracking
122    chord_buffer: Vec<ChordKey>,
123    chord_start: Option<Instant>,
124}
125
126impl std::fmt::Debug for GestureRecognizer {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("GestureRecognizer")
129            .field("dragging", &self.is_dragging())
130            .field("chord_len", &self.chord_buffer.len())
131            .finish()
132    }
133}
134
135impl GestureRecognizer {
136    /// Create a new gesture recognizer with the given configuration.
137    #[must_use]
138    pub fn new(config: GestureConfig) -> Self {
139        Self {
140            config,
141            last_click: None,
142            mouse_down: None,
143            drag: None,
144            long_press_pos: None,
145            long_press_fired: false,
146            chord_buffer: Vec::with_capacity(4),
147            chord_start: None,
148        }
149    }
150
151    /// Process a raw event, returning any semantic events produced.
152    ///
153    /// Most events produce 0 or 1 semantic events. A mouse-up after a
154    /// multi-click sequence may produce both a `Click` and a `DoubleClick`.
155    pub fn process(&mut self, event: &Event, now: Instant) -> Vec<SemanticEvent> {
156        let mut out = Vec::with_capacity(2);
157
158        // Expire stale chord
159        self.expire_chord(now);
160
161        match event {
162            Event::Mouse(mouse) => {
163                let pos = Position::new(mouse.x, mouse.y);
164                match mouse.kind {
165                    MouseEventKind::Down(button) => {
166                        self.on_mouse_down(pos, button, now, &mut out);
167                    }
168                    MouseEventKind::Up(button) => {
169                        self.on_mouse_up(pos, button, now, &mut out);
170                    }
171                    MouseEventKind::Drag(button) => {
172                        self.on_mouse_drag(pos, button, &mut out);
173                    }
174                    MouseEventKind::Moved => {
175                        // Movement without button cancels long press
176                        self.long_press_pos = None;
177                    }
178                    _ => {}
179                }
180            }
181            Event::Key(key) => {
182                if key.kind != KeyEventKind::Press {
183                    return out;
184                }
185
186                // Escape cancels drag
187                if key.code == KeyCode::Escape {
188                    if let Some(drag) = self.drag.take()
189                        && drag.started
190                    {
191                        out.push(SemanticEvent::DragCancel);
192                    }
193                    self.mouse_down = None;
194                    self.long_press_pos = None;
195                    self.chord_buffer.clear();
196                    self.chord_start = None;
197                    return out;
198                }
199
200                // Chord detection: modifier+key combinations
201                let has_modifier = key
202                    .modifiers
203                    .intersects(Modifiers::CTRL | Modifiers::ALT | Modifiers::SUPER);
204
205                if has_modifier {
206                    let chord_key = ChordKey::new(key.code, key.modifiers);
207                    if self.chord_buffer.is_empty() {
208                        self.chord_start = Some(now);
209                    }
210                    self.chord_buffer.push(chord_key);
211
212                    // Emit chord after each addition (caller can decide if complete).
213                    // A chord of length 1 is just a modified key; length ≥2 is a sequence.
214                    if self.chord_buffer.len() >= 2 {
215                        out.push(SemanticEvent::Chord {
216                            sequence: self.chord_buffer.clone(),
217                        });
218                        self.chord_buffer.clear();
219                        self.chord_start = None;
220                    }
221                } else {
222                    // Non-modifier key clears any in-progress chord
223                    self.chord_buffer.clear();
224                    self.chord_start = None;
225                }
226            }
227            Event::Focus(false) => {
228                // Focus loss: reset drag and long press
229                if let Some(drag) = self.drag.take()
230                    && drag.started
231                {
232                    out.push(SemanticEvent::DragCancel);
233                }
234                self.mouse_down = None;
235                self.long_press_pos = None;
236                self.long_press_fired = false;
237            }
238            _ => {}
239        }
240
241        out
242    }
243
244    /// Check for long press timeout. Call periodically (e.g., on tick).
245    ///
246    /// Returns `Some(LongPress { .. })` if the mouse has been held stationary
247    /// beyond the configured threshold.
248    pub fn check_long_press(&mut self, now: Instant) -> Option<SemanticEvent> {
249        if self.long_press_fired {
250            return None;
251        }
252        if let Some((pos, down_time)) = self.long_press_pos {
253            let elapsed = now.duration_since(down_time);
254            if elapsed >= self.config.long_press_threshold {
255                self.long_press_fired = true;
256                return Some(SemanticEvent::LongPress {
257                    pos,
258                    duration: elapsed,
259                });
260            }
261        }
262        None
263    }
264
265    /// Whether a drag is currently in progress.
266    #[must_use]
267    pub fn is_dragging(&self) -> bool {
268        self.drag.as_ref().is_some_and(|d| d.started)
269    }
270
271    /// Reset all gesture state to initial idle.
272    pub fn reset(&mut self) {
273        self.last_click = None;
274        self.mouse_down = None;
275        self.drag = None;
276        self.long_press_pos = None;
277        self.long_press_fired = false;
278        self.chord_buffer.clear();
279        self.chord_start = None;
280    }
281
282    /// Get a reference to the current configuration.
283    #[must_use]
284    pub fn config(&self) -> &GestureConfig {
285        &self.config
286    }
287
288    /// Update the configuration.
289    pub fn set_config(&mut self, config: GestureConfig) {
290        self.config = config;
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Internal event handlers
296// ---------------------------------------------------------------------------
297
298impl GestureRecognizer {
299    fn on_mouse_down(
300        &mut self,
301        pos: Position,
302        button: MouseButton,
303        now: Instant,
304        _out: &mut Vec<SemanticEvent>,
305    ) {
306        self.mouse_down = Some((pos, button, now));
307        self.drag = Some(DragTracker {
308            start_pos: pos,
309            button,
310            last_pos: pos,
311            started: false,
312        });
313        self.long_press_pos = Some((pos, now));
314        self.long_press_fired = false;
315    }
316
317    fn on_mouse_up(
318        &mut self,
319        pos: Position,
320        button: MouseButton,
321        now: Instant,
322        out: &mut Vec<SemanticEvent>,
323    ) {
324        self.long_press_pos = None;
325        self.long_press_fired = false;
326
327        // If we were dragging, emit DragEnd
328        if let Some(drag) = self.drag.take()
329            && drag.started
330        {
331            out.push(SemanticEvent::DragEnd {
332                start: drag.start_pos,
333                end: pos,
334            });
335            self.mouse_down = None;
336            return;
337        }
338
339        // Not a drag — emit click
340        self.mouse_down = None;
341
342        // Multi-click detection
343        let click_count = if let Some(ref last) = self.last_click {
344            if last.button == button
345                && last.pos.manhattan_distance(pos) <= u32::from(self.config.click_tolerance)
346                && now.duration_since(last.time) <= self.config.multi_click_timeout
347                && last.count < 3
348            {
349                last.count + 1
350            } else {
351                1
352            }
353        } else {
354            1
355        };
356
357        self.last_click = Some(ClickState {
358            pos,
359            button,
360            time: now,
361            count: click_count,
362        });
363
364        match click_count {
365            1 => out.push(SemanticEvent::Click { pos, button }),
366            2 => out.push(SemanticEvent::DoubleClick { pos, button }),
367            3 => out.push(SemanticEvent::TripleClick { pos, button }),
368            _ => out.push(SemanticEvent::Click { pos, button }),
369        }
370    }
371
372    fn on_mouse_drag(&mut self, pos: Position, button: MouseButton, out: &mut Vec<SemanticEvent>) {
373        // Cancel long press on any movement
374        self.long_press_pos = None;
375
376        let Some(ref mut drag) = self.drag else {
377            // Drag without prior mouse-down: create tracker
378            self.drag = Some(DragTracker {
379                start_pos: pos,
380                button,
381                last_pos: pos,
382                started: false,
383            });
384            return;
385        };
386
387        if !drag.started {
388            // Check if we've moved past the drag threshold
389            let distance = drag.start_pos.manhattan_distance(pos);
390            if distance >= u32::from(self.config.drag_threshold) {
391                drag.started = true;
392                out.push(SemanticEvent::DragStart {
393                    pos: drag.start_pos,
394                    button: drag.button,
395                });
396            }
397        }
398
399        if drag.started {
400            let delta = (
401                pos.x as i16 - drag.last_pos.x as i16,
402                pos.y as i16 - drag.last_pos.y as i16,
403            );
404            out.push(SemanticEvent::DragMove {
405                start: drag.start_pos,
406                current: pos,
407                delta,
408            });
409        }
410
411        drag.last_pos = pos;
412    }
413
414    fn expire_chord(&mut self, now: Instant) {
415        if let Some(start) = self.chord_start
416            && now.duration_since(start) > self.config.chord_timeout
417        {
418            self.chord_buffer.clear();
419            self.chord_start = None;
420        }
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Tests
426// ---------------------------------------------------------------------------
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::event::{KeyEvent, MouseEvent};
432
433    fn now() -> Instant {
434        Instant::now()
435    }
436
437    fn mouse_down(x: u16, y: u16, button: MouseButton) -> Event {
438        Event::Mouse(MouseEvent {
439            kind: MouseEventKind::Down(button),
440            x,
441            y,
442            modifiers: Modifiers::NONE,
443        })
444    }
445
446    fn mouse_up(x: u16, y: u16, button: MouseButton) -> Event {
447        Event::Mouse(MouseEvent {
448            kind: MouseEventKind::Up(button),
449            x,
450            y,
451            modifiers: Modifiers::NONE,
452        })
453    }
454
455    fn mouse_drag(x: u16, y: u16, button: MouseButton) -> Event {
456        Event::Mouse(MouseEvent {
457            kind: MouseEventKind::Drag(button),
458            x,
459            y,
460            modifiers: Modifiers::NONE,
461        })
462    }
463
464    fn key_press(code: KeyCode, modifiers: Modifiers) -> Event {
465        Event::Key(KeyEvent {
466            code,
467            modifiers,
468            kind: KeyEventKind::Press,
469        })
470    }
471
472    fn esc() -> Event {
473        key_press(KeyCode::Escape, Modifiers::NONE)
474    }
475
476    const MS_50: Duration = Duration::from_millis(50);
477    const MS_100: Duration = Duration::from_millis(100);
478    const MS_200: Duration = Duration::from_millis(200);
479    const MS_500: Duration = Duration::from_millis(500);
480    const MS_600: Duration = Duration::from_millis(600);
481
482    // --- Click tests ---
483
484    #[test]
485    fn single_click() {
486        let mut gr = GestureRecognizer::new(GestureConfig::default());
487        let t = now();
488
489        let events = gr.process(&mouse_down(5, 5, MouseButton::Left), t);
490        assert!(events.is_empty());
491
492        let events = gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
493        assert_eq!(events.len(), 1);
494        assert!(matches!(
495            events[0],
496            SemanticEvent::Click {
497                pos: Position { x: 5, y: 5 },
498                button: MouseButton::Left,
499            }
500        ));
501    }
502
503    #[test]
504    fn double_click() {
505        let mut gr = GestureRecognizer::new(GestureConfig::default());
506        let t = now();
507
508        // First click
509        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
510        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
511
512        // Second click within timeout
513        gr.process(&mouse_down(5, 5, MouseButton::Left), t + MS_100);
514        let events = gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_200);
515
516        assert_eq!(events.len(), 1);
517        assert!(matches!(events[0], SemanticEvent::DoubleClick { .. }));
518    }
519
520    #[test]
521    fn triple_click() {
522        let mut gr = GestureRecognizer::new(GestureConfig::default());
523        let t = now();
524
525        // First click
526        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
527        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
528
529        // Second click
530        gr.process(&mouse_down(5, 5, MouseButton::Left), t + MS_100);
531        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_200);
532
533        // Third click
534        gr.process(
535            &mouse_down(5, 5, MouseButton::Left),
536            t + Duration::from_millis(250),
537        );
538        let events = gr.process(
539            &mouse_up(5, 5, MouseButton::Left),
540            t + Duration::from_millis(280),
541        );
542
543        assert_eq!(events.len(), 1);
544        assert!(matches!(events[0], SemanticEvent::TripleClick { .. }));
545    }
546
547    #[test]
548    fn double_click_timeout_resets_to_single() {
549        let mut gr = GestureRecognizer::new(GestureConfig::default());
550        let t = now();
551
552        // First click
553        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
554        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
555
556        // Second click AFTER timeout (>300ms)
557        gr.process(&mouse_down(5, 5, MouseButton::Left), t + MS_500);
558        let events = gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_600);
559
560        assert_eq!(events.len(), 1);
561        assert!(matches!(events[0], SemanticEvent::Click { .. }));
562    }
563
564    #[test]
565    fn different_position_resets_click_count() {
566        let mut gr = GestureRecognizer::new(GestureConfig::default());
567        let t = now();
568
569        // First click at (5, 5)
570        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
571        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
572
573        // Second click at (20, 20) — different position
574        gr.process(&mouse_down(20, 20, MouseButton::Left), t + MS_100);
575        let events = gr.process(&mouse_up(20, 20, MouseButton::Left), t + MS_200);
576
577        assert_eq!(events.len(), 1);
578        assert!(matches!(events[0], SemanticEvent::Click { .. }));
579    }
580
581    #[test]
582    fn different_button_resets_click_count() {
583        let mut gr = GestureRecognizer::new(GestureConfig::default());
584        let t = now();
585
586        // Left click
587        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
588        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
589
590        // Right click at same position
591        gr.process(&mouse_down(5, 5, MouseButton::Right), t + MS_100);
592        let events = gr.process(&mouse_up(5, 5, MouseButton::Right), t + MS_200);
593
594        assert_eq!(events.len(), 1);
595        assert!(matches!(
596            events[0],
597            SemanticEvent::Click {
598                button: MouseButton::Right,
599                ..
600            }
601        ));
602    }
603
604    #[test]
605    fn click_position_tolerance() {
606        let mut gr = GestureRecognizer::new(GestureConfig::default());
607        let t = now();
608
609        // First click at (5, 5)
610        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
611        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
612
613        // Second click at (6, 5) — within tolerance of 1 cell
614        gr.process(&mouse_down(6, 5, MouseButton::Left), t + MS_100);
615        let events = gr.process(&mouse_up(6, 5, MouseButton::Left), t + MS_200);
616
617        assert_eq!(events.len(), 1);
618        assert!(matches!(events[0], SemanticEvent::DoubleClick { .. }));
619    }
620
621    // --- Drag tests ---
622
623    #[test]
624    fn drag_starts_after_threshold() {
625        let mut gr = GestureRecognizer::new(GestureConfig::default());
626        let t = now();
627
628        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
629
630        // Small move — below threshold (manhattan distance 1 < 3)
631        let events = gr.process(&mouse_drag(6, 5, MouseButton::Left), t + MS_50);
632        assert!(events.is_empty());
633        assert!(!gr.is_dragging());
634
635        // Move beyond threshold (manhattan distance 5 >= 3)
636        let events = gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_100);
637        assert!(!events.is_empty());
638        assert!(
639            events
640                .iter()
641                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
642        );
643        assert!(gr.is_dragging());
644    }
645
646    #[test]
647    fn drag_move_has_correct_delta() {
648        let mut gr = GestureRecognizer::new(GestureConfig::default());
649        let t = now();
650
651        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
652        // Move past threshold
653        let events = gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
654
655        // Find the DragMove event
656        let drag_move = events
657            .iter()
658            .find(|e| matches!(e, SemanticEvent::DragMove { .. }));
659        assert!(drag_move.is_some());
660        if let Some(SemanticEvent::DragMove {
661            start,
662            current,
663            delta,
664        }) = drag_move
665        {
666            assert_eq!(*start, Position::new(5, 5));
667            assert_eq!(*current, Position::new(10, 5));
668            assert_eq!(*delta, (5, 0));
669        }
670    }
671
672    #[test]
673    fn drag_end_on_mouse_up() {
674        let mut gr = GestureRecognizer::new(GestureConfig::default());
675        let t = now();
676
677        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
678        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
679
680        // Mouse up during drag
681        let events = gr.process(&mouse_up(12, 5, MouseButton::Left), t + MS_100);
682        assert_eq!(events.len(), 1);
683        assert!(matches!(
684            events[0],
685            SemanticEvent::DragEnd {
686                start: Position { x: 5, y: 5 },
687                end: Position { x: 12, y: 5 },
688            }
689        ));
690        assert!(!gr.is_dragging());
691    }
692
693    #[test]
694    fn drag_cancel_on_escape() {
695        let mut gr = GestureRecognizer::new(GestureConfig::default());
696        let t = now();
697
698        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
699        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
700        assert!(gr.is_dragging());
701
702        let events = gr.process(&esc(), t + MS_100);
703        assert_eq!(events.len(), 1);
704        assert!(matches!(events[0], SemanticEvent::DragCancel));
705        assert!(!gr.is_dragging());
706    }
707
708    #[test]
709    fn drag_prevents_click() {
710        let mut gr = GestureRecognizer::new(GestureConfig::default());
711        let t = now();
712
713        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
714        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
715
716        // Mouse up after drag → DragEnd, NOT Click
717        let events = gr.process(&mouse_up(10, 5, MouseButton::Left), t + MS_100);
718        assert_eq!(events.len(), 1);
719        assert!(matches!(events[0], SemanticEvent::DragEnd { .. }));
720        assert!(
721            !events
722                .iter()
723                .any(|e| matches!(e, SemanticEvent::Click { .. }))
724        );
725    }
726
727    // --- Long press tests ---
728
729    #[test]
730    fn long_press_fires_after_threshold() {
731        let mut gr = GestureRecognizer::new(GestureConfig::default());
732        let t = now();
733
734        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
735
736        // Before threshold — no long press
737        assert!(gr.check_long_press(t + MS_200).is_none());
738
739        // After threshold (500ms)
740        let lp = gr.check_long_press(t + MS_600);
741        assert!(lp.is_some());
742        if let Some(SemanticEvent::LongPress { pos, duration }) = lp {
743            assert_eq!(pos, Position::new(5, 5));
744            assert!(duration >= Duration::from_millis(500));
745        }
746    }
747
748    #[test]
749    fn long_press_not_repeated() {
750        let mut gr = GestureRecognizer::new(GestureConfig::default());
751        let t = now();
752
753        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
754
755        // First check fires
756        assert!(gr.check_long_press(t + MS_600).is_some());
757
758        // Second check does NOT fire again
759        assert!(
760            gr.check_long_press(t + Duration::from_millis(700))
761                .is_none()
762        );
763    }
764
765    #[test]
766    fn drag_cancels_long_press() {
767        let mut gr = GestureRecognizer::new(GestureConfig::default());
768        let t = now();
769
770        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
771        // Move the mouse
772        gr.process(&mouse_drag(6, 5, MouseButton::Left), t + MS_100);
773
774        // Long press should not fire after movement
775        assert!(gr.check_long_press(t + MS_600).is_none());
776    }
777
778    #[test]
779    fn mouse_up_cancels_long_press() {
780        let mut gr = GestureRecognizer::new(GestureConfig::default());
781        let t = now();
782
783        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
784        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_100);
785
786        // Long press should not fire after mouse up
787        assert!(gr.check_long_press(t + MS_600).is_none());
788    }
789
790    // --- Chord tests ---
791
792    #[test]
793    fn two_key_chord() {
794        let mut gr = GestureRecognizer::new(GestureConfig::default());
795        let t = now();
796
797        // Ctrl+K
798        let events1 = gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
799        assert!(events1.is_empty()); // First key in chord: no event yet
800
801        // Ctrl+C within timeout
802        let events2 = gr.process(&key_press(KeyCode::Char('c'), Modifiers::CTRL), t + MS_100);
803        assert_eq!(events2.len(), 1);
804        if let SemanticEvent::Chord { sequence } = &events2[0] {
805            assert_eq!(sequence.len(), 2);
806            assert_eq!(sequence[0].code, KeyCode::Char('k'));
807            assert_eq!(sequence[1].code, KeyCode::Char('c'));
808        } else {
809            panic!("Expected Chord event");
810        }
811    }
812
813    #[test]
814    fn chord_timeout_clears_buffer() {
815        let mut gr = GestureRecognizer::new(GestureConfig::default());
816        let t = now();
817
818        // Ctrl+K
819        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
820
821        // Wait beyond chord timeout (>1000ms)
822        let events = gr.process(
823            &key_press(KeyCode::Char('c'), Modifiers::CTRL),
824            t + Duration::from_millis(1100),
825        );
826
827        // Should not emit a chord (timeout expired, buffer was cleared)
828        assert!(events.is_empty());
829    }
830
831    #[test]
832    fn non_modifier_key_clears_chord() {
833        let mut gr = GestureRecognizer::new(GestureConfig::default());
834        let t = now();
835
836        // Start chord with Ctrl+K
837        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
838
839        // Plain key (no modifier) — clears chord
840        gr.process(&key_press(KeyCode::Char('x'), Modifiers::NONE), t + MS_50);
841
842        // Now Ctrl+C — should not form a chord with Ctrl+K
843        let events = gr.process(&key_press(KeyCode::Char('c'), Modifiers::CTRL), t + MS_100);
844        assert!(events.is_empty()); // Only one key in new chord buffer
845    }
846
847    #[test]
848    fn escape_clears_chord() {
849        let mut gr = GestureRecognizer::new(GestureConfig::default());
850        let t = now();
851
852        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
853        gr.process(&esc(), t + MS_50);
854
855        // Chord buffer should be cleared
856        let events = gr.process(&key_press(KeyCode::Char('c'), Modifiers::CTRL), t + MS_100);
857        assert!(events.is_empty());
858    }
859
860    // --- Focus loss tests ---
861
862    #[test]
863    fn focus_loss_cancels_drag() {
864        let mut gr = GestureRecognizer::new(GestureConfig::default());
865        let t = now();
866
867        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
868        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
869        assert!(gr.is_dragging());
870
871        let events = gr.process(&Event::Focus(false), t + MS_100);
872        assert_eq!(events.len(), 1);
873        assert!(matches!(events[0], SemanticEvent::DragCancel));
874        assert!(!gr.is_dragging());
875    }
876
877    #[test]
878    fn focus_loss_without_drag_is_silent() {
879        let mut gr = GestureRecognizer::new(GestureConfig::default());
880        let t = now();
881
882        let events = gr.process(&Event::Focus(false), t);
883        assert!(events.is_empty());
884    }
885
886    // --- Reset tests ---
887
888    #[test]
889    fn reset_clears_all_state() {
890        let mut gr = GestureRecognizer::new(GestureConfig::default());
891        let t = now();
892
893        // Build up state
894        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
895        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
896        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t + MS_100);
897
898        assert!(gr.is_dragging());
899
900        gr.reset();
901
902        assert!(!gr.is_dragging());
903        assert!(gr.last_click.is_none());
904        assert!(gr.mouse_down.is_none());
905        assert!(gr.drag.is_none());
906        assert!(gr.chord_buffer.is_empty());
907        assert!(gr.chord_start.is_none());
908    }
909
910    // --- Edge cases ---
911
912    #[test]
913    fn quadruple_click_wraps_to_single() {
914        let mut gr = GestureRecognizer::new(GestureConfig::default());
915        let t = now();
916
917        // Click 1-3
918        for i in 0..3u32 {
919            let offset = Duration::from_millis(i as u64 * 80);
920            gr.process(&mouse_down(5, 5, MouseButton::Left), t + offset);
921            gr.process(&mouse_up(5, 5, MouseButton::Left), t + offset + MS_50);
922        }
923
924        // Click 4 — should wrap back to single click (count capped at 3)
925        gr.process(
926            &mouse_down(5, 5, MouseButton::Left),
927            t + Duration::from_millis(260),
928        );
929        let events = gr.process(
930            &mouse_up(5, 5, MouseButton::Left),
931            t + Duration::from_millis(280),
932        );
933
934        assert_eq!(events.len(), 1);
935        assert!(matches!(events[0], SemanticEvent::Click { .. }));
936    }
937
938    #[test]
939    fn key_release_ignored() {
940        let mut gr = GestureRecognizer::new(GestureConfig::default());
941        let t = now();
942
943        let events = gr.process(
944            &Event::Key(KeyEvent {
945                code: KeyCode::Char('k'),
946                modifiers: Modifiers::CTRL,
947                kind: KeyEventKind::Release,
948            }),
949            t,
950        );
951        assert!(events.is_empty());
952    }
953
954    #[test]
955    fn debug_format() {
956        let gr = GestureRecognizer::new(GestureConfig::default());
957        let dbg = format!("{:?}", gr);
958        assert!(dbg.contains("GestureRecognizer"));
959    }
960
961    // --- Additional click tests (bd-950w) ---
962
963    #[test]
964    fn right_click() {
965        let mut gr = GestureRecognizer::new(GestureConfig::default());
966        let t = now();
967
968        gr.process(&mouse_down(10, 10, MouseButton::Right), t);
969        let events = gr.process(&mouse_up(10, 10, MouseButton::Right), t + MS_50);
970
971        assert_eq!(events.len(), 1);
972        assert!(matches!(
973            events[0],
974            SemanticEvent::Click {
975                button: MouseButton::Right,
976                ..
977            }
978        ));
979    }
980
981    #[test]
982    fn middle_click() {
983        let mut gr = GestureRecognizer::new(GestureConfig::default());
984        let t = now();
985
986        gr.process(&mouse_down(10, 10, MouseButton::Middle), t);
987        let events = gr.process(&mouse_up(10, 10, MouseButton::Middle), t + MS_50);
988
989        assert_eq!(events.len(), 1);
990        assert!(matches!(
991            events[0],
992            SemanticEvent::Click {
993                button: MouseButton::Middle,
994                ..
995            }
996        ));
997    }
998
999    #[test]
1000    fn click_at_origin() {
1001        let mut gr = GestureRecognizer::new(GestureConfig::default());
1002        let t = now();
1003
1004        gr.process(&mouse_down(0, 0, MouseButton::Left), t);
1005        let events = gr.process(&mouse_up(0, 0, MouseButton::Left), t + MS_50);
1006
1007        assert_eq!(events.len(), 1);
1008        if let SemanticEvent::Click { pos, .. } = &events[0] {
1009            assert_eq!(pos.x, 0);
1010            assert_eq!(pos.y, 0);
1011        }
1012    }
1013
1014    #[test]
1015    fn click_at_max_position() {
1016        let mut gr = GestureRecognizer::new(GestureConfig::default());
1017        let t = now();
1018
1019        gr.process(&mouse_down(u16::MAX, u16::MAX, MouseButton::Left), t);
1020        let events = gr.process(&mouse_up(u16::MAX, u16::MAX, MouseButton::Left), t + MS_50);
1021
1022        assert_eq!(events.len(), 1);
1023        if let SemanticEvent::Click { pos, .. } = &events[0] {
1024            assert_eq!(pos.x, u16::MAX);
1025            assert_eq!(pos.y, u16::MAX);
1026        }
1027    }
1028
1029    #[test]
1030    fn double_click_right_button() {
1031        let mut gr = GestureRecognizer::new(GestureConfig::default());
1032        let t = now();
1033
1034        gr.process(&mouse_down(5, 5, MouseButton::Right), t);
1035        gr.process(&mouse_up(5, 5, MouseButton::Right), t + MS_50);
1036
1037        gr.process(&mouse_down(5, 5, MouseButton::Right), t + MS_100);
1038        let events = gr.process(&mouse_up(5, 5, MouseButton::Right), t + MS_200);
1039
1040        assert_eq!(events.len(), 1);
1041        assert!(matches!(
1042            events[0],
1043            SemanticEvent::DoubleClick {
1044                button: MouseButton::Right,
1045                ..
1046            }
1047        ));
1048    }
1049
1050    #[test]
1051    fn click_position_beyond_tolerance() {
1052        let mut gr = GestureRecognizer::new(GestureConfig::default());
1053        let t = now();
1054
1055        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1056        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
1057
1058        // Second click at (8, 5) — manhattan distance 3, beyond tolerance of 1
1059        gr.process(&mouse_down(8, 5, MouseButton::Left), t + MS_100);
1060        let events = gr.process(&mouse_up(8, 5, MouseButton::Left), t + MS_200);
1061
1062        assert_eq!(events.len(), 1);
1063        assert!(matches!(events[0], SemanticEvent::Click { .. }));
1064    }
1065
1066    // --- Additional drag tests (bd-950w) ---
1067
1068    #[test]
1069    fn drag_with_right_button() {
1070        let mut gr = GestureRecognizer::new(GestureConfig::default());
1071        let t = now();
1072
1073        gr.process(&mouse_down(5, 5, MouseButton::Right), t);
1074        let events = gr.process(&mouse_drag(10, 5, MouseButton::Right), t + MS_50);
1075
1076        assert!(events.iter().any(|e| matches!(
1077            e,
1078            SemanticEvent::DragStart {
1079                button: MouseButton::Right,
1080                ..
1081            }
1082        )));
1083    }
1084
1085    #[test]
1086    fn drag_vertical() {
1087        let mut gr = GestureRecognizer::new(GestureConfig::default());
1088        let t = now();
1089
1090        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1091        let events = gr.process(&mouse_drag(5, 10, MouseButton::Left), t + MS_50);
1092
1093        assert!(
1094            events
1095                .iter()
1096                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
1097        );
1098        let drag_move = events
1099            .iter()
1100            .find(|e| matches!(e, SemanticEvent::DragMove { .. }));
1101        if let Some(SemanticEvent::DragMove { delta, .. }) = drag_move {
1102            assert_eq!(delta.0, 0); // no horizontal movement
1103            assert_eq!(delta.1, 5); // 5 cells down
1104        }
1105    }
1106
1107    #[test]
1108    fn drag_multiple_moves() {
1109        let mut gr = GestureRecognizer::new(GestureConfig::default());
1110        let t = now();
1111
1112        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1113        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
1114
1115        // Second move
1116        let events = gr.process(&mouse_drag(15, 5, MouseButton::Left), t + MS_100);
1117        let drag_move = events
1118            .iter()
1119            .find(|e| matches!(e, SemanticEvent::DragMove { .. }));
1120        if let Some(SemanticEvent::DragMove {
1121            start,
1122            current,
1123            delta,
1124        }) = drag_move
1125        {
1126            assert_eq!(*start, Position::new(5, 5));
1127            assert_eq!(*current, Position::new(15, 5));
1128            assert_eq!(*delta, (5, 0)); // delta from last position (10,5) to (15,5)
1129        }
1130    }
1131
1132    #[test]
1133    fn drag_threshold_exactly_met() {
1134        let mut gr = GestureRecognizer::new(GestureConfig::default());
1135        let t = now();
1136
1137        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1138        // Manhattan distance = 3 (exactly at threshold)
1139        let events = gr.process(&mouse_drag(8, 5, MouseButton::Left), t + MS_50);
1140
1141        assert!(
1142            events
1143                .iter()
1144                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
1145        );
1146    }
1147
1148    #[test]
1149    fn drag_threshold_one_below() {
1150        let mut gr = GestureRecognizer::new(GestureConfig::default());
1151        let t = now();
1152
1153        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1154        // Manhattan distance = 2 (below threshold of 3)
1155        let events = gr.process(&mouse_drag(7, 5, MouseButton::Left), t + MS_50);
1156
1157        assert!(
1158            !events
1159                .iter()
1160                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
1161        );
1162        assert!(!gr.is_dragging());
1163    }
1164
1165    #[test]
1166    fn drag_state_reset_after_end() {
1167        let mut gr = GestureRecognizer::new(GestureConfig::default());
1168        let t = now();
1169
1170        // Complete a drag
1171        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1172        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
1173        gr.process(&mouse_up(10, 5, MouseButton::Left), t + MS_100);
1174
1175        assert!(!gr.is_dragging());
1176
1177        // New mouse down should start fresh
1178        gr.process(&mouse_down(20, 20, MouseButton::Left), t + MS_200);
1179        let events = gr.process(
1180            &mouse_up(20, 20, MouseButton::Left),
1181            t + Duration::from_millis(250),
1182        );
1183
1184        // Should be a click, not a drag
1185        assert_eq!(events.len(), 1);
1186        assert!(matches!(events[0], SemanticEvent::Click { .. }));
1187    }
1188
1189    #[test]
1190    fn no_click_after_drag_cancel() {
1191        let mut gr = GestureRecognizer::new(GestureConfig::default());
1192        let t = now();
1193
1194        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1195        gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
1196        gr.process(&esc(), t + MS_100); // Cancel
1197
1198        // Mouse up after cancel — should not produce click or DragEnd
1199        let events = gr.process(&mouse_up(10, 5, MouseButton::Left), t + MS_200);
1200        // Click is emitted because drag state was cleared by cancel
1201        // This is expected: after Escape cancels the drag, mouse up is treated as a fresh interaction
1202        assert!(
1203            !events
1204                .iter()
1205                .any(|e| matches!(e, SemanticEvent::DragEnd { .. }))
1206        );
1207    }
1208
1209    // --- Additional long press tests (bd-950w) ---
1210
1211    #[test]
1212    fn long_press_correct_position() {
1213        let mut gr = GestureRecognizer::new(GestureConfig::default());
1214        let t = now();
1215
1216        gr.process(&mouse_down(42, 17, MouseButton::Left), t);
1217        let lp = gr.check_long_press(t + MS_600);
1218
1219        if let Some(SemanticEvent::LongPress { pos, .. }) = lp {
1220            assert_eq!(pos, Position::new(42, 17));
1221        } else {
1222            panic!("Expected LongPress");
1223        }
1224    }
1225
1226    #[test]
1227    fn long_press_with_custom_threshold() {
1228        let config = GestureConfig {
1229            long_press_threshold: Duration::from_millis(200),
1230            ..Default::default()
1231        };
1232        let mut gr = GestureRecognizer::new(config);
1233        let t = now();
1234
1235        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1236
1237        // Should not fire at 150ms
1238        assert!(
1239            gr.check_long_press(t + Duration::from_millis(150))
1240                .is_none()
1241        );
1242
1243        // Should fire at 250ms (past 200ms threshold)
1244        assert!(
1245            gr.check_long_press(t + Duration::from_millis(250))
1246                .is_some()
1247        );
1248    }
1249
1250    #[test]
1251    fn long_press_resets_on_new_mouse_down() {
1252        let mut gr = GestureRecognizer::new(GestureConfig::default());
1253        let t = now();
1254
1255        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1256        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_100);
1257
1258        // New mouse down resets long press timer
1259        gr.process(&mouse_down(10, 10, MouseButton::Left), t + MS_200);
1260
1261        // Should not fire based on old timer
1262        assert!(gr.check_long_press(t + MS_600).is_none());
1263
1264        // Should fire based on new timer (200ms + 500ms = 700ms)
1265        assert!(
1266            gr.check_long_press(t + Duration::from_millis(750))
1267                .is_some()
1268        );
1269    }
1270
1271    // --- Additional chord tests (bd-950w) ---
1272
1273    #[test]
1274    fn alt_key_chord() {
1275        let mut gr = GestureRecognizer::new(GestureConfig::default());
1276        let t = now();
1277
1278        gr.process(&key_press(KeyCode::Char('x'), Modifiers::ALT), t);
1279        let events = gr.process(&key_press(KeyCode::Char('y'), Modifiers::ALT), t + MS_100);
1280
1281        assert_eq!(events.len(), 1);
1282        if let SemanticEvent::Chord { sequence } = &events[0] {
1283            assert_eq!(sequence.len(), 2);
1284            assert!(sequence[0].modifiers.contains(Modifiers::ALT));
1285        }
1286    }
1287
1288    #[test]
1289    fn mixed_modifier_chord() {
1290        let mut gr = GestureRecognizer::new(GestureConfig::default());
1291        let t = now();
1292
1293        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
1294        let events = gr.process(&key_press(KeyCode::Char('d'), Modifiers::ALT), t + MS_100);
1295
1296        assert_eq!(events.len(), 1);
1297        if let SemanticEvent::Chord { sequence } = &events[0] {
1298            assert_eq!(sequence[0].modifiers, Modifiers::CTRL);
1299            assert_eq!(sequence[1].modifiers, Modifiers::ALT);
1300        }
1301    }
1302
1303    #[test]
1304    fn chord_with_function_key() {
1305        let mut gr = GestureRecognizer::new(GestureConfig::default());
1306        let t = now();
1307
1308        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
1309        let events = gr.process(&key_press(KeyCode::F(1), Modifiers::CTRL), t + MS_100);
1310
1311        assert_eq!(events.len(), 1);
1312        if let SemanticEvent::Chord { sequence } = &events[0] {
1313            assert_eq!(sequence[1].code, KeyCode::F(1));
1314        }
1315    }
1316
1317    #[test]
1318    fn single_modifier_key_no_chord_emitted() {
1319        let mut gr = GestureRecognizer::new(GestureConfig::default());
1320        let t = now();
1321
1322        // Single Ctrl+K does not emit a chord (needs ≥2 keys)
1323        let events = gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
1324        assert!(events.is_empty());
1325    }
1326
1327    // --- Config tests (bd-950w) ---
1328
1329    #[test]
1330    fn custom_click_tolerance() {
1331        let config = GestureConfig {
1332            click_tolerance: 5,
1333            ..Default::default()
1334        };
1335        let mut gr = GestureRecognizer::new(config);
1336        let t = now();
1337
1338        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1339        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
1340
1341        // Click at (10, 5) — manhattan distance 5, within tolerance of 5
1342        gr.process(&mouse_down(10, 5, MouseButton::Left), t + MS_100);
1343        let events = gr.process(&mouse_up(10, 5, MouseButton::Left), t + MS_200);
1344
1345        assert!(matches!(events[0], SemanticEvent::DoubleClick { .. }));
1346    }
1347
1348    #[test]
1349    fn custom_drag_threshold() {
1350        let config = GestureConfig {
1351            drag_threshold: 10,
1352            ..Default::default()
1353        };
1354        let mut gr = GestureRecognizer::new(config);
1355        let t = now();
1356
1357        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1358        // Move 5 cells — below new threshold of 10
1359        let events = gr.process(&mouse_drag(10, 5, MouseButton::Left), t + MS_50);
1360        assert!(!gr.is_dragging());
1361        assert!(events.is_empty());
1362
1363        // Move 10 cells — at threshold
1364        let events = gr.process(&mouse_drag(15, 5, MouseButton::Left), t + MS_100);
1365        assert!(gr.is_dragging());
1366        assert!(
1367            events
1368                .iter()
1369                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
1370        );
1371    }
1372
1373    #[test]
1374    fn custom_multi_click_timeout() {
1375        let config = GestureConfig {
1376            multi_click_timeout: Duration::from_millis(100),
1377            ..Default::default()
1378        };
1379        let mut gr = GestureRecognizer::new(config);
1380        let t = now();
1381
1382        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1383        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
1384
1385        // Second click at 150ms — beyond 100ms timeout
1386        gr.process(
1387            &mouse_down(5, 5, MouseButton::Left),
1388            t + Duration::from_millis(150),
1389        );
1390        let events = gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_200);
1391
1392        assert!(matches!(events[0], SemanticEvent::Click { .. }));
1393    }
1394
1395    #[test]
1396    fn config_getter_and_setter() {
1397        let mut gr = GestureRecognizer::new(GestureConfig::default());
1398
1399        assert_eq!(gr.config().drag_threshold, 3);
1400
1401        let new_config = GestureConfig {
1402            drag_threshold: 10,
1403            ..Default::default()
1404        };
1405        gr.set_config(new_config);
1406
1407        assert_eq!(gr.config().drag_threshold, 10);
1408    }
1409
1410    // --- Integration / sequence tests (bd-950w) ---
1411
1412    #[test]
1413    fn click_then_drag_are_independent() {
1414        let mut gr = GestureRecognizer::new(GestureConfig::default());
1415        let t = now();
1416
1417        // Click
1418        gr.process(&mouse_down(5, 5, MouseButton::Left), t);
1419        let click_events = gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_50);
1420        assert!(matches!(click_events[0], SemanticEvent::Click { .. }));
1421
1422        // Drag
1423        gr.process(&mouse_down(5, 5, MouseButton::Left), t + MS_200);
1424        let drag_events = gr.process(
1425            &mouse_drag(10, 5, MouseButton::Left),
1426            t + Duration::from_millis(250),
1427        );
1428        assert!(
1429            drag_events
1430                .iter()
1431                .any(|e| matches!(e, SemanticEvent::DragStart { .. }))
1432        );
1433
1434        let end_events = gr.process(
1435            &mouse_up(10, 5, MouseButton::Left),
1436            t + Duration::from_millis(300),
1437        );
1438        assert!(matches!(end_events[0], SemanticEvent::DragEnd { .. }));
1439    }
1440
1441    #[test]
1442    fn interleaved_mouse_and_keyboard() {
1443        let mut gr = GestureRecognizer::new(GestureConfig::default());
1444        let t = now();
1445
1446        // Start typing a chord
1447        gr.process(&key_press(KeyCode::Char('k'), Modifiers::CTRL), t);
1448
1449        // Mouse click in between
1450        gr.process(&mouse_down(5, 5, MouseButton::Left), t + MS_50);
1451        gr.process(&mouse_up(5, 5, MouseButton::Left), t + MS_100);
1452
1453        // Continue chord — chord buffer was not cleared by mouse events
1454        let events = gr.process(&key_press(KeyCode::Char('c'), Modifiers::CTRL), t + MS_200);
1455
1456        assert_eq!(events.len(), 1);
1457        assert!(matches!(events[0], SemanticEvent::Chord { .. }));
1458    }
1459
1460    #[test]
1461    fn rapid_clicks_produce_correct_sequence() {
1462        let mut gr = GestureRecognizer::new(GestureConfig::default());
1463        let t = now();
1464        let mut results = Vec::new();
1465
1466        for i in 0..5u32 {
1467            let offset = Duration::from_millis(i as u64 * 60);
1468            gr.process(&mouse_down(5, 5, MouseButton::Left), t + offset);
1469            let events = gr.process(
1470                &mouse_up(5, 5, MouseButton::Left),
1471                t + offset + Duration::from_millis(30),
1472            );
1473            results.extend(events);
1474        }
1475
1476        // Should see: Click, DoubleClick, TripleClick, Click (wrap), DoubleClick
1477        assert!(results.len() == 5);
1478        assert!(matches!(results[0], SemanticEvent::Click { .. }));
1479        assert!(matches!(results[1], SemanticEvent::DoubleClick { .. }));
1480        assert!(matches!(results[2], SemanticEvent::TripleClick { .. }));
1481        assert!(matches!(results[3], SemanticEvent::Click { .. }));
1482        assert!(matches!(results[4], SemanticEvent::DoubleClick { .. }));
1483    }
1484
1485    #[test]
1486    fn tick_event_ignored() {
1487        let mut gr = GestureRecognizer::new(GestureConfig::default());
1488        let t = now();
1489
1490        let events = gr.process(&Event::Tick, t);
1491        assert!(events.is_empty());
1492    }
1493
1494    #[test]
1495    fn resize_event_ignored() {
1496        let mut gr = GestureRecognizer::new(GestureConfig::default());
1497        let t = now();
1498
1499        let events = gr.process(
1500            &Event::Resize {
1501                width: 80,
1502                height: 24,
1503            },
1504            t,
1505        );
1506        assert!(events.is_empty());
1507    }
1508
1509    #[test]
1510    fn focus_gain_ignored() {
1511        let mut gr = GestureRecognizer::new(GestureConfig::default());
1512        let t = now();
1513
1514        let events = gr.process(&Event::Focus(true), t);
1515        assert!(events.is_empty());
1516    }
1517
1518    #[test]
1519    fn default_config_values() {
1520        let config = GestureConfig::default();
1521        assert_eq!(config.multi_click_timeout, Duration::from_millis(300));
1522        assert_eq!(config.long_press_threshold, Duration::from_millis(500));
1523        assert_eq!(config.drag_threshold, 3);
1524        assert_eq!(config.chord_timeout, Duration::from_millis(1000));
1525        assert_eq!(config.click_tolerance, 1);
1526    }
1527}