@@ -28,6 +28,7 @@ pub const DEFAULT_RENDER_HEIGHT: u16 = 26; |
| 28 | 28 | const HEADER_HEIGHT: u16 = 2; |
| 29 | 29 | const VERTICAL_GRID_LINES: u16 = DAYS_PER_WEEK as u16 + 1; |
| 30 | 30 | const HORIZONTAL_GRID_LINES: u16 = MONTH_GRID_WEEKS as u16 + 1; |
| 31 | +const HELP_HINT: &str = "?: Help"; |
| 31 | 32 | static EMPTY_AGENDA_SOURCE: EmptyAgendaSource = EmptyAgendaSource; |
| 32 | 33 | |
| 33 | 34 | #[derive(Clone, Copy)] |
@@ -102,6 +103,9 @@ impl Widget for AppView<'_> { |
| 102 | 103 | if let Some(choice) = self.app.delete_choice() { |
| 103 | 104 | render_delete_choice_modal(choice, area, buf, CreateModalStyles::new()); |
| 104 | 105 | } |
| 106 | + if self.app.is_showing_help() { |
| 107 | + render_help_modal(self.app.view_mode(), area, buf, CreateModalStyles::new()); |
| 108 | + } |
| 105 | 109 | } |
| 106 | 110 | } |
| 107 | 111 | |
@@ -418,6 +422,7 @@ struct MonthGridStyles { |
| 418 | 422 | in_month_border: Style, |
| 419 | 423 | preview: Style, |
| 420 | 424 | preview_summary: Style, |
| 425 | + help_hint: Style, |
| 421 | 426 | filler: Style, |
| 422 | 427 | filler_border: Style, |
| 423 | 428 | } |
@@ -443,6 +448,7 @@ impl MonthGridStyles { |
| 443 | 448 | in_month_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), |
| 444 | 449 | preview: Style::new().fg(Color::Gray), |
| 445 | 450 | preview_summary: Style::new().fg(Color::Cyan).add_modifier(Modifier::DIM), |
| 451 | + help_hint: Style::new().fg(Color::DarkGray), |
| 446 | 452 | filler: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), |
| 447 | 453 | filler_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), |
| 448 | 454 | } |
@@ -462,6 +468,7 @@ struct DayViewStyles { |
| 462 | 468 | holiday: Style, |
| 463 | 469 | event: Style, |
| 464 | 470 | selected_event: Style, |
| 471 | + help_hint: Style, |
| 465 | 472 | } |
| 466 | 473 | |
| 467 | 474 | impl DayViewStyles { |
@@ -481,6 +488,7 @@ impl DayViewStyles { |
| 481 | 488 | .fg(Color::White) |
| 482 | 489 | .bg(Color::Blue) |
| 483 | 490 | .add_modifier(Modifier::BOLD), |
| 491 | + help_hint: Style::new().fg(Color::DarkGray), |
| 484 | 492 | } |
| 485 | 493 | } |
| 486 | 494 | } |
@@ -790,14 +798,23 @@ fn render_day_header( |
| 790 | 798 | buf: &mut Buffer, |
| 791 | 799 | styles: DayViewStyles, |
| 792 | 800 | ) { |
| 801 | + let title = day_title(agenda.date); |
| 793 | 802 | write_centered( |
| 794 | 803 | buf, |
| 795 | 804 | layout.title_y, |
| 796 | 805 | layout.area.x, |
| 797 | 806 | layout.area.width, |
| 798 | | - &day_title(agenda.date), |
| 807 | + &title, |
| 799 | 808 | styles.title, |
| 800 | 809 | ); |
| 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 | + ); |
| 801 | 818 | |
| 802 | 819 | let Some(summary_y) = layout.summary_y else { |
| 803 | 820 | return; |
@@ -1046,6 +1063,60 @@ fn render_delete_choice_modal( |
| 1046 | 1063 | ); |
| 1047 | 1064 | } |
| 1048 | 1065 | |
| 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 | + |
| 1049 | 1120 | fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect { |
| 1050 | 1121 | if area.width < 52 || area.height < 16 { |
| 1051 | 1122 | return area; |
@@ -1062,6 +1133,56 @@ fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect { |
| 1062 | 1133 | ) |
| 1063 | 1134 | } |
| 1064 | 1135 | |
| 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 | + |
| 1065 | 1186 | fn recurrence_choice_modal_area(area: Rect) -> Rect { |
| 1066 | 1187 | if area.width < 36 || area.height < 9 { |
| 1067 | 1188 | return area; |
@@ -1521,6 +1642,14 @@ fn render_title( |
| 1521 | 1642 | &title, |
| 1522 | 1643 | styles.title, |
| 1523 | 1644 | ); |
| 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 | + ); |
| 1524 | 1653 | } |
| 1525 | 1654 | |
| 1526 | 1655 | fn render_weekdays(layout: &MonthGridLayout, buf: &mut Buffer, styles: MonthGridStyles) { |
@@ -1566,6 +1695,14 @@ fn render_week_title( |
| 1566 | 1695 | &title, |
| 1567 | 1696 | styles.title, |
| 1568 | 1697 | ); |
| 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 | + ); |
| 1569 | 1706 | } |
| 1570 | 1707 | |
| 1571 | 1708 | fn render_cell( |
@@ -1850,6 +1987,23 @@ fn write_centered(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, styl |
| 1850 | 1987 | buf.set_stringn(start, y, text, usize::from(width), style); |
| 1851 | 1988 | } |
| 1852 | 1989 | |
| 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 | + |
| 1853 | 2007 | fn write_left(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) { |
| 1854 | 2008 | if width == 0 || !buf.area.contains((x, y).into()) { |
| 1855 | 2009 | return; |
@@ -2092,6 +2246,11 @@ mod tests { |
| 2092 | 2246 | format!("{}{}{}", " ".repeat(left), text, " ".repeat(right)) |
| 2093 | 2247 | } |
| 2094 | 2248 | |
| 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 | + |
| 2095 | 2254 | fn source_metadata() -> SourceMetadata { |
| 2096 | 2255 | SourceMetadata::fixture() |
| 2097 | 2256 | } |
@@ -2127,10 +2286,8 @@ mod tests { |
| 2127 | 2286 | let lines = buffer_lines(&buffer); |
| 2128 | 2287 | |
| 2129 | 2288 | 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")); |
| 2134 | 2291 | assert_eq!( |
| 2135 | 2292 | lines[1], |
| 2136 | 2293 | " Sun Mon Tue Wed Thu Fri Sat " |
@@ -2271,6 +2428,16 @@ mod tests { |
| 2271 | 2428 | assert!(rendered.contains("30")); |
| 2272 | 2429 | } |
| 2273 | 2430 | |
| 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 | + |
| 2274 | 2441 | #[test] |
| 2275 | 2442 | fn create_modal_renders_over_month_view() { |
| 2276 | 2443 | let mut app = AppState::new(date(2026, Month::April, 23)); |
@@ -2354,6 +2521,32 @@ mod tests { |
| 2354 | 2521 | assert!(rendered.contains("Enter select")); |
| 2355 | 2522 | } |
| 2356 | 2523 | |
| 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 | + |
| 2357 | 2550 | #[test] |
| 2358 | 2551 | fn recurring_instances_render_without_repeat_marker() { |
| 2359 | 2552 | let day = date(2026, Month::April, 23); |
@@ -2648,7 +2841,8 @@ mod tests { |
| 2648 | 2841 | let rendered = render_app_to_string(&app, 84, 14); |
| 2649 | 2842 | let lines: Vec<_> = rendered.lines().collect(); |
| 2650 | 2843 | |
| 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")); |
| 2652 | 2846 | assert_eq!( |
| 2653 | 2847 | lines[1], |
| 2654 | 2848 | centered(84, "0 holidays | 0 events | Esc returns to month") |