tenseleyflow/rcal / 5475c02

Browse files

Add contextual help modal

Authored by espadonne
SHA
5475c027352bb09cf6ac22bf2aad2e312f92d546
Parents
9cb416e
Tree
66b16ff

4 changed files

StatusFile+-
M README.md 1 0
M src/app.rs 91 1
M src/cli.rs 9 2
M src/tui.rs 200 6
README.mdmodified
@@ -49,6 +49,7 @@ access.
4949
 ## Controls
5050
 
5151
 - Arrow keys move the selected date.
52
+- `?` opens contextual help.
5253
 - `+` opens the Create event modal.
5354
 - In day view, `d` opens the Delete confirmation for the selected local event.
5455
 - `Enter` opens the focused day view.
src/app.rsmodified
@@ -26,6 +26,7 @@ pub struct AppState {
2626
     create_form: Option<CreateEventForm>,
2727
     recurrence_choice: Option<RecurrenceEditChoice>,
2828
     delete_choice: Option<EventDeleteChoice>,
29
+    help_open: bool,
2930
     selected_day_event_id: Option<String>,
3031
     should_quit: bool,
3132
 }
@@ -43,6 +44,7 @@ impl AppState {
4344
             create_form: None,
4445
             recurrence_choice: None,
4546
             delete_choice: None,
47
+            help_open: false,
4648
             selected_day_event_id: None,
4749
             should_quit: false,
4850
         }
@@ -92,6 +94,10 @@ impl AppState {
9294
         self.delete_choice.is_some()
9395
     }
9496
 
97
+    pub const fn is_showing_help(&self) -> bool {
98
+        self.help_open
99
+    }
100
+
95101
     pub fn close_create_form(&mut self) {
96102
         self.create_form = None;
97103
     }
@@ -104,6 +110,10 @@ impl AppState {
104110
         self.delete_choice = None;
105111
     }
106112
 
