Skip to main content

kimun_notes/app_screen/
click_run.rs

1//! When two clicks are one gesture.
2//!
3//! Following a link with the mouse is a double-click, not the Ctrl+click every
4//! IDE uses, because no modifier-click survives all three platforms:
5//!
6//! - **Cmd+click cannot exist.** xterm mouse reporting encodes three modifier
7//!   bits — shift, alt, control. There is no super bit for a terminal to set
8//!   or a parser to read, and macOS terminals consume Cmd+click for their own
9//!   URL opening anyway.
10//! - **Ctrl+click is macOS's right-click**, synthesised by Terminal.app and
11//!   iTerm2 *before* mouse reporting. The application receives a right press,
12//!   which kimün already answers with the note context menu.
13//! - **Shift+click never arrives.** Most terminals use shift to bypass
14//!   application mouse reporting entirely.
15//! - **Alt+click** works on Linux and Windows, and fails silently on macOS
16//!   unless the user has turned on "Use Option as Meta key".
17//!
18//! A double-click needs no modifier bit and no terminal cooperation beyond the
19//! mouse reporting kimün already requires, so it behaves the same everywhere.
20//! Nothing is lost by not having a chord: the bound `FollowLink` shortcut
21//! (Ctrl-N by default) follows from the keyboard, and the footer advertises it
22//! whenever the cursor sits on a link.
23//!
24//! This module is the rule for what counts as one gesture.
25//!
26//! Two conditions, and the second is why the first can be strict:
27//!
28//! - **The same cell.** Not a neighbourhood. The terminal grid is coarse
29//!   enough to absorb ordinary hand drift for free, and a tolerance would make
30//!   two deliberate clicks on adjacent characters — nudging the cursor one
31//!   column over — follow a link, which is the accident this rule exists to
32//!   stop.
33//! - **Within `WINDOW`.** A burst, not a pause. Without it, any two clicks
34//!   on a cell would pair up however far apart, and a link cell gets clicked
35//!   repeatedly while its text is being edited.
36//!
37//! Firing ends the run: a third click must not pair with the second, because
38//! by then the double has opened a different note under the pointer.
39//!
40//! Like [`TypingRun`](crate::components::text_editor::typing_run::TypingRun),
41//! this holds no clock — the caller passes `now`, so a test describes a gap
42//! instead of sleeping through one.
43
44use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
45use std::time::{Duration, Instant};
46
47/// How close together two clicks on a cell have to be to read as one gesture.
48///
49/// Shorter than `TypingRun::IDLE`, which measures something else: that is a
50/// *pause* (how long before a human stops feeling they are still typing), this
51/// is a *deliberate burst*. macOS and Windows default to 500ms, GNOME to 400ms.
52const WINDOW: Duration = Duration::from_millis(400);
53
54/// The clicks currently forming one gesture.
55#[derive(Debug, Default)]
56pub struct ClickRun {
57    last: Option<(u16, u16, Instant)>,
58}
59
60impl ClickRun {
61    /// Feed one mouse event to the run and report whether it completes a
62    /// double-click. `in_editor` is the screen's answer to "did this land in
63    /// the editor column" — the run does no hit-testing of its own.
64    ///
65    /// The policy lives here rather than in `EditorScreen` so it can be tested
66    /// against a real event sequence. It could not be, and a release ending
67    /// the run went unnoticed: every press is followed by one.
68    pub fn observe(&mut self, event: &MouseEvent, in_editor: bool, now: Instant) -> bool {
69        match event.kind {
70            MouseEventKind::Down(MouseButton::Left) if in_editor => {
71                self.completes_double(event.column, event.row, now)
72            }
73            // Neither is a new action, and ending the run on either would mean
74            // no double-click ever completes — see [`Self::end`].
75            MouseEventKind::Up(_) | MouseEventKind::Moved => false,
76            _ => {
77                self.end();
78                false
79            }
80        }
81    }
82
83    /// Whether a press on `(column, row)` at `now` completes a double-click,
84    /// recording it either way.
85    fn completes_double(&mut self, column: u16, row: u16, now: Instant) -> bool {
86        let doubled = match self.last {
87            Some((c, r, at)) => c == column && r == row && now.duration_since(at) < WINDOW,
88            None => false,
89        };
90        // A fired double starts over rather than becoming the first half of the
91        // next one — see the module note on the third click.
92        self.last = if doubled {
93            None
94        } else {
95            Some((column, row, now))
96        };
97        doubled
98    }
99
100    /// End the run: the next press starts a new gesture.
101    ///
102    /// Called for everything that is not a press in the editor — a key, a
103    /// paste, a drag, a scroll, a press on another panel or on the divider.
104    ///
105    /// Two mouse events are deliberately *not* among them, both because they
106    /// are noise rather than actions, and ending the run on either would mean
107    /// no double-click could ever complete:
108    ///
109    /// - **The release.** Every press is followed by one. SGR reports any
110    ///   release as button 3, so crossterm surfaces it as `Up(Left)` whichever
111    ///   button was let go — the button carries no information and the event
112    ///   is simply the tail of the press before it.
113    /// - **Pointer motion.** `EnableMouseCapture` turns on any-event tracking
114    ///   (`?1003h`), so a `Moved` arrives for every pixel of travel. Drift is
115    ///   already handled by requiring the same cell.
116    ///
117    /// A scroll, by contrast, must end it: the viewport moved, so the same
118    /// cell now shows different text, and a second press there would follow a
119    /// link the user never saw.
120    pub fn end(&mut self) {
121        self.last = None;
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    fn at(millis: u64) -> Instant {
130        // A fixed origin so the tests describe gaps rather than wall-clock time.
131        static ORIGIN: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
132        *ORIGIN.get_or_init(Instant::now) + Duration::from_millis(millis)
133    }
134
135    #[test]
136    fn one_press_is_not_a_double() {
137        let mut run = ClickRun::default();
138        assert!(!run.completes_double(4, 2, at(0)));
139    }
140
141    #[test]
142    fn two_quick_presses_on_a_cell_are_a_double() {
143        let mut run = ClickRun::default();
144        run.completes_double(4, 2, at(0));
145        assert!(run.completes_double(4, 2, at(120)));
146    }
147
148    #[test]
149    fn a_slow_second_press_is_a_fresh_first() {
150        let mut run = ClickRun::default();
151        run.completes_double(4, 2, at(0));
152        assert!(
153            !run.completes_double(4, 2, at(WINDOW.as_millis() as u64)),
154            "a gap of exactly the window is already too long"
155        );
156        assert!(
157            run.completes_double(4, 2, at(WINDOW.as_millis() as u64 + 100)),
158            "and it counts as the first of the next pair"
159        );
160    }
161
162    #[test]
163    fn a_neighbouring_cell_is_a_different_target() {
164        let mut run = ClickRun::default();
165        run.completes_double(4, 2, at(0));
166        assert!(
167            !run.completes_double(5, 2, at(50)),
168            "one column over is a second cursor placement, not a double-click"
169        );
170        run.completes_double(4, 2, at(100));
171        assert!(!run.completes_double(4, 3, at(150)), "nor is one row down");
172    }
173
174    #[test]
175    fn a_third_press_does_not_pair_with_the_second() {
176        let mut run = ClickRun::default();
177        run.completes_double(4, 2, at(0));
178        assert!(run.completes_double(4, 2, at(100)));
179        assert!(
180            !run.completes_double(4, 2, at(200)),
181            "the double already fired and opened something else under the pointer"
182        );
183    }
184
185    fn ev(kind: MouseEventKind) -> MouseEvent {
186        MouseEvent {
187            kind,
188            column: 4,
189            row: 2,
190            modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
191        }
192    }
193
194    fn press() -> MouseEvent {
195        ev(MouseEventKind::Down(MouseButton::Left))
196    }
197
198    /// The sequence a real double-click actually produces. A press is always
199    /// followed by its release, and SGR reports every release as button 3 —
200    /// which crossterm surfaces as `Up(Left)` whatever was let go. Treating
201    /// that as "something else happened" ended the run between the two
202    /// presses, so no double-click could ever complete.
203    #[test]
204    fn a_release_does_not_end_the_run() {
205        let mut run = ClickRun::default();
206        assert!(!run.observe(&press(), true, at(0)));
207        assert!(!run.observe(&ev(MouseEventKind::Up(MouseButton::Left)), true, at(10)));
208        assert!(
209            run.observe(&press(), true, at(120)),
210            "the release between the two presses is part of the gesture, not an interruption"
211        );
212    }
213
214    /// Motion is exempt for the same reason and a different cause: any-event
215    /// tracking means a `Moved` per pixel of travel.
216    #[test]
217    fn motion_does_not_end_the_run() {
218        let mut run = ClickRun::default();
219        run.observe(&press(), true, at(0));
220        run.observe(&ev(MouseEventKind::Moved), true, at(30));
221        assert!(run.observe(&press(), true, at(120)));
222    }
223
224    /// A scroll moves the viewport, so the same cell now shows different text.
225    /// A second press there would follow a link the user never saw.
226    #[test]
227    fn a_scroll_ends_the_run() {
228        let mut run = ClickRun::default();
229        run.observe(&press(), true, at(0));
230        run.observe(&ev(MouseEventKind::ScrollDown), true, at(30));
231        assert!(!run.observe(&press(), true, at(120)));
232    }
233
234    /// A drag is a selection, not half of a double-click.
235    #[test]
236    fn a_drag_ends_the_run() {
237        let mut run = ClickRun::default();
238        run.observe(&press(), true, at(0));
239        run.observe(&ev(MouseEventKind::Drag(MouseButton::Left)), true, at(30));
240        assert!(!run.observe(&press(), true, at(120)));
241    }
242
243    /// The screen decides what "in the editor" means; the run only obeys it.
244    /// A press on the drawer or the divider is not half of an editor gesture.
245    #[test]
246    fn a_press_outside_the_editor_ends_the_run() {
247        let mut run = ClickRun::default();
248        run.observe(&press(), true, at(0));
249        run.observe(&press(), false, at(30));
250        assert!(!run.observe(&press(), true, at(120)));
251    }
252
253    /// Another button is a deliberate other action — the right-click context
254    /// menu, most obviously.
255    #[test]
256    fn another_button_ends_the_run() {
257        let mut run = ClickRun::default();
258        run.observe(&press(), true, at(0));
259        run.observe(&ev(MouseEventKind::Down(MouseButton::Right)), true, at(30));
260        assert!(!run.observe(&press(), true, at(120)));
261    }
262
263    #[test]
264    fn anything_else_ends_the_run() {
265        let mut run = ClickRun::default();
266        run.completes_double(4, 2, at(0));
267        run.end();
268        assert!(
269            !run.completes_double(4, 2, at(50)),
270            "a key, a drag or a scroll between the two presses separates them"
271        );
272    }
273}