ad_editor/editor/
mouse.rs

1//! Handling for acme-style mouse interactions with the editor.
2use crate::{
3    config_handle,
4    dot::{Dot, Range},
5    editor::{Action, Editor},
6    fsys::LogEvent,
7    key::{MouseButton, MouseEvent, MouseEventKind, MouseMod},
8    system::System,
9    ui::{Border, SCRATCH_ID},
10};
11use ad_event::Source;
12use std::time::Instant;
13
14/// Number of milliseconds between successive mouse inputs under which we enable fast scrolling.
15const FAST_SCROLL_MS: u128 = 10;
16/// Number of rows to scroll per mouse wheel event when fast scrolling is enabled.
17const FAST_SCROLL_ROWS: usize = 5;
18
19/// Transient state that we hold to track the last mouse click we saw while
20/// we wait for it to be released or if the buffer changes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Click {
23    Text {
24        /// The button being held down
25        btn: MouseButton,
26        /// The current state of the dot associated with this click. This is updated
27        /// by Hold events and matches the buffer Dot for Left clicks. For Right and
28        /// Middle clicks this is a separate selection that is used on release.
29        selection: Range,
30        cut_handled: bool,
31        paste_handled: bool,
32    },
33    ResizeColumn {
34        last_x: usize,
35    },
36    ResizeWindow {
37        last_y: usize,
38    },
39}
40
41impl Click {
42    pub(super) fn text(btn: MouseButton, selection: Range) -> Self {
43        Self::Text {
44            btn,
45            selection,
46            cut_handled: false,
47            paste_handled: false,
48        }
49    }
50}
51
52impl<S> Editor<S>
53where
54    S: System,
55{
56    /// When scrolling with the mouse wheel we support two scroll rates based on the interval
57    /// between successive mouse clicks. If the delta between clicks is under [FAST_SCROLL_MS] we
58    /// move into fast scrolling and advance by [FAST_SCROLL_ROWS] per mouse wheel event rather
59    /// than individual rows.
60    ///
61    /// We _could_ be tracking whether or not the last click received was the same mouse wheel
62    /// event in order to avoid false positives when the user is clicking and scrolling at the same
63    /// time but in practice this doesn't seem to be required due to the short interval we are
64    /// using for detecting fast scrolling.
65    fn scroll_rows(&self, last_click_time: Instant) -> usize {
66        let delta = (self.last_click_time - last_click_time).as_millis();
67        if delta < FAST_SCROLL_MS {
68            FAST_SCROLL_ROWS
69        } else {
70            1
71        }
72    }
73
74    /// The outcome of a mouse event depends on any prior mouse state that is being held:
75    ///   - Left   (click+release):      set Cur Dot at the location of the click
76    ///   - Left   (click+hold+release): set Range Dot from click->release with click active
77    ///   - Right  (click+release):      Load Dot (expanding current dot from Cur if needed)
78    ///   - Right  (click+hold+release): Load click->release
79    ///   - Middle (click+release):      Execute Dot (expanding current dot from Cur if needed)
80    ///   - Middle (click+hold+release): Execute click->release
81    ///
82    ///   -> For Execute actions, if the current Dot is a Range then it is used as an
83    ///      argument to the command being executed
84    ///
85    /// The chording behaviour we implement follows that of acme: http://acme.cat-v.org/mouse
86    ///   - Hold Left + click Right:    Paste into selection
87    ///   - Hold Left + click Middle:   Delete selection (cut)
88    ///   - Hold Right + click either:  cancel Load
89    ///   - Hold Middle + click either: cancel Execute
90    pub(super) fn handle_mouse_event(&mut self, MouseEvent { k, m, b, x, y }: MouseEvent) {
91        use MouseButton::*;
92        use MouseEventKind::*;
93        use MouseMod::*;
94
95        let last_click_time = self.last_click_time;
96        self.last_click_time = Instant::now();
97
98        match (k, m, b) {
99            (Press, NoMod, Left) => {
100                // Left clicking while Right or Middle is held is always a cancel
101                if self.held_click.is_some() {
102                    self.held_click = None;
103                    return;
104                }
105
106                // Check border hits first as they don't attempt to modify the focus state in the
107                // same way that `set_dot_from_screen_coords` does.
108                if let Some(border) = self.layout.border_at_coords(x, y) {
109                    match border {
110                        Border::Vertical { col_idx } => {
111                            self.layout.focus_column_for_resize(col_idx);
112                            self.held_click = Some(Click::ResizeColumn { last_x: x });
113                        }
114                        Border::Horizontal { col_idx, win_idx } => {
115                            self.layout
116                                .focus_column_and_window_for_resize(col_idx, win_idx);
117                            self.held_click = Some(Click::ResizeWindow { last_y: y });
118                        }
119                    }
120                    return;
121                }
122
123                let click_in_active_buffer = self.layout.set_dot_from_screen_coords(x, y);
124                let b = self.layout.active_buffer_mut();
125                if !click_in_active_buffer && b.id != SCRATCH_ID {
126                    _ = self.tx_fsys.send(LogEvent::Focus(b.id));
127                }
128
129                if self.last_click_was_left && click_in_active_buffer {
130                    let delta = (self.last_click_time - last_click_time).as_millis();
131                    if delta < config_handle!(self).double_click_ms as u128 {
132                        b.try_expand_delimited();
133                        return;
134                    }
135                }
136
137                self.held_click = Some(Click::text(Left, b.dot.as_range()));
138                self.last_click_was_left = true;
139            }
140
141            (Press, NoMod, Right) => self.handle_right_or_middle_click(true, x, y),
142            (Press, Alt, Right) => self.handle_right_or_middle_click(true, x, y),
143
144            (Press, NoMod, Middle) | (Press, Ctrl, Left) => {
145                self.handle_right_or_middle_click(false, x, y)
146            }
147
148            (Hold, _, _) => match &mut self.held_click {
149                Some(Click::Text {
150                    btn,
151                    selection,
152                    cut_handled,
153                    paste_handled,
154                }) => {
155                    if *btn == Left && (*cut_handled || *paste_handled) {
156                        return;
157                    }
158
159                    match self.layout.try_active_cur_from_screen_coords(x, y) {
160                        Some(cur) => selection.set_active_cursor(cur),
161                        None => return,
162                    }
163
164                    if *btn == Left {
165                        self.layout.active_buffer_mut().dot = Dot::from(*selection);
166                    }
167                }
168
169                Some(Click::ResizeColumn { last_x }) => {
170                    let delta = x as i16 - *last_x as i16;
171                    if delta != 0 {
172                        self.layout.resize_active_column_against_next(delta);
173                        *last_x = x;
174                    }
175                }
176
177                Some(Click::ResizeWindow { last_y }) => {
178                    let delta = y as i16 - *last_y as i16;
179                    if delta != 0 {
180                        self.layout.resize_active_window_against_next(delta);
181                        *last_y = y;
182                    }
183                }
184
185                None => (),
186            },
187
188            (Press, _, WheelUp) => {
189                self.last_click_was_left = false;
190                self.layout
191                    .scroll_view(x, y, true, self.scroll_rows(last_click_time));
192            }
193
194            (Press, _, WheelDown) => {
195                self.last_click_was_left = false;
196                self.layout
197                    .scroll_view(x, y, false, self.scroll_rows(last_click_time));
198            }
199
200            (Release, m, b) => {
201                if let Some(Click::Text { btn, .. }) = self.held_click
202                    && btn == Left
203                    && (b == Right || b == Middle)
204                {
205                    return; // paste and cut are handled on click
206                }
207
208                let held = match self.held_click.take() {
209                    Some(held) => held,
210                    None => return,
211                };
212
213                // Only Text clicks need further processing on release
214                let (btn, mut selection, cut_handled, paste_handled) = match held {
215                    Click::Text {
216                        btn,
217                        selection,
218                        cut_handled,
219                        paste_handled,
220                    } => (btn, selection, cut_handled, paste_handled),
221                    Click::ResizeColumn { .. } | Click::ResizeWindow { .. } => return,
222                };
223
224                if btn == Left && (cut_handled || paste_handled) {
225                    return;
226                }
227
228                // Support releasing the mouse over a different window as actioning the selection
229                // as it was present in the active buffer
230                if let Some(cur) = self.layout.try_active_cur_from_screen_coords(x, y) {
231                    selection.set_active_cursor(cur);
232                }
233
234                match btn {
235                    Left | WheelUp | WheelDown => (),
236                    Right | Middle => {
237                        self.handle_right_or_middle_release(btn == Right, selection, m == Alt)
238                    }
239                }
240            }
241
242            _ => (),
243        }
244    }
245
246    #[inline]
247    fn handle_right_or_middle_click(&mut self, is_right: bool, x: usize, y: usize) {
248        use MouseButton::*;
249
250        self.last_click_was_left = false;
251
252        match &mut self.held_click {
253            Some(Click::Text {
254                btn,
255                selection,
256                cut_handled,
257                paste_handled,
258            }) => {
259                // Mouse chords execute on the click of the second button rather than the release
260                if *btn == Left {
261                    if is_right && !*paste_handled {
262                        *paste_handled = true;
263                        self.paste_from_clipboard(Source::Mouse);
264                    } else if !is_right && !*cut_handled {
265                        *selection = self.layout.active_buffer().dot.as_range();
266                        *cut_handled = true;
267                        self.forward_action_to_active_buffer(Action::Delete, Source::Mouse);
268                    }
269                } else if (is_right && *btn == Middle) || (!is_right && *btn == Right) {
270                    self.held_click = None;
271                }
272            }
273
274            Some(_) => {
275                // ResizeColumn or ResizeWindow - cancel on other button press
276                self.held_click = None;
277            }
278
279            None => {
280                let btn = if is_right { Right } else { Middle };
281                let (id, cur) = self.layout.focus_cur_from_screen_coords(x, y);
282                _ = self.tx_fsys.send(LogEvent::Focus(id));
283                self.held_click = Some(Click::text(btn, Range::from_cursors(cur, cur, false)));
284            }
285        };
286    }
287
288    #[inline]
289    fn handle_right_or_middle_release(
290        &mut self,
291        is_right: bool,
292        selection: Range,
293        load_in_new_window: bool,
294    ) {
295        if selection.start != selection.end {
296            // In the case where the click selection is a range we Load/Execute it directly.
297            // For Middle clicks, if there is also a range dot in the buffer then that is
298            // used as an argument to the command being executed.
299            if is_right {
300                self.layout.active_buffer_mut().dot = Dot::from(selection);
301                self.default_load_dot(Source::Mouse, load_in_new_window);
302            } else {
303                let dot = self.layout.active_buffer().dot;
304                self.layout.active_buffer_mut().dot = Dot::from(selection);
305
306                if dot.is_range() {
307                    // Execute as if the click selection was dot then reset dot
308                    let arg = dot.content(self.layout.active_buffer()).trim().to_string();
309                    self.default_execute_dot(Some((dot.as_range(), arg)), Source::Mouse);
310                    self.layout.active_buffer_mut().dot = dot;
311                } else {
312                    self.default_execute_dot(None, Source::Mouse);
313                }
314            }
315        } else {
316            // In the case where the click selection was a Cur rather than a Range we
317            // set the buffer dot to the click location if it is outside of the current buffer
318            // dot (and allow smart expand to handle generating the selection) before we Load/Execute
319            if !self.layout.active_buffer().dot.contains(&selection.start) {
320                self.layout.active_buffer_mut().dot = Dot::from(selection.start);
321            }
322
323            if is_right {
324                self.default_load_dot(Source::Mouse, load_in_new_window);
325            } else {
326                self.default_execute_dot(None, Source::Mouse);
327            }
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::{
336        dot::{Cur, Range},
337        editor::EditorMode,
338        fsys::InputFilter,
339        key::{MouseButton::*, MouseEvent, MouseEventKind::*, MouseMod::*},
340        log::LogBuffer,
341    };
342    use ad_event::{FsysEvent, Kind, Source};
343    use simple_test_case::test_case;
344    use std::{io, sync::mpsc::channel};
345
346    #[derive(Debug, Default)]
347    struct TestSystem {
348        clipboard: String,
349    }
350
351    impl System for TestSystem {
352        fn set_clipboard(&mut self, s: &str) -> io::Result<()> {
353            self.clipboard = s.to_string();
354
355            Ok(())
356        }
357
358        fn read_clipboard(&self) -> io::Result<String> {
359            Ok(self.clipboard.clone())
360        }
361
362        fn store_child_handle(&mut self, _: &str, _: std::process::Child) {}
363        fn running_children(&self) -> Vec<String> {
364            vec![]
365        }
366        fn cleanup_child(&mut self, _: u32) {}
367        fn kill_child(&mut self, _: usize) {}
368    }
369
370    fn r(start: usize, end: usize, start_active: bool) -> Range {
371        Range {
372            start: Cur { idx: start },
373            end: Cur { idx: end },
374            start_active,
375        }
376    }
377
378    // NOTE: All mouse tests use 1-indexed terminal coordinates
379
380    #[test_case(
381        &[
382            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
383            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
384            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
385        ],
386        None,
387        "some",
388        "some text to test with",
389        "X",
390        &[];
391        "left click drag selection complete"
392    )]
393    #[test_case(
394        &[
395            MouseEvent { k: Press, m: NoMod, b: Right, x: 3, y: 1 },
396            MouseEvent { k: Hold, m: NoMod, b: Right, x: 7, y: 1 },
397            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
398        ],
399        None,
400        "some",
401        "some text to test with",
402        "X",
403        &[
404            FsysEvent::new(Source::Mouse, Kind::LoadBody, 0, 3, "some"),
405        ];
406        "right click drag selection complete"
407    )]
408    #[test_case(
409        &[
410            MouseEvent { k: Press, m: NoMod, b: Middle, x: 3, y: 1 },
411            MouseEvent { k: Hold, m: NoMod, b: Middle, x: 7, y: 1 },
412            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
413        ],
414        None,
415        "some",
416        "some text to test with",
417        "X",
418        &[
419            FsysEvent::new(Source::Mouse, Kind::ExecuteBody, 0, 3, "some"),
420        ];
421        "middle click drag selection complete"
422    )]
423    #[test_case(
424        &[
425            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
426            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
427        ],
428        Some(Click::text(Left, r(0, 3, false))),
429        "some",
430        "some text to test with",
431        "X",
432        &[];
433        "left click drag selection without release"
434    )]
435    #[test_case(
436        &[
437            MouseEvent { k: Press, m: NoMod, b: Right, x: 3, y: 1 },
438            MouseEvent { k: Hold, m: NoMod, b: Right, x: 7, y: 1 },
439        ],
440        Some(Click::text(Right, r(0, 3, false))),
441        "t",  // default dot position
442        "some text to test with",
443        "X",
444        &[];
445        "right click drag selection without release"
446    )]
447    #[test_case(
448        &[
449            MouseEvent { k: Press, m: NoMod, b: Middle, x: 3, y: 1 },
450            MouseEvent { k: Hold, m: NoMod, b: Middle, x: 7, y: 1 },
451        ],
452        Some(Click::text(Middle, r(0, 3, false))),
453        "t",  // default dot position
454        "some text to test with",
455        "X",
456        &[];
457        "middle click drag selection without release"
458    )]
459    #[test_case(
460        &[
461            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
462            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
463            MouseEvent { k: Press, m: NoMod, b: Right, x: 4, y: 1 },
464            MouseEvent { k: Release, m: NoMod, b: Right, x: 4, y: 1 },
465        ],
466        None,
467        "some",
468        "some text to test with",
469        "X",
470        &[
471            FsysEvent::new(Source::Mouse, Kind::LoadBody, 0, 3, "some"),
472        ];
473        "right click expand in existing selection"
474    )]
475    #[test_case(
476        &[
477            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
478            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
479            MouseEvent { k: Press, m: NoMod, b: Middle, x: 4, y: 1 },
480            MouseEvent { k: Release, m: NoMod, b: Middle, x: 4, y: 1 },
481        ],
482        None,
483        "some",
484        "some text to test with",
485        "X",
486        &[
487            FsysEvent::new(Source::Mouse, Kind::ExecuteBody, 0, 3, "some"),
488        ];
489        "middle click expand in existing selection"
490    )]
491    #[test_case(
492        &[
493            MouseEvent { k: Press, m: NoMod, b: Left, x: 9, y: 1 },
494            MouseEvent { k: Hold, m: NoMod, b: Left, x: 12, y: 1 },
495            MouseEvent { k: Release, m: NoMod, b: Left, x: 12, y: 1 },
496            MouseEvent { k: Press, m: NoMod, b: Middle, x: 3, y: 1 },
497            MouseEvent { k: Hold, m: NoMod, b: Middle, x: 7, y: 1 },
498            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
499        ],
500        None,
501        "text",
502        "some text to test with",
503        "X",
504        &[
505            FsysEvent::new(Source::Mouse, Kind::ChordedArgument, 5, 8, "text"),
506            FsysEvent::new(Source::Mouse, Kind::ExecuteBody, 0, 3, "some"),
507        ];
508        "middle click with dot arg"
509    )]
510    #[test_case(
511        &[
512            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
513            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
514            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
515            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
516            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
517        ],
518        None,
519        " ",
520        " text to test with",
521        "some",
522        &[
523            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
524        ];
525        "chord cut"
526    )]
527    #[test_case(
528        &[
529            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
530            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
531            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
532            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
533            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
534        ],
535        None,
536        " ",
537        "X text to test with",
538        "X",
539        &[
540            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
541            FsysEvent::new(Source::Mouse, Kind::InsertBody, 0, 1, "X"),
542        ];
543        "chord paste"
544    )]
545    #[test_case(
546        &[
547            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
548            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
549            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
550            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
551            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
552            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
553            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
554        ],
555        None,
556        " ",
557        "some text to test with",
558        "some",
559        &[
560            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
561            FsysEvent::new(Source::Mouse, Kind::InsertBody, 0, 4, "some"),
562        ];
563        "chord cut then paste"
564    )]
565    // not 100% sure this is correct at the moment in terms of what the
566    // selection and clipboard end up as
567    #[test_case(
568        &[
569            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
570            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
571            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
572            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
573            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
574            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
575            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
576        ],
577        None,
578        "t",
579        "Xtext to test with",
580        "X",
581        &[
582            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
583            FsysEvent::new(Source::Mouse, Kind::InsertBody, 0, 1, "X"),
584            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 1, 2, " "),
585        ];
586        "chord paste then cut"
587    )]
588    #[test_case(
589        &[
590            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
591            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
592            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
593            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
594            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
595            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
596            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
597        ],
598        None,
599        " ",
600        " text to test with",
601        "some",
602        &[
603            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
604        ];
605        "repeated chord cut"
606    )]
607    #[test_case(
608        &[
609            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
610            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
611            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
612            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
613            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
614            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
615            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
616        ],
617        None,
618        " ",
619        "X text to test with",
620        "X",
621        &[
622            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
623            FsysEvent::new(Source::Mouse, Kind::InsertBody, 0, 1, "X"),
624        ];
625        "repeated chord paste"
626    )]
627    #[test_case(
628        &[
629            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
630            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
631            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
632            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
633            MouseEvent { k: Hold, m: NoMod, b: Left, x: 3, y: 1 },
634            MouseEvent { k: Release, m: NoMod, b: Left, x: 3, y: 1 },
635        ],
636        None,
637        " ",
638        "X text to test with",
639        "X",
640        &[
641            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
642            FsysEvent::new(Source::Mouse, Kind::InsertBody, 0, 1, "X"),
643        ];
644        "motion after chord paste is ignored"
645    )]
646    #[test_case(
647        &[
648            MouseEvent { k: Press, m: NoMod, b: Left, x: 3, y: 1 },
649            MouseEvent { k: Hold, m: NoMod, b: Left, x: 7, y: 1 },
650            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
651            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
652            MouseEvent { k: Hold, m: NoMod, b: Left, x: 2, y: 1 },
653            MouseEvent { k: Release, m: NoMod, b: Left, x: 2, y: 1 },
654        ],
655        None,
656        " ",
657        " text to test with",
658        "some",
659        &[
660            FsysEvent::new(Source::Mouse, Kind::DeleteBody, 0, 4, ""),
661        ];
662        "motion after chord cut is ignored"
663    )]
664    #[test_case(
665        &[
666            MouseEvent { k: Press, m: NoMod, b: Right, x: 3, y: 1 },
667            MouseEvent { k: Hold, m: NoMod, b: Right, x: 7, y: 1 },
668            MouseEvent { k: Press, m: NoMod, b: Left, x: 7, y: 1 },
669            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
670            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
671        ],
672        None,
673        "t",
674        "some text to test with",
675        "X",
676        &[];
677        "right click cancel with left"
678    )]
679    #[test_case(
680        &[
681            MouseEvent { k: Press, m: NoMod, b: Right, x: 3, y: 1 },
682            MouseEvent { k: Hold, m: NoMod, b: Right, x: 7, y: 1 },
683            MouseEvent { k: Press, m: NoMod, b: Middle, x: 7, y: 1 },
684            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
685            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
686        ],
687        None,
688        "t",
689        "some text to test with",
690        "X",
691        &[];
692        "right click cancel with middle"
693    )]
694    #[test_case(
695        &[
696            MouseEvent { k: Press, m: NoMod, b: Middle, x: 3, y: 1 },
697            MouseEvent { k: Hold, m: NoMod, b: Middle, x: 7, y: 1 },
698            MouseEvent { k: Press, m: NoMod, b: Left, x: 7, y: 1 },
699            MouseEvent { k: Release, m: NoMod, b: Left, x: 7, y: 1 },
700            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
701        ],
702        None,
703        "t",
704        "some text to test with",
705        "X",
706        &[];
707        "middle click cancel with left"
708    )]
709    #[test_case(
710        &[
711            MouseEvent { k: Press, m: NoMod, b: Middle, x: 3, y: 1 },
712            MouseEvent { k: Hold, m: NoMod, b: Middle, x: 7, y: 1 },
713            MouseEvent { k: Press, m: NoMod, b: Right, x: 7, y: 1 },
714            MouseEvent { k: Release, m: NoMod, b: Right, x: 7, y: 1 },
715            MouseEvent { k: Release, m: NoMod, b: Middle, x: 7, y: 1 },
716        ],
717        None,
718        "t",
719        "some text to test with",
720        "X",
721        &[];
722        "middle click cancel with right"
723    )]
724    #[test_case(
725        &[
726            MouseEvent { k: Press, m: NoMod, b: Left, x: 9, y: 1 },
727            MouseEvent { k: Release, m: NoMod, b: Left, x: 9, y: 1 },
728            MouseEvent { k: Press, m: NoMod, b: Left, x: 9, y: 1 },
729            MouseEvent { k: Release, m: NoMod, b: Left, x: 9, y: 1 },
730        ],
731        None,
732        "text",
733        "some text to test with",
734        "X",
735        &[];
736        "double left click"
737    )]
738    #[test]
739    fn mouse_interactions_work(
740        evts: &[MouseEvent],
741        click: Option<Click>,
742        dot: &str,
743        content: &str,
744        clipboard: &str,
745        fsys_events: &[FsysEvent],
746    ) {
747        let mut ed = Editor::new_with_system(
748            Default::default(),
749            Default::default(),
750            EditorMode::Headless,
751            LogBuffer::default(),
752            TestSystem {
753                clipboard: "X".to_string(),
754            },
755        );
756        ed.update_window_size(100, 80); // Needed in order to keep clicks in bounds
757        ed.layout
758            .open_virtual("test", "some text to test with", false);
759        ed.layout.active_buffer_mut().dot = Dot::Cur { c: Cur { idx: 5 } };
760
761        // attach an input filter so we can intercept load and execute events
762        let (tx, rx) = channel();
763        let filter = InputFilter::new(tx);
764        ed.layout
765            .try_set_input_filter(ed.active_buffer_id(), filter);
766
767        for evt in evts.iter() {
768            ed.handle_mouse_event(*evt);
769        }
770
771        let recvd_fsys_events: Vec<_> = rx.try_iter().collect();
772        let b = ed.layout.active_buffer();
773
774        assert_eq!(ed.held_click, click, "click");
775        assert_eq!(b.dot.content(b), dot, "dot content");
776        assert_eq!(b.str_contents(), content, "buffer content");
777        assert_eq!(ed.system.clipboard, clipboard, "clipboard content");
778        assert_eq!(fsys_events, &recvd_fsys_events, "fsys events");
779    }
780
781    // TODO:
782    //   - test that each mouse click and scroll informs fsys that the appropriate
783    //     window is focused
784    //   - test that focus events aren't sent when they aren't needed
785
786    fn editor_with_layout(n_cols: usize, n_wins: usize) -> Editor<TestSystem> {
787        let mut ed = Editor::new_with_system(
788            Default::default(),
789            Default::default(),
790            EditorMode::Headless,
791            LogBuffer::default(),
792            TestSystem::default(),
793        );
794        ed.update_window_size(80, 100);
795        ed.layout.open_virtual("test", "test content", false);
796
797        for _ in 1..n_cols {
798            ed.layout.new_column();
799        }
800        for _ in 1..n_wins {
801            ed.layout.new_window();
802        }
803
804        ed
805    }
806
807    #[test_case(5; "drag right grows first column")]
808    #[test_case(-5; "drag left shrinks first column")]
809    #[test]
810    fn resize_column_drag(delta: i16) {
811        let mut ed = editor_with_layout(2, 1);
812        let initial_sizes = ed.layout.column_widths();
813        let border_x = initial_sizes[0] + 1;
814
815        ed.handle_mouse_event(MouseEvent {
816            k: Press,
817            m: NoMod,
818            b: Left,
819            x: border_x,
820            y: 10,
821        });
822        assert!(matches!(ed.held_click, Some(Click::ResizeColumn { .. })));
823
824        let target_x = (border_x as i16 + delta) as usize;
825        ed.handle_mouse_event(MouseEvent {
826            k: Hold,
827            m: NoMod,
828            b: Left,
829            x: target_x,
830            y: 10,
831        });
832        ed.handle_mouse_event(MouseEvent {
833            k: Release,
834            m: NoMod,
835            b: Left,
836            x: target_x,
837            y: 10,
838        });
839
840        assert!(ed.held_click.is_none());
841
842        let final_sizes = ed.layout.column_widths();
843        assert_eq!(
844            final_sizes[0] as i16,
845            initial_sizes[0] as i16 + delta,
846            "first column"
847        );
848        assert_eq!(
849            final_sizes[1] as i16,
850            initial_sizes[1] as i16 - delta,
851            "second column"
852        );
853    }
854
855    #[test_case(5; "drag down grows first window")]
856    #[test_case(-5; "drag up shrinks first window")]
857    #[test]
858    fn resize_window_drag(delta: i16) {
859        let mut ed = editor_with_layout(1, 2);
860        let initial_sizes = ed.layout.window_heights();
861        let border_y = initial_sizes[0] + 1;
862
863        ed.handle_mouse_event(MouseEvent {
864            k: Press,
865            m: NoMod,
866            b: Left,
867            x: 10,
868            y: border_y,
869        });
870        assert!(matches!(ed.held_click, Some(Click::ResizeWindow { .. })));
871
872        let target_y = (border_y as i16 + delta) as usize;
873        ed.handle_mouse_event(MouseEvent {
874            k: Hold,
875            m: NoMod,
876            b: Left,
877            x: 10,
878            y: target_y,
879        });
880        ed.handle_mouse_event(MouseEvent {
881            k: Release,
882            m: NoMod,
883            b: Left,
884            x: 10,
885            y: target_y,
886        });
887
888        assert!(ed.held_click.is_none());
889
890        let final_sizes = ed.layout.window_heights();
891        assert_eq!(
892            final_sizes[0] as i16,
893            initial_sizes[0] as i16 + delta,
894            "first window"
895        );
896        assert_eq!(
897            final_sizes[1] as i16,
898            initial_sizes[1] as i16 - delta,
899            "second window"
900        );
901    }
902
903    #[test]
904    fn resize_sets_correct_focus() {
905        let mut ed = editor_with_layout(3, 1);
906
907        ed.layout.focus_column_for_resize(0);
908        assert_eq!(ed.layout.cols_before_focus(), 0, "initially on column 0");
909
910        let widths = ed.layout.column_widths();
911        let border_x = widths[0] + widths[1] + 2;
912
913        ed.handle_mouse_event(MouseEvent {
914            k: Press,
915            m: NoMod,
916            b: Left,
917            x: border_x,
918            y: 10,
919        });
920
921        assert_eq!(ed.layout.cols_before_focus(), 1, "focus moved to column 1");
922    }
923
924    #[test]
925    fn click_on_border_without_drag_is_noop() {
926        let mut ed = editor_with_layout(2, 1);
927        let initial_sizes = ed.layout.column_widths();
928        let border_x = initial_sizes[0] + 1;
929
930        ed.handle_mouse_event(MouseEvent {
931            k: Press,
932            m: NoMod,
933            b: Left,
934            x: border_x,
935            y: 10,
936        });
937        ed.handle_mouse_event(MouseEvent {
938            k: Release,
939            m: NoMod,
940            b: Left,
941            x: border_x,
942            y: 10,
943        });
944
945        let final_sizes = ed.layout.column_widths();
946        assert_eq!(initial_sizes, final_sizes, "sizes unchanged");
947    }
948
949    #[test_case(Right; "right click")]
950    #[test_case(Middle; "middle click")]
951    #[test]
952    fn non_left_click_on_border_is_noop(btn: MouseButton) {
953        let mut ed = editor_with_layout(2, 1);
954        let initial_sizes = ed.layout.column_widths();
955        let border_x = initial_sizes[0] + 1;
956
957        ed.handle_mouse_event(MouseEvent {
958            k: Press,
959            m: NoMod,
960            b: btn,
961            x: border_x,
962            y: 10,
963        });
964        ed.handle_mouse_event(MouseEvent {
965            k: Release,
966            m: NoMod,
967            b: btn,
968            x: border_x,
969            y: 10,
970        });
971
972        assert!(ed.held_click.is_none());
973
974        let final_sizes = ed.layout.column_widths();
975        assert_eq!(initial_sizes, final_sizes, "sizes unchanged");
976    }
977
978    #[test]
979    fn resize_cancelled_by_right_click() {
980        let mut ed = editor_with_layout(2, 1);
981        let initial_sizes = ed.layout.column_widths();
982
983        let border_x = initial_sizes[0] + 1;
984
985        ed.handle_mouse_event(MouseEvent {
986            k: Press,
987            m: NoMod,
988            b: Left,
989            x: border_x,
990            y: 10,
991        });
992        assert!(matches!(ed.held_click, Some(Click::ResizeColumn { .. })));
993
994        ed.handle_mouse_event(MouseEvent {
995            k: Press,
996            m: NoMod,
997            b: Right,
998            x: border_x,
999            y: 10,
1000        });
1001        assert!(ed.held_click.is_none(), "resize cancelled");
1002
1003        let final_sizes = ed.layout.column_widths();
1004        assert_eq!(initial_sizes, final_sizes, "sizes unchanged after cancel");
1005    }
1006}