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}