Skip to main content

ftui_core/
semantic_event.rs

1#![forbid(unsafe_code)]
2
3//! High-level semantic events derived from raw terminal input (bd-3fu8).
4//!
5//! [`SemanticEvent`] represents user *intentions* rather than raw key presses or
6//! mouse coordinates. A gesture recognizer (see bd-2v34) converts raw [`Event`]
7//! sequences into these semantic events.
8//!
9//! # Design
10//!
11//! ## Invariants
12//! 1. Every drag sequence is well-formed: `DragStart` → zero or more `DragMove` → `DragEnd` or `DragCancel`.
13//! 2. Click multiplicity is monotonically increasing within a multi-click window:
14//!    a `TripleClick` always follows a `DoubleClick` from the same position.
15//! 3. `Chord` sequences are non-empty (enforced by constructor).
16//! 4. `Swipe` velocity is always non-negative.
17//!
18//! ## Failure Modes
19//! - If the gesture recognizer times out mid-chord, no `Chord` event is emitted;
20//!   the raw keys are passed through instead (graceful degradation).
21//! - If a drag is interrupted by focus loss, `DragCancel` is emitted (never a
22//!   dangling `DragStart` without termination).
23
24use crate::event::{KeyCode, Modifiers, MouseButton};
25use std::time::Duration;
26
27// ---------------------------------------------------------------------------
28// Position
29// ---------------------------------------------------------------------------
30
31/// A 2D cell position in the terminal (0-indexed).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
33pub struct Position {
34    pub x: u16,
35    pub y: u16,
36}
37
38impl Position {
39    /// Create a new position.
40    #[must_use]
41    pub const fn new(x: u16, y: u16) -> Self {
42        Self { x, y }
43    }
44
45    /// Manhattan distance to another position.
46    #[must_use]
47    pub fn manhattan_distance(self, other: Self) -> u32 {
48        (self.x as i32 - other.x as i32).unsigned_abs()
49            + (self.y as i32 - other.y as i32).unsigned_abs()
50    }
51}
52
53impl From<(u16, u16)> for Position {
54    fn from((x, y): (u16, u16)) -> Self {
55        Self { x, y }
56    }
57}
58
59// ---------------------------------------------------------------------------
60// ChordKey
61// ---------------------------------------------------------------------------
62
63/// A single key in a chord sequence (e.g., Ctrl+K in "Ctrl+K, Ctrl+C").
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct ChordKey {
66    pub code: KeyCode,
67    pub modifiers: Modifiers,
68}
69
70impl ChordKey {
71    /// Create a chord key.
72    #[must_use]
73    pub const fn new(code: KeyCode, modifiers: Modifiers) -> Self {
74        Self { code, modifiers }
75    }
76}
77
78// ---------------------------------------------------------------------------
79// SwipeDirection
80// ---------------------------------------------------------------------------
81
82/// Cardinal direction for swipe gestures.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub enum SwipeDirection {
85    Up,
86    Down,
87    Left,
88    Right,
89}
90
91impl SwipeDirection {
92    /// Returns the opposite direction.
93    #[must_use]
94    pub const fn opposite(self) -> Self {
95        match self {
96            Self::Up => Self::Down,
97            Self::Down => Self::Up,
98            Self::Left => Self::Right,
99            Self::Right => Self::Left,
100        }
101    }
102
103    /// Returns true for vertical directions.
104    #[must_use]
105    pub const fn is_vertical(self) -> bool {
106        matches!(self, Self::Up | Self::Down)
107    }
108
109    /// Returns true for horizontal directions.
110    #[must_use]
111    pub const fn is_horizontal(self) -> bool {
112        matches!(self, Self::Left | Self::Right)
113    }
114}
115
116// ---------------------------------------------------------------------------
117// SemanticEvent
118// ---------------------------------------------------------------------------
119
120/// High-level semantic events derived from raw terminal input.
121///
122/// These represent user intentions rather than raw key presses or mouse
123/// coordinates. A gesture recognizer converts raw events into these.
124#[derive(Debug, Clone, PartialEq)]
125pub enum SemanticEvent {
126    // === Mouse Gestures ===
127    /// Single click (mouse down + up in same position within threshold).
128    Click { pos: Position, button: MouseButton },
129
130    /// Two clicks within the double-click time threshold.
131    DoubleClick { pos: Position, button: MouseButton },
132
133    /// Three clicks within threshold (often used for line selection).
134    TripleClick { pos: Position, button: MouseButton },
135
136    /// Mouse held down beyond threshold without moving.
137    LongPress { pos: Position, duration: Duration },
138
139    // === Drag Gestures ===
140    /// Mouse moved beyond drag threshold while button held.
141    DragStart { pos: Position, button: MouseButton },
142
143    /// Ongoing drag movement.
144    DragMove {
145        start: Position,
146        current: Position,
147        /// Movement since last DragMove (dx, dy).
148        delta: (i16, i16),
149    },
150
151    /// Mouse released after drag.
152    DragEnd { start: Position, end: Position },
153
154    /// Drag cancelled (Escape pressed, focus lost, etc.).
155    DragCancel,
156
157    // === Keyboard Gestures ===
158    /// Key chord sequence completed (e.g., Ctrl+K, Ctrl+C).
159    ///
160    /// Invariant: `sequence` is always non-empty.
161    Chord { sequence: Vec<ChordKey> },
162
163    // === Touch-Like Gestures ===
164    /// Swipe gesture (rapid mouse movement in a cardinal direction).
165    Swipe {
166        direction: SwipeDirection,
167        /// Distance in cells.
168        distance: u16,
169        /// Velocity in cells per second (always >= 0.0).
170        velocity: f32,
171    },
172}
173
174impl SemanticEvent {
175    /// Returns true if this is a drag-related event.
176    #[must_use]
177    pub fn is_drag(&self) -> bool {
178        matches!(
179            self,
180            Self::DragStart { .. }
181                | Self::DragMove { .. }
182                | Self::DragEnd { .. }
183                | Self::DragCancel
184        )
185    }
186
187    /// Returns true if this is a click-related event (single, double, or triple).
188    #[must_use]
189    pub fn is_click(&self) -> bool {
190        matches!(
191            self,
192            Self::Click { .. } | Self::DoubleClick { .. } | Self::TripleClick { .. }
193        )
194    }
195
196    /// Returns the position if this event has one.
197    #[must_use]
198    pub fn position(&self) -> Option<Position> {
199        match self {
200            Self::Click { pos, .. }
201            | Self::DoubleClick { pos, .. }
202            | Self::TripleClick { pos, .. }
203            | Self::LongPress { pos, .. }
204            | Self::DragStart { pos, .. } => Some(*pos),
205            Self::DragMove { current, .. } => Some(*current),
206            Self::DragEnd { end, .. } => Some(*end),
207            Self::Chord { .. } | Self::DragCancel | Self::Swipe { .. } => None,
208        }
209    }
210
211    /// Returns the mouse button if this event involves one.
212    #[must_use]
213    pub fn button(&self) -> Option<MouseButton> {
214        match self {
215            Self::Click { button, .. }
216            | Self::DoubleClick { button, .. }
217            | Self::TripleClick { button, .. }
218            | Self::DragStart { button, .. } => Some(*button),
219            _ => None,
220        }
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Tests
226// ---------------------------------------------------------------------------
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn pos(x: u16, y: u16) -> Position {
233        Position::new(x, y)
234    }
235
236    // === Position tests ===
237
238    #[test]
239    fn position_new_and_from_tuple() {
240        let p = Position::new(5, 10);
241        assert_eq!(p, Position::from((5, 10)));
242        assert_eq!(p.x, 5);
243        assert_eq!(p.y, 10);
244    }
245
246    #[test]
247    fn position_manhattan_distance() {
248        assert_eq!(pos(0, 0).manhattan_distance(pos(3, 4)), 7);
249        assert_eq!(pos(5, 5).manhattan_distance(pos(5, 5)), 0);
250        assert_eq!(pos(10, 0).manhattan_distance(pos(0, 10)), 20);
251    }
252
253    #[test]
254    fn position_default_is_origin() {
255        assert_eq!(Position::default(), pos(0, 0));
256    }
257
258    // === ChordKey tests ===
259
260    #[test]
261    fn chord_key_equality() {
262        let k1 = ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL);
263        let k2 = ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL);
264        let k3 = ChordKey::new(KeyCode::Char('c'), Modifiers::CTRL);
265
266        assert_eq!(k1, k2);
267        assert_ne!(k1, k3);
268    }
269
270    #[test]
271    fn chord_key_hash_consistency() {
272        use std::collections::HashSet;
273        let mut set = HashSet::new();
274        set.insert(ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL));
275        set.insert(ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL)); // duplicate
276        assert_eq!(set.len(), 1);
277    }
278
279    // === SwipeDirection tests ===
280
281    #[test]
282    fn swipe_direction_opposite() {
283        assert_eq!(SwipeDirection::Up.opposite(), SwipeDirection::Down);
284        assert_eq!(SwipeDirection::Down.opposite(), SwipeDirection::Up);
285        assert_eq!(SwipeDirection::Left.opposite(), SwipeDirection::Right);
286        assert_eq!(SwipeDirection::Right.opposite(), SwipeDirection::Left);
287    }
288
289    #[test]
290    fn swipe_direction_axes() {
291        assert!(SwipeDirection::Up.is_vertical());
292        assert!(SwipeDirection::Down.is_vertical());
293        assert!(!SwipeDirection::Left.is_vertical());
294        assert!(!SwipeDirection::Right.is_vertical());
295
296        assert!(SwipeDirection::Left.is_horizontal());
297        assert!(SwipeDirection::Right.is_horizontal());
298        assert!(!SwipeDirection::Up.is_horizontal());
299        assert!(!SwipeDirection::Down.is_horizontal());
300    }
301
302    // === SemanticEvent tests ===
303
304    #[test]
305    fn is_drag_classification() {
306        assert!(
307            SemanticEvent::DragStart {
308                pos: pos(0, 0),
309                button: MouseButton::Left,
310            }
311            .is_drag()
312        );
313
314        assert!(
315            SemanticEvent::DragMove {
316                start: pos(0, 0),
317                current: pos(5, 5),
318                delta: (5, 5),
319            }
320            .is_drag()
321        );
322
323        assert!(
324            SemanticEvent::DragEnd {
325                start: pos(0, 0),
326                end: pos(10, 10),
327            }
328            .is_drag()
329        );
330
331        assert!(SemanticEvent::DragCancel.is_drag());
332
333        // Non-drag events
334        assert!(
335            !SemanticEvent::Click {
336                pos: pos(0, 0),
337                button: MouseButton::Left,
338            }
339            .is_drag()
340        );
341
342        assert!(
343            !SemanticEvent::Chord {
344                sequence: vec![ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL)],
345            }
346            .is_drag()
347        );
348    }
349
350    #[test]
351    fn is_click_classification() {
352        assert!(
353            SemanticEvent::Click {
354                pos: pos(1, 2),
355                button: MouseButton::Left,
356            }
357            .is_click()
358        );
359
360        assert!(
361            SemanticEvent::DoubleClick {
362                pos: pos(1, 2),
363                button: MouseButton::Left,
364            }
365            .is_click()
366        );
367
368        assert!(
369            SemanticEvent::TripleClick {
370                pos: pos(1, 2),
371                button: MouseButton::Left,
372            }
373            .is_click()
374        );
375
376        assert!(
377            !SemanticEvent::DragStart {
378                pos: pos(0, 0),
379                button: MouseButton::Left,
380            }
381            .is_click()
382        );
383    }
384
385    #[test]
386    fn position_extraction() {
387        assert_eq!(
388            SemanticEvent::Click {
389                pos: pos(5, 10),
390                button: MouseButton::Left,
391            }
392            .position(),
393            Some(pos(5, 10))
394        );
395
396        assert_eq!(
397            SemanticEvent::DragMove {
398                start: pos(0, 0),
399                current: pos(15, 20),
400                delta: (1, 1),
401            }
402            .position(),
403            Some(pos(15, 20))
404        );
405
406        assert_eq!(
407            SemanticEvent::DragEnd {
408                start: pos(0, 0),
409                end: pos(30, 40),
410            }
411            .position(),
412            Some(pos(30, 40))
413        );
414
415        assert_eq!(SemanticEvent::DragCancel.position(), None);
416
417        assert_eq!(SemanticEvent::Chord { sequence: vec![] }.position(), None);
418
419        assert_eq!(
420            SemanticEvent::Swipe {
421                direction: SwipeDirection::Up,
422                distance: 10,
423                velocity: 100.0,
424            }
425            .position(),
426            None
427        );
428    }
429
430    #[test]
431    fn button_extraction() {
432        assert_eq!(
433            SemanticEvent::Click {
434                pos: pos(0, 0),
435                button: MouseButton::Right,
436            }
437            .button(),
438            Some(MouseButton::Right)
439        );
440
441        assert_eq!(
442            SemanticEvent::DragStart {
443                pos: pos(0, 0),
444                button: MouseButton::Middle,
445            }
446            .button(),
447            Some(MouseButton::Middle)
448        );
449
450        assert_eq!(SemanticEvent::DragCancel.button(), None);
451
452        assert_eq!(
453            SemanticEvent::LongPress {
454                pos: pos(0, 0),
455                duration: Duration::from_millis(500),
456            }
457            .button(),
458            None
459        );
460    }
461
462    #[test]
463    fn long_press_carries_duration() {
464        let event = SemanticEvent::LongPress {
465            pos: pos(10, 20),
466            duration: Duration::from_millis(750),
467        };
468        assert_eq!(event.position(), Some(pos(10, 20)));
469        assert!(!event.is_drag());
470        assert!(!event.is_click());
471    }
472
473    #[test]
474    fn swipe_velocity_and_direction() {
475        let event = SemanticEvent::Swipe {
476            direction: SwipeDirection::Right,
477            distance: 25,
478            velocity: 150.0,
479        };
480        assert!(!event.is_drag());
481        assert!(!event.is_click());
482        assert_eq!(event.position(), None);
483    }
484
485    #[test]
486    fn chord_sequence_contents() {
487        let chord = SemanticEvent::Chord {
488            sequence: vec![
489                ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL),
490                ChordKey::new(KeyCode::Char('c'), Modifiers::CTRL),
491            ],
492        };
493        if let SemanticEvent::Chord { sequence } = &chord {
494            assert_eq!(sequence.len(), 2);
495            assert_eq!(sequence[0].code, KeyCode::Char('k'));
496            assert_eq!(sequence[1].code, KeyCode::Char('c'));
497        } else {
498            panic!("Expected Chord variant");
499        }
500    }
501
502    #[test]
503    fn semantic_event_debug_format() {
504        let click = SemanticEvent::Click {
505            pos: pos(5, 10),
506            button: MouseButton::Left,
507        };
508        let dbg = format!("{:?}", click);
509        assert!(dbg.contains("Click"));
510        assert!(dbg.contains("Position"));
511    }
512
513    // ─── Edge-case tests (bd-17azz) ────────────────────────────────────
514
515    // === Position edge cases ===
516
517    #[test]
518    fn position_manhattan_distance_max_coordinates() {
519        let p1 = Position::new(0, 0);
520        let p2 = Position::new(u16::MAX, u16::MAX);
521        // |65535 - 0| + |65535 - 0| = 131070
522        assert_eq!(p1.manhattan_distance(p2), 131070);
523    }
524
525    #[test]
526    fn position_manhattan_distance_symmetric() {
527        let a = pos(10, 20);
528        let b = pos(50, 3);
529        assert_eq!(a.manhattan_distance(b), b.manhattan_distance(a));
530    }
531
532    #[test]
533    fn position_manhattan_distance_same_point() {
534        let p = pos(100, 200);
535        assert_eq!(p.manhattan_distance(p), 0);
536    }
537
538    #[test]
539    fn position_manhattan_distance_horizontal_only() {
540        assert_eq!(pos(0, 5).manhattan_distance(pos(10, 5)), 10);
541    }
542
543    #[test]
544    fn position_manhattan_distance_vertical_only() {
545        assert_eq!(pos(5, 0).manhattan_distance(pos(5, 10)), 10);
546    }
547
548    #[test]
549    fn position_from_tuple_max() {
550        let p: Position = (u16::MAX, u16::MAX).into();
551        assert_eq!(p.x, u16::MAX);
552        assert_eq!(p.y, u16::MAX);
553    }
554
555    #[test]
556    fn position_hash_consistency() {
557        use std::collections::HashSet;
558        let mut set = HashSet::new();
559        set.insert(pos(10, 20));
560        set.insert(pos(10, 20)); // duplicate
561        assert_eq!(set.len(), 1);
562        set.insert(pos(20, 10)); // different
563        assert_eq!(set.len(), 2);
564    }
565
566    #[test]
567    fn position_copy_semantics() {
568        let p = pos(5, 10);
569        let q = p; // Copy, not move
570        assert_eq!(p, q); // p is still usable
571    }
572
573    // === ChordKey edge cases ===
574
575    #[test]
576    fn chord_key_different_modifiers_not_equal() {
577        let k1 = ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL);
578        let k2 = ChordKey::new(KeyCode::Char('k'), Modifiers::ALT);
579        assert_ne!(k1, k2);
580    }
581
582    #[test]
583    fn chord_key_clone_independence() {
584        let original = ChordKey::new(KeyCode::Char('x'), Modifiers::SHIFT);
585        let cloned = original.clone();
586        assert_eq!(original, cloned);
587    }
588
589    #[test]
590    fn chord_key_no_modifiers() {
591        let k = ChordKey::new(KeyCode::Enter, Modifiers::NONE);
592        assert_eq!(k.modifiers, Modifiers::NONE);
593    }
594
595    #[test]
596    fn chord_key_debug_format() {
597        let k = ChordKey::new(KeyCode::Char('a'), Modifiers::CTRL);
598        let dbg = format!("{k:?}");
599        assert!(dbg.contains("ChordKey"));
600    }
601
602    // === SwipeDirection edge cases ===
603
604    #[test]
605    fn swipe_direction_double_opposite_is_identity() {
606        for dir in [
607            SwipeDirection::Up,
608            SwipeDirection::Down,
609            SwipeDirection::Left,
610            SwipeDirection::Right,
611        ] {
612            assert_eq!(dir.opposite().opposite(), dir);
613        }
614    }
615
616    #[test]
617    fn swipe_direction_vertical_horizontal_mutually_exclusive() {
618        for dir in [
619            SwipeDirection::Up,
620            SwipeDirection::Down,
621            SwipeDirection::Left,
622            SwipeDirection::Right,
623        ] {
624            assert_ne!(dir.is_vertical(), dir.is_horizontal());
625        }
626    }
627
628    #[test]
629    fn swipe_direction_copy_semantics() {
630        let d = SwipeDirection::Up;
631        let e = d; // Copy
632        assert_eq!(d, e);
633    }
634
635    #[test]
636    fn swipe_direction_hash_consistency() {
637        use std::collections::HashSet;
638        let mut set = HashSet::new();
639        set.insert(SwipeDirection::Up);
640        set.insert(SwipeDirection::Up);
641        assert_eq!(set.len(), 1);
642        set.insert(SwipeDirection::Down);
643        assert_eq!(set.len(), 2);
644    }
645
646    // === SemanticEvent edge cases ===
647
648    #[test]
649    fn position_for_double_click() {
650        assert_eq!(
651            SemanticEvent::DoubleClick {
652                pos: pos(33, 44),
653                button: MouseButton::Right,
654            }
655            .position(),
656            Some(pos(33, 44))
657        );
658    }
659
660    #[test]
661    fn position_for_triple_click() {
662        assert_eq!(
663            SemanticEvent::TripleClick {
664                pos: pos(1, 2),
665                button: MouseButton::Middle,
666            }
667            .position(),
668            Some(pos(1, 2))
669        );
670    }
671
672    #[test]
673    fn position_for_long_press() {
674        assert_eq!(
675            SemanticEvent::LongPress {
676                pos: pos(99, 88),
677                duration: Duration::from_secs(1),
678            }
679            .position(),
680            Some(pos(99, 88))
681        );
682    }
683
684    #[test]
685    fn position_for_drag_start() {
686        assert_eq!(
687            SemanticEvent::DragStart {
688                pos: pos(7, 8),
689                button: MouseButton::Left,
690            }
691            .position(),
692            Some(pos(7, 8))
693        );
694    }
695
696    #[test]
697    fn button_for_double_click() {
698        assert_eq!(
699            SemanticEvent::DoubleClick {
700                pos: pos(0, 0),
701                button: MouseButton::Middle,
702            }
703            .button(),
704            Some(MouseButton::Middle)
705        );
706    }
707
708    #[test]
709    fn button_for_triple_click() {
710        assert_eq!(
711            SemanticEvent::TripleClick {
712                pos: pos(0, 0),
713                button: MouseButton::Right,
714            }
715            .button(),
716            Some(MouseButton::Right)
717        );
718    }
719
720    #[test]
721    fn button_none_for_drag_move() {
722        assert_eq!(
723            SemanticEvent::DragMove {
724                start: pos(0, 0),
725                current: pos(1, 1),
726                delta: (1, 1),
727            }
728            .button(),
729            None
730        );
731    }
732
733    #[test]
734    fn button_none_for_drag_end() {
735        assert_eq!(
736            SemanticEvent::DragEnd {
737                start: pos(0, 0),
738                end: pos(5, 5),
739            }
740            .button(),
741            None
742        );
743    }
744
745    #[test]
746    fn button_none_for_swipe() {
747        assert_eq!(
748            SemanticEvent::Swipe {
749                direction: SwipeDirection::Left,
750                distance: 5,
751                velocity: 1.0,
752            }
753            .button(),
754            None
755        );
756    }
757
758    #[test]
759    fn button_none_for_chord() {
760        assert_eq!(
761            SemanticEvent::Chord {
762                sequence: vec![ChordKey::new(KeyCode::Char('a'), Modifiers::NONE)],
763            }
764            .button(),
765            None
766        );
767    }
768
769    #[test]
770    fn is_drag_false_for_long_press() {
771        assert!(
772            !SemanticEvent::LongPress {
773                pos: pos(0, 0),
774                duration: Duration::from_millis(100),
775            }
776            .is_drag()
777        );
778    }
779
780    #[test]
781    fn is_drag_false_for_swipe() {
782        assert!(
783            !SemanticEvent::Swipe {
784                direction: SwipeDirection::Down,
785                distance: 10,
786                velocity: 50.0,
787            }
788            .is_drag()
789        );
790    }
791
792    #[test]
793    fn is_click_false_for_long_press() {
794        assert!(
795            !SemanticEvent::LongPress {
796                pos: pos(0, 0),
797                duration: Duration::from_millis(500),
798            }
799            .is_click()
800        );
801    }
802
803    #[test]
804    fn is_click_false_for_swipe() {
805        assert!(
806            !SemanticEvent::Swipe {
807                direction: SwipeDirection::Up,
808                distance: 5,
809                velocity: 20.0,
810            }
811            .is_click()
812        );
813    }
814
815    #[test]
816    fn drag_move_with_negative_delta() {
817        let ev = SemanticEvent::DragMove {
818            start: pos(20, 20),
819            current: pos(10, 10),
820            delta: (-10, -10),
821        };
822        assert!(ev.is_drag());
823        assert_eq!(ev.position(), Some(pos(10, 10)));
824    }
825
826    #[test]
827    fn drag_move_with_zero_delta() {
828        let ev = SemanticEvent::DragMove {
829            start: pos(5, 5),
830            current: pos(5, 5),
831            delta: (0, 0),
832        };
833        assert!(ev.is_drag());
834        assert_eq!(ev.position(), Some(pos(5, 5)));
835    }
836
837    #[test]
838    fn swipe_zero_velocity() {
839        let ev = SemanticEvent::Swipe {
840            direction: SwipeDirection::Right,
841            distance: 0,
842            velocity: 0.0,
843        };
844        assert_eq!(ev.position(), None);
845        assert!(!ev.is_drag());
846        assert!(!ev.is_click());
847    }
848
849    #[test]
850    fn swipe_large_velocity() {
851        let ev = SemanticEvent::Swipe {
852            direction: SwipeDirection::Up,
853            distance: u16::MAX,
854            velocity: f32::MAX,
855        };
856        assert_eq!(ev.position(), None);
857    }
858
859    #[test]
860    fn long_press_zero_duration() {
861        let ev = SemanticEvent::LongPress {
862            pos: pos(0, 0),
863            duration: Duration::ZERO,
864        };
865        assert_eq!(ev.position(), Some(pos(0, 0)));
866        assert!(!ev.is_click());
867    }
868
869    #[test]
870    fn chord_empty_sequence() {
871        // The invariant says non-empty, but the struct allows it
872        let ev = SemanticEvent::Chord { sequence: vec![] };
873        assert_eq!(ev.position(), None);
874        assert_eq!(ev.button(), None);
875        assert!(!ev.is_drag());
876        assert!(!ev.is_click());
877    }
878
879    #[test]
880    fn chord_clone_deep_copy() {
881        let original = SemanticEvent::Chord {
882            sequence: vec![
883                ChordKey::new(KeyCode::Char('k'), Modifiers::CTRL),
884                ChordKey::new(KeyCode::Char('c'), Modifiers::CTRL),
885            ],
886        };
887        let cloned = original.clone();
888        assert_eq!(original, cloned);
889        // Modifying clone shouldn't affect original (Vec is deep cloned)
890        if let SemanticEvent::Chord { sequence } = &cloned {
891            assert_eq!(sequence.len(), 2);
892        }
893    }
894
895    #[test]
896    fn swipe_nan_velocity_not_equal_to_itself() {
897        let ev1 = SemanticEvent::Swipe {
898            direction: SwipeDirection::Up,
899            distance: 5,
900            velocity: f32::NAN,
901        };
902        let ev2 = ev1.clone();
903        // NaN != NaN, so PartialEq should return false
904        assert_ne!(ev1, ev2);
905    }
906
907    #[test]
908    fn drag_cancel_is_minimal() {
909        let ev = SemanticEvent::DragCancel;
910        assert!(ev.is_drag());
911        assert!(!ev.is_click());
912        assert_eq!(ev.position(), None);
913        assert_eq!(ev.button(), None);
914    }
915
916    #[test]
917    fn drag_end_position_is_end_not_start() {
918        let ev = SemanticEvent::DragEnd {
919            start: pos(0, 0),
920            end: pos(100, 200),
921        };
922        assert_eq!(ev.position(), Some(pos(100, 200)));
923    }
924
925    #[test]
926    fn click_with_right_button() {
927        let ev = SemanticEvent::Click {
928            pos: pos(5, 10),
929            button: MouseButton::Right,
930        };
931        assert!(ev.is_click());
932        assert_eq!(ev.button(), Some(MouseButton::Right));
933    }
934
935    #[test]
936    fn click_with_middle_button() {
937        let ev = SemanticEvent::Click {
938            pos: pos(0, 0),
939            button: MouseButton::Middle,
940        };
941        assert!(ev.is_click());
942        assert_eq!(ev.button(), Some(MouseButton::Middle));
943    }
944
945    // ─── End edge-case tests (bd-17azz) ──────────────────────────────
946
947    #[test]
948    fn semantic_event_clone_and_eq() {
949        let original = SemanticEvent::DoubleClick {
950            pos: pos(3, 7),
951            button: MouseButton::Left,
952        };
953        let cloned = original.clone();
954        assert_eq!(original, cloned);
955    }
956}