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/// — [`DOUBLE_CLICK`](crate::components::DOUBLE_CLICK), shared with the
49/// list engine's double-click.
50pub(crate) const WINDOW: Duration = crate::components::DOUBLE_CLICK;
51
52/// The clicks currently forming one gesture.
53#[derive(Debug, Default)]
54pub struct ClickRun {
55    last: Option<(u16, u16, Instant)>,
56}
57
58impl ClickRun {
59    /// Feed one mouse event to the run and report whether it completes a
60    /// double-click. `in_editor` is the screen's answer to "did this land in
61    /// the editor column" — the run does no hit-testing of its own.
62    ///
63    /// The policy lives here rather than in `EditorScreen` so it can be tested
64    /// against a real event sequence. It could not be, and a release ending
65    /// the run went unnoticed: every press is followed by one.
66    pub fn observe(&mut self, event: &MouseEvent, in_editor: bool, now: Instant) -> bool {
67        match event.kind {
68            MouseEventKind::Down(MouseButton::Left) if in_editor => {
69                self.completes_double(event.column, event.row, now)
70            }
71            // Neither is a new action, and ending the run on either would mean
72            // no double-click ever completes — see [`Self::end`].
73            MouseEventKind::Up(_) | MouseEventKind::Moved => false,
74            _ => {
75                self.end();
76                false
77            }
78        }
79    }
80
81    /// Whether a press on `(column, row)` at `now` completes a double-click,
82    /// recording it either way.
83    fn completes_double(&mut self, column: u16, row: u16, now: Instant) -> bool {
84        let doubled = match self.last {
85            Some((c, r, at)) => c == column && r == row && now.duration_since(at) < WINDOW,
86            None => false,
87        };
88        // A fired double starts over rather than becoming the first half of the
89        // next one — see the module note on the third click.
90        self.last = if doubled {
91            None
92        } else {
93            Some((column, row, now))
94        };
95        doubled
96    }
97
98    /// End the run: the next press starts a new gesture.
99    ///
100    /// Called for everything that is not a press in the editor — a key, a
101    /// paste, a drag, a scroll, a press on another panel or on the divider.
102    ///
103    /// Two mouse events are deliberately *not* among them, both because they
104    /// are noise rather than actions, and ending the run on either would mean
105    /// no double-click could ever complete:
106    ///
107    /// - **The release.** Every press is followed by one. SGR reports any
108    ///   release as button 3, so crossterm surfaces it as `Up(Left)` whichever
109    ///   button was let go — the button carries no information and the event
110    ///   is simply the tail of the press before it.
111    /// - **Pointer motion.** `EnableMouseCapture` turns on any-event tracking
112    ///   (`?1003h`), so a `Moved` arrives for every pixel of travel. Drift is
113    ///   already handled by requiring the same cell.
114    ///
115    /// A scroll, by contrast, must end it: the viewport moved, so the same
116    /// cell now shows different text, and a second press there would follow a
117    /// link the user never saw.
118    pub fn end(&mut self) {
119        self.last = None;
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn at(millis: u64) -> Instant {
128        // A fixed origin so the tests describe gaps rather than wall-clock time.
129        static ORIGIN: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
130        *ORIGIN.get_or_init(Instant::now) + Duration::from_millis(millis)
131    }
132
133    #[test]
134    fn one_press_is_not_a_double() {
135        let mut run = ClickRun::default();
136        assert!(!run.completes_double(4, 2, at(0)));
137    }
138
139    #[test]
140    fn two_quick_presses_on_a_cell_are_a_double() {
141        let mut run = ClickRun::default();
142        run.completes_double(4, 2, at(0));
143        assert!(run.completes_double(4, 2, at(120)));
144    }
145
146    #[test]
147    fn a_slow_second_press_is_a_fresh_first() {
148        let mut run = ClickRun::default();
149        run.completes_double(4, 2, at(0));
150        assert!(
151            !run.completes_double(4, 2, at(WINDOW.as_millis() as u64)),
152            "a gap of exactly the window is already too long"
153        );
154        assert!(
155            run.completes_double(4, 2, at(WINDOW.as_millis() as u64 + 100)),
156            "and it counts as the first of the next pair"
157        );
158    }
159
160    #[test]
161    fn a_neighbouring_cell_is_a_different_target() {
162        let mut run = ClickRun::default();
163        run.completes_double(4, 2, at(0));
164        assert!(
165            !run.completes_double(5, 2, at(50)),
166            "one column over is a second cursor placement, not a double-click"
167        );
168        run.completes_double(4, 2, at(100));
169        assert!(!run.completes_double(4, 3, at(150)), "nor is one row down");
170    }
171
172    #[test]
173    fn a_third_press_does_not_pair_with_the_second() {
174        let mut run = ClickRun::default();
175        run.completes_double(4, 2, at(0));
176        assert!(run.completes_double(4, 2, at(100)));
177        assert!(
178            !run.completes_double(4, 2, at(200)),
179            "the double already fired and opened something else under the pointer"
180        );
181    }
182
183    fn ev(kind: MouseEventKind) -> MouseEvent {
184        MouseEvent {
185            kind,
186            column: 4,
187            row: 2,
188            modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
189        }
190    }
191
192    fn press() -> MouseEvent {
193        ev(MouseEventKind::Down(MouseButton::Left))
194    }
195
196    /// The sequence a real double-click actually produces. A press is always
197    /// followed by its release, and SGR reports every release as button 3 —
198    /// which crossterm surfaces as `Up(Left)` whatever was let go. Treating
199    /// that as "something else happened" ended the run between the two
200    /// presses, so no double-click could ever complete.
201    #[test]
202    fn a_release_does_not_end_the_run() {
203        let mut run = ClickRun::default();
204        assert!(!run.observe(&press(), true, at(0)));
205        assert!(!run.observe(&ev(MouseEventKind::Up(MouseButton::Left)), true, at(10)));
206        assert!(
207            run.observe(&press(), true, at(120)),
208            "the release between the two presses is part of the gesture, not an interruption"
209        );
210    }
211
212    /// Motion is exempt for the same reason and a different cause: any-event
213    /// tracking means a `Moved` per pixel of travel.
214    #[test]
215    fn motion_does_not_end_the_run() {
216        let mut run = ClickRun::default();
217        run.observe(&press(), true, at(0));
218        run.observe(&ev(MouseEventKind::Moved), true, at(30));
219        assert!(run.observe(&press(), true, at(120)));
220    }
221
222    /// A scroll moves the viewport, so the same cell now shows different text.
223    /// A second press there would follow a link the user never saw.
224    #[test]
225    fn a_scroll_ends_the_run() {
226        let mut run = ClickRun::default();
227        run.observe(&press(), true, at(0));
228        run.observe(&ev(MouseEventKind::ScrollDown), true, at(30));
229        assert!(!run.observe(&press(), true, at(120)));
230    }
231
232    /// A drag is a selection, not half of a double-click.
233    #[test]
234    fn a_drag_ends_the_run() {
235        let mut run = ClickRun::default();
236        run.observe(&press(), true, at(0));
237        run.observe(&ev(MouseEventKind::Drag(MouseButton::Left)), true, at(30));
238        assert!(!run.observe(&press(), true, at(120)));
239    }
240
241    /// The screen decides what "in the editor" means; the run only obeys it.
242    /// A press on the drawer or the divider is not half of an editor gesture.
243    #[test]
244    fn a_press_outside_the_editor_ends_the_run() {
245        let mut run = ClickRun::default();
246        run.observe(&press(), true, at(0));
247        run.observe(&press(), false, at(30));
248        assert!(!run.observe(&press(), true, at(120)));
249    }
250
251    /// Another button is a deliberate other action — the right-click context
252    /// menu, most obviously.
253    #[test]
254    fn another_button_ends_the_run() {
255        let mut run = ClickRun::default();
256        run.observe(&press(), true, at(0));
257        run.observe(&ev(MouseEventKind::Down(MouseButton::Right)), true, at(30));
258        assert!(!run.observe(&press(), true, at(120)));
259    }
260
261    #[test]
262    fn anything_else_ends_the_run() {
263        let mut run = ClickRun::default();
264        run.completes_double(4, 2, at(0));
265        run.end();
266        assert!(
267            !run.completes_double(4, 2, at(50)),
268            "a key, a drag or a scroll between the two presses separates them"
269        );
270    }
271}