@@ -1,3 +1,5 @@ |
| 1 | +use std::time::{Duration, Instant}; |
| 2 | + |
| 1 | 3 | use crossterm::event::{ |
| 2 | 4 | KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, |
| 3 | 5 | }; |
@@ -12,6 +14,8 @@ use crate::{ |
| 12 | 14 | calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK}, |
| 13 | 15 | }; |
| 14 | 16 | |
| 17 | +const MOUSE_DOUBLE_CLICK_TIMEOUT: Duration = Duration::from_millis(500); |
| 18 | + |
| 15 | 19 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 16 | 20 | pub enum ViewMode { |
| 17 | 21 | Month, |
@@ -1816,7 +1820,13 @@ impl KeyboardInput { |
| 1816 | 1820 | |
| 1817 | 1821 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 1818 | 1822 | pub struct MouseInput { |
| 1819 | | - pending_open_date: Option<CalendarDate>, |
| 1823 | + last_left_click: Option<MouseClick>, |
| 1824 | +} |
| 1825 | + |
| 1826 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1827 | +struct MouseClick { |
| 1828 | + date: CalendarDate, |
| 1829 | + at: Instant, |
| 1820 | 1830 | } |
| 1821 | 1831 | |
| 1822 | 1832 | impl MouseInput { |
@@ -1825,6 +1835,16 @@ impl MouseInput { |
| 1825 | 1835 | mouse: MouseEvent, |
| 1826 | 1836 | target_date: Option<CalendarDate>, |
| 1827 | 1837 | selected_date: CalendarDate, |
| 1838 | + ) -> AppAction { |
| 1839 | + self.translate_at(mouse, target_date, selected_date, Instant::now()) |
| 1840 | + } |
| 1841 | + |
| 1842 | + fn translate_at( |
| 1843 | + &mut self, |
| 1844 | + mouse: MouseEvent, |
| 1845 | + target_date: Option<CalendarDate>, |
| 1846 | + selected_date: CalendarDate, |
| 1847 | + now: Instant, |
| 1828 | 1848 | ) -> AppAction { |
| 1829 | 1849 | match mouse.kind { |
| 1830 | 1850 | MouseEventKind::Down(MouseButton::Left) => { |
@@ -1833,14 +1853,27 @@ impl MouseInput { |
| 1833 | 1853 | return AppAction::Noop; |
| 1834 | 1854 | }; |
| 1835 | 1855 | |
| 1836 | | - if self.pending_open_date == Some(target_date) && selected_date == target_date { |
| 1856 | + let is_double_click = self |
| 1857 | + .last_left_click |
| 1858 | + .map(|click| { |
| 1859 | + click.date == target_date |
| 1860 | + && now.saturating_duration_since(click.at) <= MOUSE_DOUBLE_CLICK_TIMEOUT |
| 1861 | + }) |
| 1862 | + .unwrap_or(false); |
| 1863 | + |
| 1864 | + self.last_left_click = Some(MouseClick { |
| 1865 | + date: target_date, |
| 1866 | + at: now, |
| 1867 | + }); |
| 1868 | + |
| 1869 | + if is_double_click && selected_date == target_date { |
| 1837 | 1870 | self.clear(); |
| 1838 | 1871 | AppAction::OpenDay |
| 1839 | 1872 | } else { |
| 1840 | | - self.pending_open_date = Some(target_date); |
| 1841 | 1873 | AppAction::SelectDate(target_date) |
| 1842 | 1874 | } |
| 1843 | 1875 | } |
| 1876 | + MouseEventKind::Up(_) => AppAction::Noop, |
| 1844 | 1877 | _ => { |
| 1845 | 1878 | self.clear(); |
| 1846 | 1879 | AppAction::Noop |
@@ -1849,7 +1882,7 @@ impl MouseInput { |
| 1849 | 1882 | } |
| 1850 | 1883 | |
| 1851 | 1884 | pub fn clear(&mut self) { |
| 1852 | | - self.pending_open_date = None; |
| 1885 | + self.last_left_click = None; |
| 1853 | 1886 | } |
| 1854 | 1887 | } |
| 1855 | 1888 | |
@@ -2785,23 +2818,61 @@ mod tests { |
| 2785 | 2818 | } |
| 2786 | 2819 | |
| 2787 | 2820 | #[test] |
| 2788 | | - fn mouse_click_selects_then_second_click_opens_day() { |
| 2821 | + fn mouse_double_click_selects_then_opens_day() { |
| 2789 | 2822 | let target = date(2026, Month::April, 18); |
| 2790 | 2823 | let mut app = AppState::new(date(2026, Month::April, 23)); |
| 2791 | 2824 | let mut input = MouseInput::default(); |
| 2825 | + let start = Instant::now(); |
| 2792 | 2826 | |
| 2793 | | - let action = input.translate(mouse_down(10, 10), Some(target), app.selected_date()); |
| 2827 | + let action = |
| 2828 | + input.translate_at(mouse_down(10, 10), Some(target), app.selected_date(), start); |
| 2794 | 2829 | app.apply(action); |
| 2795 | 2830 | |
| 2796 | 2831 | assert_eq!(app.selected_date(), target); |
| 2797 | 2832 | assert_eq!(app.view_mode(), ViewMode::Month); |
| 2798 | 2833 | |
| 2799 | | - let action = input.translate(mouse_down(10, 10), Some(target), app.selected_date()); |
| 2834 | + let action = input.translate_at( |
| 2835 | + mouse_event(MouseEventKind::Up(MouseButton::Left), 10, 10), |
| 2836 | + Some(target), |
| 2837 | + app.selected_date(), |
| 2838 | + start + Duration::from_millis(40), |
| 2839 | + ); |
| 2840 | + app.apply(action); |
| 2841 | + assert_eq!(app.view_mode(), ViewMode::Month); |
| 2842 | + |
| 2843 | + let action = input.translate_at( |
| 2844 | + mouse_down(10, 10), |
| 2845 | + Some(target), |
| 2846 | + app.selected_date(), |
| 2847 | + start + Duration::from_millis(120), |
| 2848 | + ); |
| 2800 | 2849 | app.apply(action); |
| 2801 | 2850 | |
| 2802 | 2851 | assert_eq!(app.view_mode(), ViewMode::Day); |
| 2803 | 2852 | } |
| 2804 | 2853 | |
| 2854 | + #[test] |
| 2855 | + fn slow_second_mouse_click_only_reselects_date() { |
| 2856 | + let target = date(2026, Month::April, 18); |
| 2857 | + let mut app = AppState::new(date(2026, Month::April, 23)); |
| 2858 | + let mut input = MouseInput::default(); |
| 2859 | + let start = Instant::now(); |
| 2860 | + |
| 2861 | + let action = |
| 2862 | + input.translate_at(mouse_down(10, 10), Some(target), app.selected_date(), start); |
| 2863 | + app.apply(action); |
| 2864 | + let action = input.translate_at( |
| 2865 | + mouse_down(10, 10), |
| 2866 | + Some(target), |
| 2867 | + app.selected_date(), |
| 2868 | + start + MOUSE_DOUBLE_CLICK_TIMEOUT + Duration::from_millis(1), |
| 2869 | + ); |
| 2870 | + app.apply(action); |
| 2871 | + |
| 2872 | + assert_eq!(app.selected_date(), target); |
| 2873 | + assert_eq!(app.view_mode(), ViewMode::Month); |
| 2874 | + } |
| 2875 | + |
| 2805 | 2876 | #[test] |
| 2806 | 2877 | fn mouse_clicks_without_a_date_target_are_ignored() { |
| 2807 | 2878 | let mut input = MouseInput::default(); |