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}