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}