113
+    pub fn close_help(&mut self) {
114
+        self.help_open = false;
115
+    }
116
+
107117
     pub fn set_delete_error(&mut self, message: impl Into<String>) {
108118
         if let Some(choice) = &mut self.delete_choice {
109119
             choice.error = Some(message.into());
@@ -229,6 +239,28 @@ impl AppState {
229239
         }
230240
     }
231241
 
242
+    pub fn handle_help_key(&mut self, key: KeyEvent) -> HelpInputResult {
243
+        if key.kind == KeyEventKind::Release {
244
+            return HelpInputResult::Continue;
245
+        }
246
+
247
+        match key.code {
248
+            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => {
249
+                self.close_help();
250
+                HelpInputResult::Close
251
+            }
252
+            KeyCode::Char(value) if value.eq_ignore_ascii_case(&'q') => {
253
+                self.should_quit = true;
254
+                HelpInputResult::Continue
255
+            }
256
+            KeyCode::Char(value) if ctrl_c(value, key.modifiers) => {
257
+                self.should_quit = true;
258
+                HelpInputResult::Continue
259
+            }
260
+            _ => HelpInputResult::Continue,
261
+        }
262
+    }
263
+
232264
     pub fn calendar_month(&self) -> CalendarMonth {
233265
         CalendarMonth::from_dates(self.selected_date, self.today)
234266
     }
@@ -268,6 +300,14 @@ impl AppState {
268300
         match action {
269301
             AppAction::Noop => {}
270302
             AppAction::Quit => self.should_quit = true,
303
+            AppAction::OpenHelp
304
+                if self.create_form.is_none()
305
+                    && self.recurrence_choice.is_none()
306
+                    && self.delete_choice.is_none() =>
307
+            {
308
+                self.help_open = true;
309
+            }
310
+            _ if self.help_open => {}
271311
             AppAction::OpenDay if self.view_mode == ViewMode::Day => {
272312
                 if let Some(source) = source {
273313
                     self.open_selected_event_for_edit(source);
@@ -284,11 +324,13 @@ impl AppState {
284324
                 self.selected_day_event_id = None;
285325
                 self.recurrence_choice = None;
286326
                 self.delete_choice = None;
327
+                self.help_open = false;
287328
             }
288329
             AppAction::OpenCreate => {
289330
                 if self.create_form.is_none()
290331
                     && self.recurrence_choice.is_none()
291332
                     && self.delete_choice.is_none()
333
+                    && !self.help_open
292334
                 {
293335
                     let context = match self.view_mode {
294336
                         ViewMode::Month => CreateEventContext::EditableDate,
@@ -301,6 +343,7 @@ impl AppState {
301343
                 if self.create_form.is_none()
302344
                     && self.recurrence_choice.is_none()
303345
                     && self.delete_choice.is_none()
346
+                    && !self.help_open
304347
                     && let Some(source) = source
305348
                 {
306349
                     self.open_selected_event_for_delete(source);
@@ -346,7 +389,8 @@ impl AppState {
346389
             | AppAction::SelectDate(_)
347390
             | AppAction::JumpToDay(_)
348391
             | AppAction::JumpToWeekday(_)
349
-            | AppAction::OpenDelete => {}
392
+            | AppAction::OpenDelete
393
+            | AppAction::OpenHelp => {}
350394
         }
351395
     }
352396
 
@@ -428,6 +472,7 @@ pub enum AppAction {
428472
     CloseDay,
429473
     OpenCreate,
430474
     OpenDelete,
475
+    OpenHelp,
431476
     Quit,
432477
 }
433478
 
@@ -679,6 +724,12 @@ pub enum EventDeleteInputResult {
679724
     Submit(EventDeleteSubmission),
680725
 }
681726
 
727
+#[derive(Debug, Clone, PartialEq, Eq)]
728
+pub enum HelpInputResult {
729
+    Continue,
730
+    Close,
731
+}
732
+
682733
 #[derive(Debug, Clone, PartialEq, Eq)]
683734
 pub struct CreateEventForm {
684735
     mode: EventFormMode,
@@ -1678,6 +1729,11 @@ impl KeyboardInput {
16781729
             return AppAction::OpenCreate;
16791730
         }
16801731
 
1732
+        if value == '?' {
1733
+            self.clear();
1734
+            return AppAction::OpenHelp;
1735
+        }
1736
+
16811737
         if value.eq_ignore_ascii_case(&'d') {
16821738
             self.clear();
16831739
             return AppAction::OpenDelete;
@@ -2451,6 +2507,40 @@ mod tests {
24512507
         );
24522508
     }
24532509
 
2510
+    #[test]
2511
+    fn question_mark_opens_help_and_esc_closes_it() {
2512
+        let day = date(2026, Month::April, 23);
2513
+        let mut app = AppState::new(day);
2514
+        let mut input = KeyboardInput::default();
2515
+
2516
+        app.apply(input.translate(char_key('?')));
2517
+
2518
+        assert!(app.is_showing_help());
2519
+        assert_eq!(
2520
+            app.handle_help_key(key(KeyCode::Esc)),
2521
+            HelpInputResult::Close
2522
+        );
2523
+        assert!(!app.is_showing_help());
2524
+        assert_eq!(app.selected_date(), day);
2525
+    }
2526
+
2527
+    #[test]
2528
+    fn help_modal_blocks_calendar_navigation_until_closed() {
2529
+        let day = date(2026, Month::April, 23);
2530
+        let mut app = AppState::new(day);
2531
+        let mut input = KeyboardInput::default();
2532
+
2533
+        app.apply(input.translate(char_key('?')));
2534
+        app.apply(input.translate(key(KeyCode::Right)));
2535
+
2536
+        assert!(app.is_showing_help());
2537
+        assert_eq!(app.selected_date(), day);
2538
+
2539
+        assert_eq!(app.handle_help_key(char_key('?')), HelpInputResult::Close);
2540
+        app.apply(input.translate(key(KeyCode::Right)));
2541
+        assert_eq!(app.selected_date(), date(2026, Month::April, 24));
2542
+    }
2543
+
24542544
     #[test]
24552545
     fn create_form_text_input_does_not_move_selection() {
24562546
         let day = date(2026, Month::April, 23);
src/cli.rsmodified
@@ -18,7 +18,7 @@ use crate::{
1818
     agenda::{ConfiguredAgendaSource, HolidayProvider, LocalEventStoreError, default_events_file},
1919
     app::{
2020
         AppState, CreateEventInputResult, EventDeleteInputResult, EventDeleteSubmission,
21
-        EventFormMode, KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
21
+        EventFormMode, HelpInputResult, KeyboardInput, MouseInput, RecurrenceChoiceInputResult,
2222
     },
2323
     calendar::CalendarDate,
2424
     tui::{
@@ -43,6 +43,7 @@ const HELP: &str = concat!(
4343
     "  -V, --version                       Show version.\n\n",
4444
     "Keys:\n",
4545
     "  Arrow keys move selection; Enter opens day view; Esc returns to month; q exits.\n",
46
+    "  ? opens contextual help.\n",
4647
     "  + opens the Create event modal.\n",
4748
     "  In day view, d opens the Delete confirmation for the selected local event.\n",
4849
     "  In day view, Left/Right move to the previous or next day.\n",
@@ -446,6 +447,7 @@ where
446447
         let event = if !app.is_creating_event()
447448
             && !app.is_choosing_recurring_edit()
448449
             && !app.is_confirming_delete()
450
+            && !app.is_showing_help()
449451
             && keyboard.is_waiting_for_digit()
450452
         {
451453
             if event::poll(DIGIT_JUMP_TIMEOUT)? {
@@ -461,7 +463,11 @@ where
461463
         match event {
462464
             Event::Key(key) => {
463465
                 mouse.clear();
464
-                if app.is_confirming_delete() {
466
+                if app.is_showing_help() {
467
+                    match app.handle_help_key(key) {
468
+                        HelpInputResult::Continue | HelpInputResult::Close => {}
469
+                    }
470
+                } else if app.is_confirming_delete() {
465471
                     match app.handle_delete_choice_key(key) {
466472
                         EventDeleteInputResult::Continue => {}
467473
                         EventDeleteInputResult::Cancel => app.close_delete_choice(),
@@ -542,6 +548,7 @@ where
542548
                 if app.is_creating_event()
543549
                     || app.is_choosing_recurring_edit()
544550
                     || app.is_confirming_delete()
551
+                    || app.is_showing_help()
545552
                 {
546553
                     continue;
547554
                 }
src/tui.rsmodified
@@ -28,6 +28,7 @@ pub const DEFAULT_RENDER_HEIGHT: u16 = 26;
2828
 const HEADER_HEIGHT: u16 = 2;
2929
 const VERTICAL_GRID_LINES: u16 = DAYS_PER_WEEK as u16 + 1;
3030
 const HORIZONTAL_GRID_LINES: u16 = MONTH_GRID_WEEKS as u16 + 1;
31
+const HELP_HINT: &str = "?: Help";
3132
 static EMPTY_AGENDA_SOURCE: EmptyAgendaSource = EmptyAgendaSource;
3233
 
3334
 #[derive(Clone, Copy)]
@@ -102,6 +103,9 @@ impl Widget for AppView<'_> {
102103
         if let Some(choice) = self.app.delete_choice() {
103104
             render_delete_choice_modal(choice, area, buf, CreateModalStyles::new());
104105
         }
106
+        if self.app.is_showing_help() {
107
+            render_help_modal(self.app.view_mode(), area, buf, CreateModalStyles::new());
108
+        }
105109
     }
106110
 }
107111
 
@@ -418,6 +422,7 @@ struct MonthGridStyles {
418422
     in_month_border: Style,
419423
     preview: Style,
420424
     preview_summary: Style,
425
+    help_hint: Style,
421426
     filler: Style,
422427
     filler_border: Style,
423428
 }
@@ -443,6 +448,7 @@ impl MonthGridStyles {
443448
             in_month_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
444449
             preview: Style::new().fg(Color::Gray),
445450
             preview_summary: Style::new().fg(Color::Cyan).add_modifier(Modifier::DIM),
451
+            help_hint: Style::new().fg(Color::DarkGray),
446452
             filler: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
447453
             filler_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
448454
         }
@@ -462,6 +468,7 @@ struct DayViewStyles {
462468
     holiday: Style,
463469
     event: Style,
464470
     selected_event: Style,
471
+    help_hint: Style,
465472
 }
466473
 
467474
 impl DayViewStyles {
@@ -481,6 +488,7 @@ impl DayViewStyles {
481488
                 .fg(Color::White)
482489
                 .bg(Color::Blue)
483490
                 .add_modifier(Modifier::BOLD),
491
+            help_hint: Style::new().fg(Color::DarkGray),
484492
         }
485493
     }
486494
 }
@@ -790,14 +798,23 @@ fn render_day_header(
790798
     buf: &mut Buffer,
791799
     styles: DayViewStyles,
792800
 ) {
801
+    let title = day_title(agenda.date);
793802
     write_centered(
794803
         buf,
795804
         layout.title_y,
796805
         layout.area.x,
797806
         layout.area.width,
798
-        &day_title(agenda.date),
807
+        &title,
799808
         styles.title,
800809
     );
810
+    render_title_hint(
811
+        buf,
812
+        layout.title_y,
813
+        layout.area.x,
814
+        layout.area.width,
815
+        &title,
816
+        styles.help_hint,
817
+    );
801818
 
802819
     let Some(summary_y) = layout.summary_y else {
803820
         return;
@@ -1046,6 +1063,60 @@ fn render_delete_choice_modal(
10461063
     );
10471064
 }
10481065
 
1066
+fn render_help_modal(view_mode: ViewMode, area: Rect, buf: &mut Buffer, styles: CreateModalStyles) {
1067
+    if area.width == 0 || area.height == 0 {
1068
+        return;
1069
+    }
1070
+
1071
+    let rows = help_rows(view_mode);
1072
+    let modal = help_modal_area(area, rows.len());
1073
+    fill_rect(buf, modal, styles.panel);
1074
+    draw_border(buf, modal, styles.border, BorderCharacters::normal());
1075
+
1076
+    let content = inset_rect(modal);
1077
+    if content.width == 0 || content.height == 0 {
1078
+        return;
1079
+    }
1080
+
1081
+    write_centered(
1082
+        buf,
1083
+        content.y,
1084
+        content.x,
1085
+        content.width,
1086
+        help_heading(view_mode),
1087
+        styles.title,
1088
+    );
1089
+
1090
+    let key_width = 13.min(content.width.saturating_sub(2));
1091
+    let description_x = content.x.saturating_add(key_width).saturating_add(2);
1092
+    let description_width = content.right().saturating_sub(description_x);
1093
+    let mut y = content.y.saturating_add(2);
1094
+    for (key, description) in rows {
1095
+        if y >= content.bottom().saturating_sub(2) {
1096
+            break;
1097
+        }
1098
+        write_padded_left(buf, y, content.x, key_width, key, styles.checkbox);
1099
+        write_left(
1100
+            buf,
1101
+            y,
1102
+            description_x,
1103
+            description_width,
1104
+            description,
1105
+            styles.value,
1106
+        );
1107
+        y = y.saturating_add(1);
1108
+    }
1109
+
1110
+    write_centered(
1111
+        buf,
1112
+        content.bottom().saturating_sub(1),
1113
+        content.x,
1114
+        content.width,
1115
+        "Esc / ? close",
1116
+        styles.footer,
1117
+    );
1118
+}
1119
+
10491120
 fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
10501121
     if area.width < 52 || area.height < 16 {
10511122
         return area;
@@ -1062,6 +1133,56 @@ fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
10621133
     )
10631134
 }
10641135
 
1136
+fn help_modal_area(area: Rect, row_count: usize) -> Rect {
1137
+    if area.width < 48 || area.height < 12 {
1138
+        return area;
1139
+    }
1140
+
1141
+    let width = area.width.saturating_sub(4).min(66);
1142
+    let desired_height = u16::try_from(row_count)
1143
+        .unwrap_or(u16::MAX)
1144
+        .saturating_add(5);
1145
+    let height = desired_height.min(area.height.saturating_sub(4)).max(10);
1146
+    Rect::new(
1147
+        area.x + area.width.saturating_sub(width) / 2,
1148
+        area.y + area.height.saturating_sub(height) / 2,
1149
+        width,
1150
+        height,
1151
+    )
1152
+}
1153
+
1154
+fn help_heading(view_mode: ViewMode) -> &'static str {
1155
+    match view_mode {
1156
+        ViewMode::Month => "Month / Week keys",
1157
+        ViewMode::Day => "Day keys",
1158
+    }
1159
+}
1160
+
1161
+fn help_rows(view_mode: ViewMode) -> &'static [(&'static str, &'static str)] {
1162
+    match view_mode {
1163
+        ViewMode::Month => &[
1164
+            ("Arrows", "Move the selected date"),
1165
+            ("Digits", "Jump to a day, with quick two-digit refinement"),
1166
+            ("Weekdays", "Jump within the selected week"),
1167
+            ("Enter", "Open the focused day view"),
1168
+            ("+", "Create an event on the selected date"),
1169
+            ("Mouse", "Select a date; click it again to open"),
1170
+            ("?", "Close this help"),
1171
+            ("q", "Quit"),
1172
+        ],
1173
+        ViewMode::Day => &[
1174
+            ("Left/Right", "Move to the previous or next day"),
1175
+            ("Up/Down", "Select a local event"),
1176
+            ("Enter", "Edit the selected local event"),
1177
+            ("d", "Delete the selected local event"),
1178
+            ("+", "Create an event on this day"),
1179
+            ("Esc", "Return to month view"),
1180
+            ("?", "Close this help"),
1181
+            ("q", "Quit"),
1182
+        ],
1183
+    }
1184
+}
1185
+
10651186
 fn recurrence_choice_modal_area(area: Rect) -> Rect {
10661187
     if area.width < 36 || area.height < 9 {
10671188
         return area;
@@ -1521,6 +1642,14 @@ fn render_title(
15211642
         &title,
15221643
         styles.title,
15231644
     );
1645
+    render_title_hint(
1646
+        buf,
1647
+        layout.title_y,
1648
+        layout.area.x,
1649
+        layout.area.width,
1650
+        &title,
1651
+        styles.help_hint,
1652
+    );
15241653
 }
15251654
 
15261655
 fn render_weekdays(layout: &MonthGridLayout, buf: &mut Buffer, styles: MonthGridStyles) {
@@ -1566,6 +1695,14 @@ fn render_week_title(
15661695
         &title,
15671696
         styles.title,
15681697
     );
1698
+    render_title_hint(
1699
+        buf,
1700
+        layout.title_y,
1701
+        layout.area.x,
1702
+        layout.area.width,
1703
+        &title,
1704
+        styles.help_hint,
1705
+    );
15691706
 }
15701707
 
15711708
 fn render_cell(
@@ -1850,6 +1987,23 @@ fn write_centered(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, styl
18501987
     buf.set_stringn(start, y, text, usize::from(width), style);
18511988
 }
18521989
 
1990
+fn render_title_hint(buf: &mut Buffer, y: u16, x: u16, width: u16, title: &str, style: Style) {
1991
+    let hint_width = u16::try_from(HELP_HINT.len()).unwrap_or(u16::MAX);
1992
+    let title_width = u16::try_from(title.len()).unwrap_or(u16::MAX);
1993
+    if width <= hint_width.saturating_add(1) || title_width >= width {
1994
+        return;
1995
+    }
1996
+
1997
+    let title_start = x + width.saturating_sub(title_width) / 2;
1998
+    let title_end = title_start.saturating_add(title_width);
1999
+    let hint_x = x + width.saturating_sub(hint_width).saturating_sub(1);
2000
+    if hint_x <= title_end.saturating_add(3) {
2001
+        return;
2002
+    }
2003
+
2004
+    write_left(buf, y, hint_x, hint_width, HELP_HINT, style);
2005
+}
2006
+
18532007
 fn write_left(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) {
18542008
     if width == 0 || !buf.area.contains((x, y).into()) {
18552009
         return;
@@ -2092,6 +2246,11 @@ mod tests {
20922246
         format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
20932247
     }
20942248
 
2249
+    fn assert_line_has_centered_title(line: &str, width: usize, title: &str) {
2250
+        let start = width.saturating_sub(title.len()) / 2;
2251
+        assert_eq!(&line[start..start + title.len()], title);
2252
+    }
2253
+
20952254
     fn source_metadata() -> SourceMetadata {
20962255
         SourceMetadata::fixture()
20972256
     }
@@ -2127,10 +2286,8 @@ mod tests {
21272286
         let lines = buffer_lines(&buffer);
21282287
 
21292288
         assert_eq!(lines.len(), 20);
2130
-        assert_eq!(
2131
-            lines[0],
2132
-            "                   April 2026                    "
2133
-        );
2289
+        assert_line_has_centered_title(&lines[0], 49, "April 2026");
2290
+        assert!(lines[0].contains("?: Help"));
21342291
         assert_eq!(
21352292
             lines[1],
21362293
             "  Sun    Mon    Tue    Wed    Thu    Fri    Sat  "
@@ -2271,6 +2428,16 @@ mod tests {
22712428
         assert!(rendered.contains("30"));
22722429
     }
22732430
 
2431
+    #[test]
2432
+    fn title_hint_renders_without_moving_month_title() {
2433
+        let app = AppState::new(date(2026, Month::April, 23));
2434
+        let rendered = render_app_to_string(&app, 84, 26);
2435
+        let lines = rendered.lines().collect::<Vec<_>>();
2436
+
2437
+        assert_line_has_centered_title(lines[0], 84, "April 2026");
2438
+        assert!(lines[0].contains("?: Help"));
2439
+    }
2440
+
22742441
     #[test]
22752442
     fn create_modal_renders_over_month_view() {
22762443
         let mut app = AppState::new(date(2026, Month::April, 23));
@@ -2354,6 +2521,32 @@ mod tests {
23542521
         assert!(rendered.contains("Enter select"));
23552522
     }
23562523
 
2524
+    #[test]
2525
+    fn help_modal_shows_month_week_keys_in_month_mode() {
2526
+        let mut app = AppState::new(date(2026, Month::April, 23));
2527
+        app.apply(AppAction::OpenHelp);
2528
+
2529
+        let rendered = render_app_to_string(&app, 84, 26);
2530
+
2531
+        assert!(rendered.contains("Month / Week keys"));
2532
+        assert!(rendered.contains("Open the focused day view"));
2533
+        assert!(rendered.contains("Esc / ? close"));
2534
+        assert!(!rendered.contains("Delete the selected local event"));
2535
+    }
2536
+
2537
+    #[test]
2538
+    fn help_modal_shows_day_keys_in_day_mode() {
2539
+        let mut app = AppState::new(date(2026, Month::April, 23));
2540
+        app.apply(AppAction::OpenDay);
2541
+        app.apply(AppAction::OpenHelp);
2542
+
2543
+        let rendered = render_app_to_string(&app, 84, 26);
2544
+
2545
+        assert!(rendered.contains("Day keys"));
2546
+        assert!(rendered.contains("Delete the selected local event"));
2547
+        assert!(rendered.contains("Move to the previous or next day"));
2548
+    }
2549
+
23572550
     #[test]
23582551
     fn recurring_instances_render_without_repeat_marker() {
23592552
         let day = date(2026, Month::April, 23);
@@ -2648,7 +2841,8 @@ mod tests {
26482841
         let rendered = render_app_to_string(&app, 84, 14);
26492842
         let lines: Vec<_> = rendered.lines().collect();
26502843
 
2651
-        assert_eq!(lines[0], centered(84, "Thursday, April 23, 2026"));
2844
+        assert_line_has_centered_title(lines[0], 84, "Thursday, April 23, 2026");
2845
+        assert!(lines[0].contains("?: Help"));
26522846
         assert_eq!(
26532847
             lines[1],
26542848
             centered(84, "0 holidays | 0 events | Esc returns to month")