Skip to main content

agg_gui/widgets/
multi_click.rs

1//! Shared multi-click (single / double / triple) detection for the text
2//! editors.
3//!
4//! `TextField`, `TextArea` and `RichTextEdit` all want the same "click again
5//! quickly to select more" behaviour: a first press positions the caret, a
6//! second selects the word, a third selects the line/paragraph/block. Rather
7//! than each widget re-deriving the timing gate, they share this tracker so the
8//! window and travel tolerance match each other — and match the Scene's
9//! double-click reset (`scene::DBL_CLICK_MS` / `MAX_CLICK_DIST`).
10
11use crate::geometry::Point;
12use web_time::Instant;
13
14/// Time window (ms) within which a follow-up press counts as continuing a
15/// multi-click sequence. Matches the Scene's `DBL_CLICK_MS`.
16const MULTI_CLICK_MS: u128 = 400;
17
18/// Maximum pointer travel (px) between presses that still counts as the same
19/// multi-click sequence. Matches the Scene's `MAX_CLICK_DIST`.
20const MULTI_CLICK_DIST: f64 = 6.0;
21
22/// Granularity of an in-progress selection drag, decided by the click that
23/// began it. A double-click starts a `Word` drag (extends by whole words), a
24/// triple-click a `Line` drag; an ordinary press stays `Char`.
25#[derive(Clone, Copy, PartialEq, Eq, Default)]
26pub enum SelectGranularity {
27    #[default]
28    Char,
29    Word,
30    Line,
31}
32
33/// Tracks consecutive mouse presses to distinguish single, double and triple
34/// clicks. One lives per text-editor widget instance.
35#[derive(Default)]
36pub struct MultiClickTracker {
37    last_time: Option<Instant>,
38    last_pos: Option<Point>,
39    count: u32,
40}
41
42impl MultiClickTracker {
43    /// Register a press at `pos`, returning the click count in the current
44    /// sequence: `1` = single, `2` = double, `3` = triple. A fourth quick click
45    /// wraps back to `1`, and any press outside the time window or travel
46    /// tolerance restarts the sequence at `1`.
47    pub fn register(&mut self, pos: Point) -> u32 {
48        let now = Instant::now();
49        let in_time = self
50            .last_time
51            .map(|t| now.duration_since(t).as_millis() < MULTI_CLICK_MS)
52            .unwrap_or(false);
53        let near = self
54            .last_pos
55            .map(|p| {
56                let dx = pos.x - p.x;
57                let dy = pos.y - p.y;
58                dx * dx + dy * dy <= MULTI_CLICK_DIST * MULTI_CLICK_DIST
59            })
60            .unwrap_or(false);
61        self.count = if in_time && near {
62            // Cap the sequence at triple; a fourth quick click starts over.
63            if self.count >= 3 {
64                1
65            } else {
66                self.count + 1
67            }
68        } else {
69            1
70        };
71        self.last_time = Some(now);
72        self.last_pos = Some(pos);
73        self.count
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn single_press_reports_one() {
83        let mut t = MultiClickTracker::default();
84        assert_eq!(t.register(Point::new(10.0, 10.0)), 1);
85    }
86
87    #[test]
88    fn two_quick_presses_report_double_then_triple() {
89        let mut t = MultiClickTracker::default();
90        assert_eq!(t.register(Point::new(10.0, 10.0)), 1);
91        assert_eq!(t.register(Point::new(10.0, 10.0)), 2);
92        assert_eq!(t.register(Point::new(10.0, 10.0)), 3);
93        // Fourth quick click restarts the sequence.
94        assert_eq!(t.register(Point::new(10.0, 10.0)), 1);
95    }
96
97    #[test]
98    fn far_second_press_restarts_sequence() {
99        let mut t = MultiClickTracker::default();
100        assert_eq!(t.register(Point::new(10.0, 10.0)), 1);
101        // Beyond the 6 px travel tolerance — a fresh single click.
102        assert_eq!(t.register(Point::new(40.0, 10.0)), 1);
103    }
104}