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    #[test]
514    fn semantic_event_clone_and_eq() {
515        let original = SemanticEvent::DoubleClick {
516            pos: pos(3, 7),
517            button: MouseButton::Left,
518        };
519        let cloned = original.clone();
520        assert_eq!(original, cloned);
521    }
522}