tenseleyflow/rcal / 04203a9

Browse files

Use double click to open day view

Authored by espadonne
SHA
04203a9e215116fb73c717abd9ddcd648bcbf6b8
Parents
5475c02
Tree
5c461e2

4 changed files

StatusFile+-
M README.md 2 2
M src/app.rs 78 7
M src/cli.rs 1 1
M src/tui.rs 1 1
README.mdmodified
@@ -62,8 +62,8 @@ access.
6262
   16.
6363
 - Weekday initials jump within the selected week. Use `tu` for Tuesday, `th`
6464
   for Thursday, `su` for Sunday, and `sa` for Saturday.
65
-- Left click selects a visible date; left click the selected date again to open
66
-  day view.
65
+- Left click selects a visible date; double-click a visible date to open day
66
+  view.
6767
 
6868
 Created events are stored locally as JSON and are shown immediately in month,
6969
 week, and day views. The create/edit modal supports timed events, single-day
src/app.rsmodified
@@ -1,3 +1,5 @@
1
+use std::time::{Duration, Instant};
2
+
13
 use crossterm::event::{
24
     KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
35
 };
@@ -12,6 +14,8 @@ use crate::{
1214
     calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK},
1315
 };
1416
 
17
+const MOUSE_DOUBLE_CLICK_TIMEOUT: Duration = Duration::from_millis(500);
18
+
1519
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1620
 pub enum ViewMode {
1721
     Month,
@@ -1816,7 +1820,13 @@ impl KeyboardInput {
18161820
 
18171821
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
18181822
 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,
18201830
 }
18211831
 
18221832
 impl MouseInput {
@@ -1825,6 +1835,16 @@ impl MouseInput {
18251835
         mouse: MouseEvent,
18261836
         target_date: Option<CalendarDate>,
18271837
         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,
18281848
     ) -> AppAction {
18291849
         match mouse.kind {
18301850
             MouseEventKind::Down(MouseButton::Left) => {
@@ -1833,14 +1853,27 @@ impl MouseInput {
18331853
                     return AppAction::Noop;
18341854
                 };
18351855
 
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 {
18371870
                     self.clear();
18381871
                     AppAction::OpenDay
18391872
                 } else {
1840
-                    self.pending_open_date = Some(target_date);
18411873
                     AppAction::SelectDate(target_date)
18421874
                 }
18431875
             }
1876
+            MouseEventKind::Up(_) => AppAction::Noop,
18441877
             _ => {
18451878
                 self.clear();
18461879
                 AppAction::Noop
@@ -1849,7 +1882,7 @@ impl MouseInput {
18491882
     }
18501883
 
18511884
     pub fn clear(&mut self) {
1852
-        self.pending_open_date = None;
1885
+        self.last_left_click = None;
18531886
     }
18541887
 }
18551888
 
@@ -2785,23 +2818,61 @@ mod tests {
27852818
     }
27862819
 
27872820
     #[test]
2788
-    fn mouse_click_selects_then_second_click_opens_day() {
2821
+    fn mouse_double_click_selects_then_opens_day() {
27892822
         let target = date(2026, Month::April, 18);
27902823
         let mut app = AppState::new(date(2026, Month::April, 23));
27912824
         let mut input = MouseInput::default();
2825
+        let start = Instant::now();
27922826
 
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);
27942829
         app.apply(action);
27952830
 
27962831
         assert_eq!(app.selected_date(), target);
27972832
         assert_eq!(app.view_mode(), ViewMode::Month);
27982833
 
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
+        );
28002849
         app.apply(action);
28012850
 
28022851
         assert_eq!(app.view_mode(), ViewMode::Day);
28032852
     }
28042853
 
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
+
28052876
     #[test]
28062877
     fn mouse_clicks_without_a_date_target_are_ignored() {
28072878
         let mut input = MouseInput::default();
src/cli.rsmodified
@@ -50,7 +50,7 @@ const HELP: &str = concat!(
5050
     "  Digits jump immediately; a quick second digit refines the selected day.\n",
5151
     "  Weekday initials jump within the selected week.\n\n",
5252
     "Mouse:\n",
53
-    "  Left click selects a visible date; left click the selected date again to open day view.\n\n",
53
+    "  Left click selects a visible date; double-click a visible date to open day view.\n\n",
5454
     "Notes:\n",
5555
     "  Real calendar-account integration and reminder notifications are not in this milestone.\n",
5656
 );
src/tui.rsmodified
@@ -1166,7 +1166,7 @@ fn help_rows(view_mode: ViewMode) -> &'static [(&'static str, &'static str)] {
11661166
             ("Weekdays", "Jump within the selected week"),
11671167
             ("Enter", "Open the focused day view"),
11681168
             ("+", "Create an event on the selected date"),
1169
-            ("Mouse", "Select a date; click it again to open"),
1169
+            ("Mouse", "Select a date; double-click to open"),
11701170
             ("?", "Close this help"),
11711171
             ("q", "Quit"),
11721172
         ],