Rust · 101829 bytes Raw Blame History
1 use std::array;
2
3 use ratatui::{
4 buffer::Buffer,
5 layout::{Position, Rect},
6 style::{Color, Modifier, Style},
7 widgets::Widget,
8 };
9 use time::Weekday;
10
11 use crate::{
12 agenda::{
13 AgendaSource, DayAgenda, DayMinute, EmptyAgendaSource, Event, EventTiming, TimedAgendaEvent,
14 },
15 app::{
16 AppState, CreateEventForm, CreateEventFormRowKind, EventCopyChoice, EventDeleteChoice,
17 KeyBindings, KeyCommand, RecurrenceEditChoice, ViewMode,
18 },
19 calendar::{
20 CalendarCell, CalendarDate, CalendarMonth, CalendarWeek, DAYS_PER_WEEK, MONTH_GRID_WEEKS,
21 },
22 layout::ResponsiveLayout,
23 };
24
25 pub const DEFAULT_RENDER_WIDTH: u16 = 84;
26 pub const DEFAULT_RENDER_HEIGHT: u16 = 26;
27
28 const HEADER_HEIGHT: u16 = 2;
29 const VERTICAL_GRID_LINES: u16 = DAYS_PER_WEEK as u16 + 1;
30 const HORIZONTAL_GRID_LINES: u16 = MONTH_GRID_WEEKS as u16 + 1;
31 static EMPTY_AGENDA_SOURCE: EmptyAgendaSource = EmptyAgendaSource;
32
33 #[derive(Clone, Copy)]
34 pub struct MonthGrid<'a> {
35 month: &'a CalendarMonth,
36 agenda_source: &'a dyn AgendaSource,
37 styles: MonthGridStyles,
38 }
39
40 impl<'a> MonthGrid<'a> {
41 pub fn new(month: &'a CalendarMonth) -> Self {
42 Self::with_agenda_source(month, &EMPTY_AGENDA_SOURCE)
43 }
44
45 pub fn with_agenda_source(
46 month: &'a CalendarMonth,
47 agenda_source: &'a dyn AgendaSource,
48 ) -> Self {
49 Self {
50 month,
51 agenda_source,
52 styles: MonthGridStyles::new(),
53 }
54 }
55 }
56
57 impl Widget for MonthGrid<'_> {
58 fn render(self, area: Rect, buf: &mut Buffer) {
59 render_month_grid(
60 self.month,
61 self.agenda_source,
62 area,
63 buf,
64 self.styles,
65 &KeyBindings::default(),
66 );
67 }
68 }
69
70 #[derive(Clone)]
71 pub struct AppView<'a> {
72 app: &'a AppState,
73 agenda_source: &'a dyn AgendaSource,
74 keybindings: KeyBindings,
75 }
76
77 impl<'a> AppView<'a> {
78 pub fn new(app: &'a AppState) -> Self {
79 Self::with_agenda_source(app, &EMPTY_AGENDA_SOURCE)
80 }
81
82 pub fn with_agenda_source(app: &'a AppState, agenda_source: &'a dyn AgendaSource) -> Self {
83 Self::with_agenda_source_and_keybindings(app, agenda_source, &KeyBindings::default())
84 }
85
86 pub fn with_agenda_source_and_keybindings(
87 app: &'a AppState,
88 agenda_source: &'a dyn AgendaSource,
89 keybindings: &KeyBindings,
90 ) -> Self {
91 Self {
92 app,
93 agenda_source,
94 keybindings: keybindings.clone(),
95 }
96 }
97 }
98
99 impl Widget for AppView<'_> {
100 fn render(self, area: Rect, buf: &mut Buffer) {
101 match (self.app.view_mode(), ResponsiveLayout::for_area(area)) {
102 (ViewMode::Month, layout) if layout.should_render_month_grid() => {
103 let month = self.app.calendar_month();
104 render_month_grid(
105 &month,
106 self.agenda_source,
107 area,
108 buf,
109 MonthGridStyles::new(),
110 &self.keybindings,
111 );
112 }
113 (ViewMode::Month, layout) if layout.should_render_week_view() => {
114 let month = self.app.calendar_month();
115 render_week_grid(
116 &month,
117 self.agenda_source,
118 area,
119 buf,
120 MonthGridStyles::new(),
121 &self.keybindings,
122 );
123 }
124 (ViewMode::Month, _) => DayView::responsive_fallback_with_keybindings(
125 self.app,
126 self.agenda_source,
127 self.keybindings.clone(),
128 )
129 .render(area, buf),
130 (ViewMode::Day, _) => DayView::focused_with_keybindings(
131 self.app,
132 self.agenda_source,
133 self.keybindings.clone(),
134 )
135 .render(area, buf),
136 }
137
138 if let Some(form) = self.app.create_form() {
139 render_create_event_modal(form, area, buf, CreateModalStyles::new());
140 }
141 if let Some(choice) = self.app.recurrence_choice() {
142 render_recurrence_choice_modal(choice, area, buf, CreateModalStyles::new());
143 }
144 if let Some(choice) = self.app.delete_choice() {
145 render_delete_choice_modal(choice, area, buf, CreateModalStyles::new());
146 }
147 if let Some(choice) = self.app.copy_choice() {
148 render_copy_choice_modal(choice, area, buf, CreateModalStyles::new());
149 }
150 if self.app.is_showing_help() {
151 render_help_modal(
152 self.app.view_mode(),
153 area,
154 buf,
155 CreateModalStyles::new(),
156 &self.keybindings,
157 );
158 }
159 }
160 }
161
162 #[derive(Clone)]
163 pub struct DayView<'a> {
164 app: &'a AppState,
165 agenda_source: &'a dyn AgendaSource,
166 context: DayViewContext,
167 styles: DayViewStyles,
168 keybindings: KeyBindings,
169 }
170
171 impl<'a> DayView<'a> {
172 pub fn focused(app: &'a AppState, agenda_source: &'a dyn AgendaSource) -> Self {
173 Self {
174 app,
175 agenda_source,
176 context: DayViewContext::Focused,
177 styles: DayViewStyles::new(),
178 keybindings: KeyBindings::default(),
179 }
180 }
181
182 pub fn responsive_fallback(app: &'a AppState, agenda_source: &'a dyn AgendaSource) -> Self {
183 Self {
184 app,
185 agenda_source,
186 context: DayViewContext::ResponsiveFallback,
187 styles: DayViewStyles::new(),
188 keybindings: KeyBindings::default(),
189 }
190 }
191
192 fn focused_with_keybindings(
193 app: &'a AppState,
194 agenda_source: &'a dyn AgendaSource,
195 keybindings: KeyBindings,
196 ) -> Self {
197 Self {
198 app,
199 agenda_source,
200 context: DayViewContext::Focused,
201 styles: DayViewStyles::new(),
202 keybindings,
203 }
204 }
205
206 fn responsive_fallback_with_keybindings(
207 app: &'a AppState,
208 agenda_source: &'a dyn AgendaSource,
209 keybindings: KeyBindings,
210 ) -> Self {
211 Self {
212 app,
213 agenda_source,
214 context: DayViewContext::ResponsiveFallback,
215 styles: DayViewStyles::new(),
216 keybindings,
217 }
218 }
219 }
220
221 impl Widget for DayView<'_> {
222 fn render(self, area: Rect, buf: &mut Buffer) {
223 render_day_view(
224 self.app,
225 self.agenda_source,
226 self.context,
227 area,
228 buf,
229 self.styles,
230 &self.keybindings,
231 );
232 }
233 }
234
235 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
236 enum DayViewContext {
237 Focused,
238 ResponsiveFallback,
239 }
240
241 #[derive(Clone, Copy)]
242 pub struct WeekGrid<'a> {
243 month: &'a CalendarMonth,
244 agenda_source: &'a dyn AgendaSource,
245 styles: MonthGridStyles,
246 }
247
248 impl<'a> WeekGrid<'a> {
249 pub fn new(month: &'a CalendarMonth) -> Self {
250 Self::with_agenda_source(month, &EMPTY_AGENDA_SOURCE)
251 }
252
253 pub fn with_agenda_source(
254 month: &'a CalendarMonth,
255 agenda_source: &'a dyn AgendaSource,
256 ) -> Self {
257 Self {
258 month,
259 agenda_source,
260 styles: MonthGridStyles::new(),
261 }
262 }
263 }
264
265 impl Widget for WeekGrid<'_> {
266 fn render(self, area: Rect, buf: &mut Buffer) {
267 render_week_grid(
268 self.month,
269 self.agenda_source,
270 area,
271 buf,
272 self.styles,
273 &KeyBindings::default(),
274 );
275 }
276 }
277
278 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
279 pub struct MonthGridLayout {
280 pub area: Rect,
281 pub title_y: u16,
282 pub weekday_y: u16,
283 pub grid_area: Rect,
284 column_widths: [u16; DAYS_PER_WEEK],
285 row_heights: [u16; MONTH_GRID_WEEKS],
286 column_bounds: [u16; DAYS_PER_WEEK + 1],
287 row_bounds: [u16; MONTH_GRID_WEEKS + 1],
288 }
289
290 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
291 pub struct WeekGridLayout {
292 pub area: Rect,
293 pub title_y: u16,
294 pub weekday_y: u16,
295 pub grid_area: Rect,
296 column_widths: [u16; DAYS_PER_WEEK],
297 column_bounds: [u16; DAYS_PER_WEEK + 1],
298 }
299
300 pub const DAY_VIEW_SPLIT_MIN_WIDTH: u16 = 72;
301 pub const DAY_VIEW_STACKED_MIN_WIDTH: u16 = 36;
302 pub const DAY_VIEW_STACKED_MIN_HEIGHT: u16 = 12;
303
304 const DAY_HEADER_HEIGHT: u16 = 2;
305 const DAY_PANEL_GAP: u16 = 1;
306 const MIN_DAY_PANEL_HEIGHT: u16 = 4;
307
308 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
309 pub enum DayViewLayoutMode {
310 Split,
311 Stacked,
312 Minimal,
313 }
314
315 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
316 pub struct DayViewLayout {
317 pub area: Rect,
318 pub mode: DayViewLayoutMode,
319 pub title_y: u16,
320 pub summary_y: Option<u16>,
321 pub agenda_area: Option<Rect>,
322 pub timeline_area: Option<Rect>,
323 }
324
325 impl DayViewLayout {
326 pub fn new(area: Rect) -> Self {
327 if area.width == 0 || area.height == 0 {
328 return Self::minimal(area);
329 }
330
331 let content_y = area.y.saturating_add(DAY_HEADER_HEIGHT);
332 let content_height = area.height.saturating_sub(DAY_HEADER_HEIGHT);
333
334 if area.width >= DAY_VIEW_SPLIT_MIN_WIDTH && content_height >= MIN_DAY_PANEL_HEIGHT {
335 let timeline_width = area.width / 3;
336 let agenda_width = area
337 .width
338 .saturating_sub(timeline_width)
339 .saturating_sub(DAY_PANEL_GAP);
340
341 return Self {
342 area,
343 mode: DayViewLayoutMode::Split,
344 title_y: area.y,
345 summary_y: Some(area.y + 1),
346 agenda_area: Some(Rect::new(area.x, content_y, agenda_width, content_height)),
347 timeline_area: Some(Rect::new(
348 area.x + agenda_width + DAY_PANEL_GAP,
349 content_y,
350 timeline_width,
351 content_height,
352 )),
353 };
354 }
355
356 if area.width >= DAY_VIEW_STACKED_MIN_WIDTH && area.height >= DAY_VIEW_STACKED_MIN_HEIGHT {
357 let available = content_height.saturating_sub(DAY_PANEL_GAP);
358 let agenda_height = available.saturating_mul(2) / 3;
359 let timeline_height = available.saturating_sub(agenda_height);
360 let timeline_y = content_y + agenda_height + DAY_PANEL_GAP;
361
362 return Self {
363 area,
364 mode: DayViewLayoutMode::Stacked,
365 title_y: area.y,
366 summary_y: Some(area.y + 1),
367 agenda_area: Some(Rect::new(area.x, content_y, area.width, agenda_height)),
368 timeline_area: Some(Rect::new(area.x, timeline_y, area.width, timeline_height)),
369 };
370 }
371
372 Self::minimal(area)
373 }
374
375 const fn minimal(area: Rect) -> Self {
376 let summary_y = if area.height > 1 {
377 Some(area.y + 1)
378 } else {
379 None
380 };
381
382 Self {
383 area,
384 mode: DayViewLayoutMode::Minimal,
385 title_y: area.y,
386 summary_y,
387 agenda_area: None,
388 timeline_area: None,
389 }
390 }
391 }
392
393 impl WeekGridLayout {
394 pub fn new(area: Rect) -> Option<Self> {
395 if area.width < VERTICAL_GRID_LINES || area.height < HEADER_HEIGHT + 2 {
396 return None;
397 }
398
399 let grid_area = Rect::new(
400 area.x,
401 area.y + HEADER_HEIGHT,
402 area.width,
403 area.height - HEADER_HEIGHT,
404 );
405 let column_widths = distribute::<DAYS_PER_WEEK>(grid_area.width - VERTICAL_GRID_LINES);
406
407 let mut column_bounds = [0; DAYS_PER_WEEK + 1];
408 column_bounds[0] = grid_area.x;
409 for index in 0..DAYS_PER_WEEK {
410 column_bounds[index + 1] = column_bounds[index] + column_widths[index] + 1;
411 }
412
413 Some(Self {
414 area,
415 title_y: area.y,
416 weekday_y: area.y + 1,
417 grid_area,
418 column_widths,
419 column_bounds,
420 })
421 }
422
423 pub fn cell_rect(&self, weekday_index: usize) -> Rect {
424 Rect::new(
425 self.column_bounds[weekday_index],
426 self.grid_area.y,
427 self.column_widths[weekday_index] + 2,
428 self.grid_area.height,
429 )
430 }
431
432 pub fn cell_content_rect(&self, weekday_index: usize) -> Rect {
433 let cell = self.cell_rect(weekday_index);
434 Rect::new(
435 cell.x + 1,
436 cell.y + 1,
437 cell.width.saturating_sub(2),
438 cell.height.saturating_sub(2),
439 )
440 }
441 }
442
443 impl MonthGridLayout {
444 pub fn new(area: Rect) -> Option<Self> {
445 if area.width < VERTICAL_GRID_LINES || area.height < HEADER_HEIGHT + HORIZONTAL_GRID_LINES {
446 return None;
447 }
448
449 let grid_area = Rect::new(
450 area.x,
451 area.y + HEADER_HEIGHT,
452 area.width,
453 area.height - HEADER_HEIGHT,
454 );
455 let column_widths = distribute::<DAYS_PER_WEEK>(grid_area.width - VERTICAL_GRID_LINES);
456 let row_heights = distribute::<MONTH_GRID_WEEKS>(grid_area.height - HORIZONTAL_GRID_LINES);
457
458 let mut column_bounds = [0; DAYS_PER_WEEK + 1];
459 column_bounds[0] = grid_area.x;
460 for index in 0..DAYS_PER_WEEK {
461 column_bounds[index + 1] = column_bounds[index] + column_widths[index] + 1;
462 }
463
464 let mut row_bounds = [0; MONTH_GRID_WEEKS + 1];
465 row_bounds[0] = grid_area.y;
466 for index in 0..MONTH_GRID_WEEKS {
467 row_bounds[index + 1] = row_bounds[index] + row_heights[index] + 1;
468 }
469
470 Some(Self {
471 area,
472 title_y: area.y,
473 weekday_y: area.y + 1,
474 grid_area,
475 column_widths,
476 row_heights,
477 column_bounds,
478 row_bounds,
479 })
480 }
481
482 pub fn cell_rect(&self, week_index: usize, weekday_index: usize) -> Rect {
483 Rect::new(
484 self.column_bounds[weekday_index],
485 self.row_bounds[week_index],
486 self.column_widths[weekday_index] + 2,
487 self.row_heights[week_index] + 2,
488 )
489 }
490
491 pub fn cell_content_rect(&self, week_index: usize, weekday_index: usize) -> Rect {
492 let cell = self.cell_rect(week_index, weekday_index);
493 Rect::new(
494 cell.x + 1,
495 cell.y + 1,
496 cell.width.saturating_sub(2),
497 cell.height.saturating_sub(2),
498 )
499 }
500 }
501
502 #[derive(Debug, Clone, Copy)]
503 struct MonthGridStyles {
504 title: Style,
505 weekday: Style,
506 selected: Style,
507 selected_border: Style,
508 today: Style,
509 today_border: Style,
510 in_month: Style,
511 in_month_border: Style,
512 preview: Style,
513 preview_summary: Style,
514 help_hint: Style,
515 filler: Style,
516 filler_border: Style,
517 }
518
519 impl MonthGridStyles {
520 const fn new() -> Self {
521 Self {
522 title: Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
523 weekday: Style::new().fg(Color::Gray).add_modifier(Modifier::BOLD),
524 selected: Style::new()
525 .fg(Color::White)
526 .bg(Color::Blue)
527 .add_modifier(Modifier::BOLD),
528 selected_border: Style::new()
529 .fg(Color::Cyan)
530 .bg(Color::Blue)
531 .add_modifier(Modifier::BOLD),
532 today: Style::new()
533 .fg(Color::Yellow)
534 .add_modifier(Modifier::BOLD.union(Modifier::DIM)),
535 today_border: Style::new().fg(Color::Yellow).add_modifier(Modifier::DIM),
536 in_month: Style::new().fg(Color::White).add_modifier(Modifier::DIM),
537 in_month_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
538 preview: Style::new().fg(Color::Gray),
539 preview_summary: Style::new().fg(Color::Cyan).add_modifier(Modifier::DIM),
540 help_hint: Style::new().fg(Color::DarkGray),
541 filler: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
542 filler_border: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
543 }
544 }
545 }
546
547 #[derive(Debug, Clone, Copy)]
548 struct DayViewStyles {
549 title: Style,
550 summary: Style,
551 panel_title: Style,
552 border: Style,
553 content: Style,
554 muted: Style,
555 timeline_mark: Style,
556 timeline_event: Style,
557 holiday: Style,
558 event: Style,
559 selected_event: Style,
560 help_hint: Style,
561 }
562
563 impl DayViewStyles {
564 const fn new() -> Self {
565 Self {
566 title: Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
567 summary: Style::new().fg(Color::Gray),
568 panel_title: Style::new().fg(Color::White).add_modifier(Modifier::BOLD),
569 border: Style::new().fg(Color::DarkGray),
570 content: Style::new().fg(Color::White),
571 muted: Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM),
572 timeline_mark: Style::new().fg(Color::Yellow).add_modifier(Modifier::DIM),
573 timeline_event: Style::new().fg(Color::White).bg(Color::Blue),
574 holiday: Style::new().fg(Color::Yellow),
575 event: Style::new().fg(Color::White),
576 selected_event: Style::new()
577 .fg(Color::White)
578 .bg(Color::Blue)
579 .add_modifier(Modifier::BOLD),
580 help_hint: Style::new().fg(Color::DarkGray),
581 }
582 }
583 }
584
585 #[derive(Debug, Clone, Copy)]
586 struct CreateModalStyles {
587 panel: Style,
588 border: Style,
589 title: Style,
590 label: Style,
591 value: Style,
592 checkbox: Style,
593 checkbox_mark: Style,
594 error: Style,
595 footer: Style,
596 }
597
598 impl CreateModalStyles {
599 const fn new() -> Self {
600 Self {
601 panel: Style::new().fg(Color::White).bg(Color::Black),
602 border: Style::new().fg(Color::Cyan).bg(Color::Black),
603 title: Style::new()
604 .fg(Color::Cyan)
605 .bg(Color::Black)
606 .add_modifier(Modifier::BOLD),
607 label: Style::new().fg(Color::White).bg(Color::Black),
608 value: Style::new().fg(Color::Gray).bg(Color::Black),
609 checkbox: Style::new().fg(Color::Yellow).bg(Color::Black),
610 checkbox_mark: Style::new().fg(Color::White).bg(Color::Black),
611 error: Style::new()
612 .fg(Color::Red)
613 .bg(Color::Black)
614 .add_modifier(Modifier::BOLD),
615 footer: Style::new().fg(Color::DarkGray).bg(Color::Black),
616 }
617 }
618 }
619
620 pub fn render_month_to_string(month: &CalendarMonth, width: u16, height: u16) -> String {
621 let area = Rect::new(0, 0, width, height);
622 let mut buffer = Buffer::empty(area);
623 MonthGrid::new(month).render(area, &mut buffer);
624 buffer_to_string(&buffer)
625 }
626
627 pub fn render_app_to_string(app: &AppState, width: u16, height: u16) -> String {
628 render_app_to_string_with_agenda_source(app, width, height, &EMPTY_AGENDA_SOURCE)
629 }
630
631 pub fn render_app_to_string_with_agenda_source<S>(
632 app: &AppState,
633 width: u16,
634 height: u16,
635 agenda_source: &S,
636 ) -> String
637 where
638 S: AgendaSource,
639 {
640 render_app_to_string_with_agenda_source_and_keybindings(
641 app,
642 width,
643 height,
644 agenda_source,
645 &KeyBindings::default(),
646 )
647 }
648
649 pub fn render_app_to_string_with_agenda_source_and_keybindings<S>(
650 app: &AppState,
651 width: u16,
652 height: u16,
653 agenda_source: &S,
654 keybindings: &KeyBindings,
655 ) -> String
656 where
657 S: AgendaSource,
658 {
659 let area = Rect::new(0, 0, width, height);
660 let mut buffer = Buffer::empty(area);
661 AppView::with_agenda_source_and_keybindings(app, agenda_source, keybindings)
662 .render(area, &mut buffer);
663 buffer_to_string(&buffer)
664 }
665
666 pub fn hit_test_app_date(
667 app: &AppState,
668 area: Rect,
669 column: u16,
670 row: u16,
671 ) -> Option<CalendarDate> {
672 if !contains_position(area, column, row) {
673 return None;
674 }
675
676 let month = app.calendar_month();
677 match (app.view_mode(), ResponsiveLayout::for_area(area)) {
678 (ViewMode::Month, layout) if layout.should_render_month_grid() => {
679 hit_test_month_grid_date(&month, area, column, row)
680 }
681 (ViewMode::Month, layout) if layout.should_render_week_view() => {
682 hit_test_week_grid_date(&month, area, column, row)
683 }
684 _ => None,
685 }
686 }
687
688 pub fn buffer_to_string(buffer: &Buffer) -> String {
689 let mut output =
690 String::with_capacity(usize::from(buffer.area.width + 1) * usize::from(buffer.area.height));
691
692 for y in buffer.area.top()..buffer.area.bottom() {
693 for x in buffer.area.left()..buffer.area.right() {
694 let cell = buffer
695 .cell((x, y))
696 .expect("buffer iteration stays inside the buffer");
697 output.push_str(cell.symbol());
698 }
699 output.push('\n');
700 }
701
702 output
703 }
704
705 fn hit_test_month_grid_date(
706 month: &CalendarMonth,
707 area: Rect,
708 column: u16,
709 row: u16,
710 ) -> Option<CalendarDate> {
711 let layout = MonthGridLayout::new(area)?;
712 if !contains_position(layout.grid_area, column, row) {
713 return None;
714 }
715
716 let week_index = hit_test_bounds(&layout.row_bounds, row)?;
717 let weekday_index = hit_test_bounds(&layout.column_bounds, column)?;
718 Some(month.weeks[week_index].cells[weekday_index].date)
719 }
720
721 fn hit_test_week_grid_date(
722 month: &CalendarMonth,
723 area: Rect,
724 column: u16,
725 row: u16,
726 ) -> Option<CalendarDate> {
727 let layout = WeekGridLayout::new(area)?;
728 let week = selected_week(month)?;
729 if !contains_position(layout.grid_area, column, row) {
730 return None;
731 }
732
733 let weekday_index = hit_test_bounds(&layout.column_bounds, column)?;
734 Some(week.cells[weekday_index].date)
735 }
736
737 fn hit_test_bounds(bounds: &[u16], coordinate: u16) -> Option<usize> {
738 let final_index = bounds.len().checked_sub(2)?;
739
740 for index in 0..=final_index {
741 let start = bounds[index];
742 let end = bounds[index + 1];
743 if coordinate >= start && (coordinate < end || (index == final_index && coordinate == end))
744 {
745 return Some(index);
746 }
747 }
748
749 None
750 }
751
752 fn contains_position(area: Rect, column: u16, row: u16) -> bool {
753 area.contains(Position { x: column, y: row })
754 }
755
756 fn render_month_grid(
757 month: &CalendarMonth,
758 agenda_source: &dyn AgendaSource,
759 area: Rect,
760 buf: &mut Buffer,
761 styles: MonthGridStyles,
762 keybindings: &KeyBindings,
763 ) {
764 let Some(layout) = MonthGridLayout::new(area) else {
765 render_too_small_message(area, buf, styles);
766 return;
767 };
768
769 buf.set_style(area, Style::default());
770 render_title(month, &layout, buf, styles, keybindings);
771 render_weekdays(&layout, buf, styles);
772
773 for cell in month
774 .cells()
775 .filter(|cell| !cell.is_selected && !cell.is_today)
776 {
777 render_cell(cell, &layout, agenda_source, buf, styles);
778 }
779
780 for cell in month
781 .cells()
782 .filter(|cell| cell.is_today && !cell.is_selected)
783 {
784 render_cell(cell, &layout, agenda_source, buf, styles);
785 }
786
787 if let Some(selected) = month.selected_cell() {
788 render_cell(selected, &layout, agenda_source, buf, styles);
789 }
790 }
791
792 fn render_week_grid(
793 month: &CalendarMonth,
794 agenda_source: &dyn AgendaSource,
795 area: Rect,
796 buf: &mut Buffer,
797 styles: MonthGridStyles,
798 keybindings: &KeyBindings,
799 ) {
800 let Some(layout) = WeekGridLayout::new(area) else {
801 render_too_small_message(area, buf, styles);
802 return;
803 };
804
805 let Some(selected_week) = selected_week(month) else {
806 render_too_small_message(area, buf, styles);
807 return;
808 };
809
810 buf.set_style(area, Style::default());
811 render_week_title(selected_week, &layout, buf, styles, keybindings);
812 render_weekdays_for_week(&layout, buf, styles);
813
814 for cell in selected_week
815 .cells
816 .iter()
817 .filter(|cell| !cell.is_selected && !cell.is_today)
818 {
819 render_week_cell(cell, &layout, agenda_source, buf, styles);
820 }
821
822 for cell in selected_week
823 .cells
824 .iter()
825 .filter(|cell| cell.is_today && !cell.is_selected)
826 {
827 render_week_cell(cell, &layout, agenda_source, buf, styles);
828 }
829
830 if let Some(selected) = selected_week.cells.iter().find(|cell| cell.is_selected) {
831 render_week_cell(selected, &layout, agenda_source, buf, styles);
832 }
833 }
834
835 fn selected_week(month: &CalendarMonth) -> Option<&CalendarWeek> {
836 let selected = month.selected_cell()?;
837 month.weeks.get(selected.week_index)
838 }
839
840 fn render_too_small_message(area: Rect, buf: &mut Buffer, styles: MonthGridStyles) {
841 if area.width == 0 || area.height == 0 {
842 return;
843 }
844
845 write_centered(buf, area.y, area.x, area.width, "rcal", styles.title);
846
847 if area.height > 1 {
848 write_centered(
849 buf,
850 area.y + 1,
851 area.x,
852 area.width,
853 "terminal too small",
854 styles.in_month,
855 );
856 }
857 }
858
859 fn render_day_view(
860 app: &AppState,
861 agenda_source: &dyn AgendaSource,
862 context: DayViewContext,
863 area: Rect,
864 buf: &mut Buffer,
865 styles: DayViewStyles,
866 keybindings: &KeyBindings,
867 ) {
868 if area.width == 0 || area.height == 0 {
869 return;
870 }
871
872 let layout = DayViewLayout::new(area);
873 let agenda = app.day_agenda(agenda_source);
874 buf.set_style(area, Style::default());
875 render_day_header(&agenda, context, &layout, buf, styles, keybindings);
876
877 match layout.mode {
878 DayViewLayoutMode::Split | DayViewLayoutMode::Stacked => {
879 if let Some(agenda_area) = layout.agenda_area {
880 render_agenda_panel(
881 &agenda,
882 app.selected_day_event_id(),
883 agenda_area,
884 buf,
885 styles,
886 );
887 }
888
889 if let Some(timeline_area) = layout.timeline_area {
890 render_timeline_panel(&agenda, timeline_area, buf, styles);
891 }
892 }
893 DayViewLayoutMode::Minimal => {
894 if area.height > 2 {
895 let text = if agenda.is_empty() {
896 "No agenda loaded".to_string()
897 } else {
898 agenda_summary(&agenda)
899 };
900 write_centered(buf, area.y + 2, area.x, area.width, &text, styles.content);
901 }
902 }
903 }
904 }
905
906 fn render_day_header(
907 agenda: &DayAgenda,
908 context: DayViewContext,
909 layout: &DayViewLayout,
910 buf: &mut Buffer,
911 styles: DayViewStyles,
912 keybindings: &KeyBindings,
913 ) {
914 let title = day_title(agenda.date);
915 write_centered(
916 buf,
917 layout.title_y,
918 layout.area.x,
919 layout.area.width,
920 &title,
921 styles.title,
922 );
923 render_title_hint(
924 buf,
925 layout.title_y,
926 layout.area.x,
927 layout.area.width,
928 &title,
929 styles.help_hint,
930 keybindings,
931 );
932
933 let Some(summary_y) = layout.summary_y else {
934 return;
935 };
936
937 let summary = match context {
938 DayViewContext::Focused => format!(
939 "{} | {} returns to month",
940 agenda_summary(agenda),
941 keybindings.display_for(KeyCommand::CloseDay)
942 ),
943 DayViewContext::ResponsiveFallback => agenda_summary(agenda),
944 };
945 write_centered(
946 buf,
947 summary_y,
948 layout.area.x,
949 layout.area.width,
950 &summary,
951 styles.summary,
952 );
953 }
954
955 fn render_create_event_modal(
956 form: &CreateEventForm,
957 area: Rect,
958 buf: &mut Buffer,
959 styles: CreateModalStyles,
960 ) {
961 if area.width == 0 || area.height == 0 {
962 return;
963 }
964
965 let modal = create_modal_area(area, form);
966 fill_rect(buf, modal, styles.panel);
967 draw_border(buf, modal, styles.border, BorderCharacters::normal());
968
969 let content = inset_rect(modal);
970 if content.width == 0 || content.height == 0 {
971 return;
972 }
973
974 write_centered(
975 buf,
976 content.y,
977 content.x,
978 content.width,
979 form.heading(),
980 styles.title,
981 );
982 let label_width = 12.min(content.width.saturating_sub(1));
983 let rows_bottom = content.bottom().saturating_sub(2);
984 let mut y = content.y.saturating_add(2);
985
986 for row in form.rows() {
987 if y >= rows_bottom {
988 break;
989 }
990
991 let label_x = content.x.saturating_add(2);
992 let value_x = label_x.saturating_add(label_width).saturating_add(1);
993 let value_width = content.right().saturating_sub(value_x);
994 let value_lines = create_modal_value_lines(row.kind, &row.value, value_width);
995 let row_height = u16::try_from(value_lines.len()).unwrap_or(u16::MAX);
996
997 for (line_index, value_line) in value_lines.iter().enumerate() {
998 let line_y = y.saturating_add(u16::try_from(line_index).unwrap_or(u16::MAX));
999 if line_y >= rows_bottom {
1000 break;
1001 }
1002
1003 let marker = if line_index == 0 && row.focused {
1004 ">"
1005 } else {
1006 " "
1007 };
1008 let label = if line_index == 0 { row.label } else { "" };
1009 write_padded_left(buf, line_y, content.x, 1, marker, styles.label);
1010 write_padded_left(buf, line_y, label_x, label_width, label, styles.label);
1011
1012 if value_x < content.right() {
1013 match row.kind {
1014 CreateEventFormRowKind::Text
1015 | CreateEventFormRowKind::Multiline
1016 | CreateEventFormRowKind::Selector => {
1017 write_padded_left(
1018 buf,
1019 line_y,
1020 value_x,
1021 value_width,
1022 value_line,
1023 styles.value,
1024 );
1025 }
1026 CreateEventFormRowKind::Toggle => {
1027 write_toggle_value(buf, line_y, value_x, value_width, value_line, styles);
1028 }
1029 }
1030 };
1031 }
1032
1033 y = y.saturating_add(row_height);
1034 }
1035
1036 if let Some(error) = form.error() {
1037 let error_y = content.bottom().saturating_sub(2);
1038 write_left(buf, error_y, content.x, content.width, error, styles.error);
1039 }
1040
1041 let footer_y = content.bottom().saturating_sub(1);
1042 write_centered(
1043 buf,
1044 footer_y,
1045 content.x,
1046 content.width,
1047 create_modal_footer(content.width),
1048 styles.footer,
1049 );
1050 }
1051
1052 fn render_recurrence_choice_modal(
1053 choice: &RecurrenceEditChoice,
1054 area: Rect,
1055 buf: &mut Buffer,
1056 styles: CreateModalStyles,
1057 ) {
1058 if area.width == 0 || area.height == 0 {
1059 return;
1060 }
1061
1062 let modal = recurrence_choice_modal_area(area);
1063 fill_rect(buf, modal, styles.panel);
1064 draw_border(buf, modal, styles.border, BorderCharacters::normal());
1065
1066 let content = inset_rect(modal);
1067 if content.width == 0 || content.height == 0 {
1068 return;
1069 }
1070
1071 write_centered(
1072 buf,
1073 content.y,
1074 content.x,
1075 content.width,
1076 "Edit",
1077 styles.title,
1078 );
1079
1080 let mut y = content.y.saturating_add(2);
1081 for row in choice.rows() {
1082 if y >= content.bottom().saturating_sub(1) {
1083 break;
1084 }
1085 let marker = if row.selected { ">" } else { " " };
1086 write_padded_left(buf, y, content.x, 1, marker, styles.label);
1087 write_left(
1088 buf,
1089 y,
1090 content.x.saturating_add(2),
1091 content.width.saturating_sub(2),
1092 row.label,
1093 if row.selected {
1094 styles.title
1095 } else {
1096 styles.value
1097 },
1098 );
1099 y = y.saturating_add(1);
1100 }
1101
1102 write_centered(
1103 buf,
1104 content.bottom().saturating_sub(1),
1105 content.x,
1106 content.width,
1107 "Enter select | Esc cancel",
1108 styles.footer,
1109 );
1110 }
1111
1112 fn render_delete_choice_modal(
1113 choice: &EventDeleteChoice,
1114 area: Rect,
1115 buf: &mut Buffer,
1116 styles: CreateModalStyles,
1117 ) {
1118 if area.width == 0 || area.height == 0 {
1119 return;
1120 }
1121
1122 let modal = recurrence_choice_modal_area(area);
1123 fill_rect(buf, modal, styles.panel);
1124 draw_border(buf, modal, styles.border, BorderCharacters::normal());
1125
1126 let content = inset_rect(modal);
1127 if content.width == 0 || content.height == 0 {
1128 return;
1129 }
1130
1131 write_centered(
1132 buf,
1133 content.y,
1134 content.x,
1135 content.width,
1136 choice.heading(),
1137 styles.error,
1138 );
1139
1140 let mut y = content.y.saturating_add(2);
1141 for row in choice.rows() {
1142 if y >= content.bottom().saturating_sub(2) {
1143 break;
1144 }
1145 let marker = if row.selected { ">" } else { " " };
1146 let row_style = if row.selected && row.dangerous {
1147 styles.error
1148 } else if row.selected {
1149 styles.title
1150 } else {
1151 styles.value
1152 };
1153 write_padded_left(buf, y, content.x, 1, marker, styles.label);
1154 write_left(
1155 buf,
1156 y,
1157 content.x.saturating_add(2),
1158 content.width.saturating_sub(2),
1159 row.label,
1160 row_style,
1161 );
1162 y = y.saturating_add(1);
1163 }
1164
1165 if let Some(error) = choice.error() {
1166 write_left(
1167 buf,
1168 content.bottom().saturating_sub(2),
1169 content.x,
1170 content.width,
1171 error,
1172 styles.error,
1173 );
1174 }
1175
1176 write_centered(
1177 buf,
1178 content.bottom().saturating_sub(1),
1179 content.x,
1180 content.width,
1181 "Enter select | Esc cancel",
1182 styles.footer,
1183 );
1184 }
1185
1186 fn render_copy_choice_modal(
1187 choice: &EventCopyChoice,
1188 area: Rect,
1189 buf: &mut Buffer,
1190 styles: CreateModalStyles,
1191 ) {
1192 if area.width == 0 || area.height == 0 {
1193 return;
1194 }
1195
1196 let modal = recurrence_choice_modal_area(area);
1197 fill_rect(buf, modal, styles.panel);
1198 draw_border(buf, modal, styles.border, BorderCharacters::normal());
1199
1200 let content = inset_rect(modal);
1201 if content.width == 0 || content.height == 0 {
1202 return;
1203 }
1204
1205 write_centered(
1206 buf,
1207 content.y,
1208 content.x,
1209 content.width,
1210 choice.heading(),
1211 styles.title,
1212 );
1213
1214 let mut y = content.y.saturating_add(2);
1215 for row in choice.rows() {
1216 if y >= content.bottom().saturating_sub(2) {
1217 break;
1218 }
1219 let marker = if row.selected { ">" } else { " " };
1220 write_padded_left(buf, y, content.x, 1, marker, styles.label);
1221 write_left(
1222 buf,
1223 y,
1224 content.x.saturating_add(2),
1225 content.width.saturating_sub(2),
1226 row.label,
1227 if row.selected {
1228 styles.title
1229 } else {
1230 styles.value
1231 },
1232 );
1233 y = y.saturating_add(1);
1234 }
1235
1236 if let Some(error) = choice.error() {
1237 write_left(
1238 buf,
1239 content.bottom().saturating_sub(2),
1240 content.x,
1241 content.width,
1242 error,
1243 styles.error,
1244 );
1245 }
1246
1247 write_centered(
1248 buf,
1249 content.bottom().saturating_sub(1),
1250 content.x,
1251 content.width,
1252 "Enter select | Esc cancel",
1253 styles.footer,
1254 );
1255 }
1256
1257 fn render_help_modal(
1258 view_mode: ViewMode,
1259 area: Rect,
1260 buf: &mut Buffer,
1261 styles: CreateModalStyles,
1262 keybindings: &KeyBindings,
1263 ) {
1264 if area.width == 0 || area.height == 0 {
1265 return;
1266 }
1267
1268 let rows = help_rows(view_mode, keybindings);
1269 let modal = help_modal_area(area, rows.len());
1270 fill_rect(buf, modal, styles.panel);
1271 draw_border(buf, modal, styles.border, BorderCharacters::normal());
1272
1273 let content = inset_rect(modal);
1274 if content.width == 0 || content.height == 0 {
1275 return;
1276 }
1277
1278 write_centered(
1279 buf,
1280 content.y,
1281 content.x,
1282 content.width,
1283 help_heading(view_mode),
1284 styles.title,
1285 );
1286
1287 let key_width = 13.min(content.width.saturating_sub(2));
1288 let description_x = content.x.saturating_add(key_width).saturating_add(2);
1289 let description_width = content.right().saturating_sub(description_x);
1290 let mut y = content.y.saturating_add(2);
1291 for (key, description) in &rows {
1292 if y >= content.bottom().saturating_sub(2) {
1293 break;
1294 }
1295 write_padded_left(buf, y, content.x, key_width, key, styles.checkbox);
1296 write_left(
1297 buf,
1298 y,
1299 description_x,
1300 description_width,
1301 description,
1302 styles.value,
1303 );
1304 y = y.saturating_add(1);
1305 }
1306
1307 write_centered(
1308 buf,
1309 content.bottom().saturating_sub(1),
1310 content.x,
1311 content.width,
1312 "Esc / Enter close",
1313 styles.footer,
1314 );
1315 }
1316
1317 fn create_modal_area(area: Rect, form: &CreateEventForm) -> Rect {
1318 if area.width < 52 || area.height < 16 {
1319 return area;
1320 }
1321
1322 let width = area.width.saturating_sub(4).min(72);
1323 let max_height = area.height.saturating_sub(4);
1324 let height = desired_create_modal_height(form, width).min(max_height);
1325 Rect::new(
1326 area.x + area.width.saturating_sub(width) / 2,
1327 area.y + area.height.saturating_sub(height) / 2,
1328 width,
1329 height,
1330 )
1331 }
1332
1333 fn help_modal_area(area: Rect, row_count: usize) -> Rect {
1334 if area.width < 48 || area.height < 12 {
1335 return area;
1336 }
1337
1338 let width = area.width.saturating_sub(4).min(66);
1339 let desired_height = u16::try_from(row_count)
1340 .unwrap_or(u16::MAX)
1341 .saturating_add(5);
1342 let height = desired_height.min(area.height.saturating_sub(4)).max(10);
1343 Rect::new(
1344 area.x + area.width.saturating_sub(width) / 2,
1345 area.y + area.height.saturating_sub(height) / 2,
1346 width,
1347 height,
1348 )
1349 }
1350
1351 fn help_heading(view_mode: ViewMode) -> &'static str {
1352 match view_mode {
1353 ViewMode::Month => "Month / Week keys",
1354 ViewMode::Day => "Day keys",
1355 }
1356 }
1357
1358 fn help_rows(view_mode: ViewMode, keybindings: &KeyBindings) -> Vec<(String, &'static str)> {
1359 match view_mode {
1360 ViewMode::Month => vec![
1361 (
1362 format!(
1363 "{} / {} / {} / {}",
1364 keybindings.display_for(KeyCommand::MoveLeft),
1365 keybindings.display_for(KeyCommand::MoveRight),
1366 keybindings.display_for(KeyCommand::MoveUp),
1367 keybindings.display_for(KeyCommand::MoveDown)
1368 ),
1369 "Move the selected date",
1370 ),
1371 (
1372 "Digits".to_string(),
1373 "Jump to a day, with quick two-digit refinement",
1374 ),
1375 (
1376 format!(
1377 "{} {} {} {} {} {} {}",
1378 keybindings.display_for(KeyCommand::JumpMonday),
1379 keybindings.display_for(KeyCommand::JumpTuesday),
1380 keybindings.display_for(KeyCommand::JumpWednesday),
1381 keybindings.display_for(KeyCommand::JumpThursday),
1382 keybindings.display_for(KeyCommand::JumpFriday),
1383 keybindings.display_for(KeyCommand::JumpSaturday),
1384 keybindings.display_for(KeyCommand::JumpSunday)
1385 ),
1386 "Jump within the selected week",
1387 ),
1388 (
1389 keybindings.display_for(KeyCommand::OpenDayOrEdit),
1390 "Open the focused day view",
1391 ),
1392 (
1393 keybindings.display_for(KeyCommand::CreateEvent),
1394 "Create an event on the selected date",
1395 ),
1396 ("Mouse".to_string(), "Select a date; double-click to open"),
1397 ("Esc / Enter".to_string(), "Close this help"),
1398 (keybindings.display_for(KeyCommand::Quit), "Quit"),
1399 ],
1400 ViewMode::Day => vec![
1401 (
1402 format!(
1403 "{} / {}",
1404 keybindings.display_for(KeyCommand::MoveLeft),
1405 keybindings.display_for(KeyCommand::MoveRight)
1406 ),
1407 "Move to the previous or next day",
1408 ),
1409 (
1410 format!(
1411 "{} / {}",
1412 keybindings.display_for(KeyCommand::MoveUp),
1413 keybindings.display_for(KeyCommand::MoveDown)
1414 ),
1415 "Select an editable event",
1416 ),
1417 (
1418 keybindings.display_for(KeyCommand::OpenDayOrEdit),
1419 "Edit the selected event",
1420 ),
1421 (
1422 keybindings.display_for(KeyCommand::CopyEvent),
1423 "Copy the selected event",
1424 ),
1425 (
1426 keybindings.display_for(KeyCommand::DeleteEvent),
1427 "Delete the selected event",
1428 ),
1429 (
1430 keybindings.display_for(KeyCommand::CreateEvent),
1431 "Create an event on this day",
1432 ),
1433 (
1434 keybindings.display_for(KeyCommand::CloseDay),
1435 "Return to month view",
1436 ),
1437 ("Esc / Enter".to_string(), "Close this help"),
1438 (keybindings.display_for(KeyCommand::Quit), "Quit"),
1439 ],
1440 }
1441 }
1442
1443 fn recurrence_choice_modal_area(area: Rect) -> Rect {
1444 if area.width < 36 || area.height < 9 {
1445 return area;
1446 }
1447
1448 let width = area.width.saturating_sub(4).min(36);
1449 let height = 9.min(area.height.saturating_sub(4));
1450 Rect::new(
1451 area.x + area.width.saturating_sub(width) / 2,
1452 area.y + area.height.saturating_sub(height) / 2,
1453 width,
1454 height,
1455 )
1456 }
1457
1458 fn desired_create_modal_height(form: &CreateEventForm, modal_width: u16) -> u16 {
1459 let content_width = modal_width.saturating_sub(2);
1460 let value_width = create_modal_value_width(content_width);
1461 let rows_height = form
1462 .rows()
1463 .into_iter()
1464 .map(|row| create_modal_row_height(row.kind, &row.value, value_width))
1465 .fold(0_u16, u16::saturating_add);
1466
1467 rows_height.saturating_add(6).max(22)
1468 }
1469
1470 fn create_modal_value_width(content_width: u16) -> u16 {
1471 let label_width = 12.min(content_width.saturating_sub(1));
1472 content_width.saturating_sub(label_width.saturating_add(3))
1473 }
1474
1475 fn create_modal_row_height(kind: CreateEventFormRowKind, value: &str, value_width: u16) -> u16 {
1476 u16::try_from(create_modal_value_lines(kind, value, value_width).len()).unwrap_or(u16::MAX)
1477 }
1478
1479 fn create_modal_footer(width: u16) -> &'static str {
1480 let full = "Tab/Up/Down fields | Left/Right change | Ctrl-S save | Esc cancel";
1481 let compact = "Left/Right | Ctrl-S save | Esc";
1482 if usize::from(width) >= full.chars().count() {
1483 full
1484 } else {
1485 compact
1486 }
1487 }
1488
1489 fn create_modal_value_lines(
1490 kind: CreateEventFormRowKind,
1491 value: &str,
1492 value_width: u16,
1493 ) -> Vec<String> {
1494 match kind {
1495 CreateEventFormRowKind::Multiline => wrap_text_lines(value, value_width),
1496 CreateEventFormRowKind::Text
1497 | CreateEventFormRowKind::Toggle
1498 | CreateEventFormRowKind::Selector => {
1499 vec![value.to_string()]
1500 }
1501 }
1502 }
1503
1504 fn render_agenda_panel(
1505 agenda: &DayAgenda,
1506 selected_event_id: Option<&str>,
1507 area: Rect,
1508 buf: &mut Buffer,
1509 styles: DayViewStyles,
1510 ) {
1511 render_panel(area, "Agenda", buf, styles);
1512
1513 let content = inset_rect(area);
1514 if content.width == 0 || content.height == 0 {
1515 return;
1516 }
1517
1518 let mut y = content.y;
1519 write_left(
1520 buf,
1521 y,
1522 content.x,
1523 content.width,
1524 "Holidays",
1525 styles.panel_title,
1526 );
1527 y += 1;
1528 if y < content.bottom() {
1529 if agenda.holidays.is_empty() {
1530 write_left(buf, y, content.x, content.width, "None", styles.muted);
1531 y += 2;
1532 } else {
1533 for holiday in &agenda.holidays {
1534 if y >= content.bottom() {
1535 return;
1536 }
1537 write_left(
1538 buf,
1539 y,
1540 content.x,
1541 content.width,
1542 &format!("* {}", holiday.name),
1543 styles.holiday,
1544 );
1545 y += 1;
1546 }
1547 y += 1;
1548 }
1549 }
1550
1551 if !agenda.all_day_events.is_empty() && y < content.bottom() {
1552 write_left(
1553 buf,
1554 y,
1555 content.x,
1556 content.width,
1557 "All day",
1558 styles.panel_title,
1559 );
1560 y += 1;
1561
1562 for event in &agenda.all_day_events {
1563 if y >= content.bottom() {
1564 return;
1565 }
1566 y = render_event_detail_lines(
1567 event,
1568 &format!("- {}", event.title),
1569 selected_event_id == Some(event.id.as_str()),
1570 content,
1571 y,
1572 buf,
1573 styles,
1574 );
1575 }
1576
1577 y += 1;
1578 }
1579
1580 if y < content.bottom() {
1581 write_left(
1582 buf,
1583 y,
1584 content.x,
1585 content.width,
1586 "Events",
1587 styles.panel_title,
1588 );
1589 y += 1;
1590 }
1591
1592 if y < content.bottom() {
1593 if agenda.timed_events.is_empty() {
1594 write_left(
1595 buf,
1596 y,
1597 content.x,
1598 content.width,
1599 "No events scheduled",
1600 styles.muted,
1601 );
1602 } else {
1603 for agenda_event in &agenda.timed_events {
1604 if y >= content.bottom() {
1605 return;
1606 }
1607 y = render_event_detail_lines(
1608 &agenda_event.event,
1609 &agenda_event_line(agenda_event),
1610 selected_event_id == Some(agenda_event.event.id.as_str()),
1611 content,
1612 y,
1613 buf,
1614 styles,
1615 );
1616 }
1617 }
1618 }
1619 }
1620
1621 fn render_event_detail_lines(
1622 event: &Event,
1623 first_line: &str,
1624 selected: bool,
1625 content: Rect,
1626 mut y: u16,
1627 buf: &mut Buffer,
1628 styles: DayViewStyles,
1629 ) -> u16 {
1630 let first_line = if selected {
1631 format!("> {first_line}")
1632 } else {
1633 format!(" {first_line}")
1634 };
1635 let style = if selected {
1636 styles.selected_event
1637 } else {
1638 styles.event
1639 };
1640 write_left(buf, y, content.x, content.width, &first_line, style);
1641 y += 1;
1642
1643 if let Some(location) = &event.location {
1644 if y >= content.bottom() {
1645 return y;
1646 }
1647 write_left(
1648 buf,
1649 y,
1650 content.x,
1651 content.width,
1652 &format!(" @ {location}"),
1653 styles.muted,
1654 );
1655 y += 1;
1656 }
1657
1658 if !event.reminders.is_empty() {
1659 if y >= content.bottom() {
1660 return y;
1661 }
1662 write_left(
1663 buf,
1664 y,
1665 content.x,
1666 content.width,
1667 &format!(" Reminders: {}", reminder_summary(event)),
1668 styles.muted,
1669 );
1670 y += 1;
1671 }
1672
1673 if let Some(notes) = &event.notes {
1674 for line in notes.lines() {
1675 if y >= content.bottom() {
1676 return y;
1677 }
1678 write_left(
1679 buf,
1680 y,
1681 content.x,
1682 content.width,
1683 &format!(" {line}"),
1684 styles.muted,
1685 );
1686 y += 1;
1687 }
1688 }
1689
1690 y
1691 }
1692
1693 fn render_timeline_panel(agenda: &DayAgenda, area: Rect, buf: &mut Buffer, styles: DayViewStyles) {
1694 render_panel(area, "24-hour timeline", buf, styles);
1695
1696 let content = inset_rect(area);
1697 if content.width == 0 || content.height == 0 {
1698 return;
1699 }
1700
1701 if content.height >= 5 {
1702 let marks = ["00:00", "06:00", "12:00", "18:00", "24:00"];
1703 for (index, mark) in marks.into_iter().enumerate() {
1704 let offset = (u16::try_from(index).expect("timeline mark index fits in u16")
1705 * content.height.saturating_sub(1))
1706 / 4;
1707 write_left(
1708 buf,
1709 content.y + offset,
1710 content.x,
1711 content.width,
1712 mark,
1713 styles.timeline_mark,
1714 );
1715 }
1716 }
1717
1718 if agenda.timed_events.is_empty() {
1719 let message_y = content.y + content.height / 2;
1720 write_centered(
1721 buf,
1722 message_y,
1723 content.x,
1724 content.width,
1725 "No timed events",
1726 styles.muted,
1727 );
1728 return;
1729 }
1730
1731 render_timeline_events(&agenda.timed_events, content, buf, styles);
1732 }
1733
1734 fn render_panel(area: Rect, title: &str, buf: &mut Buffer, styles: DayViewStyles) {
1735 draw_border(buf, area, styles.border, BorderCharacters::normal());
1736
1737 if area.width <= 2 || area.height <= 2 {
1738 return;
1739 }
1740
1741 write_left(
1742 buf,
1743 area.y,
1744 area.x + 2,
1745 area.width.saturating_sub(4),
1746 title,
1747 styles.panel_title,
1748 );
1749 }
1750
1751 fn render_timeline_events(
1752 events: &[TimedAgendaEvent],
1753 content: Rect,
1754 buf: &mut Buffer,
1755 styles: DayViewStyles,
1756 ) {
1757 if content.width == 0 || content.height == 0 {
1758 return;
1759 }
1760
1761 let event_x = content.x + content.width.min(7);
1762 if event_x >= content.right() {
1763 return;
1764 }
1765
1766 let mut label_rows = Vec::new();
1767
1768 for event in events {
1769 let start_y = timeline_y_for_minutes(content, event.visible_start.as_minutes());
1770 let end_minute = event.visible_end.as_minutes().saturating_sub(1);
1771 let end_y = timeline_y_for_minutes(content, end_minute);
1772 let group_offset = u16::try_from(event.overlap_group)
1773 .unwrap_or(u16::MAX)
1774 .saturating_mul(2);
1775 let block_x = event_x
1776 .saturating_add(group_offset)
1777 .min(content.right().saturating_sub(1));
1778
1779 for y in start_y..=end_y.max(start_y) {
1780 set_cell(buf, block_x, y, "|", styles.timeline_event);
1781 }
1782
1783 let label_x = block_x.saturating_add(2);
1784 if label_x < content.right() {
1785 let label_y = available_label_y(start_y, content, &mut label_rows);
1786 write_left(
1787 buf,
1788 label_y,
1789 label_x,
1790 content.right() - label_x,
1791 &agenda_event_line(event),
1792 styles.timeline_event,
1793 );
1794 }
1795 }
1796 }
1797
1798 fn available_label_y(start_y: u16, content: Rect, used: &mut Vec<u16>) -> u16 {
1799 let mut y = start_y;
1800 while used.contains(&y) && y + 1 < content.bottom() {
1801 y += 1;
1802 }
1803 used.push(y);
1804 y
1805 }
1806
1807 fn timeline_y_for_minutes(content: Rect, minute: u16) -> u16 {
1808 let clamped = u32::from(minute.min(DayMinute::END.as_minutes()));
1809 let height = u32::from(content.height.saturating_sub(1));
1810 let offset = (clamped * height) / u32::from(DayMinute::END.as_minutes());
1811 content.y + u16::try_from(offset).expect("timeline offset fits in terminal height")
1812 }
1813
1814 fn agenda_summary(agenda: &DayAgenda) -> String {
1815 format!(
1816 "{} holidays | {} events",
1817 agenda.holidays.len(),
1818 agenda.all_day_events.len() + agenda.timed_events.len()
1819 )
1820 }
1821
1822 fn agenda_event_line(event: &TimedAgendaEvent) -> String {
1823 let mut prefix = String::new();
1824 if event.starts_before_day {
1825 prefix.push('<');
1826 }
1827
1828 prefix.push_str(&format!(
1829 "{}-{}",
1830 day_minute_label(event.visible_start),
1831 day_minute_label(event.visible_end)
1832 ));
1833
1834 if event.ends_after_day {
1835 prefix.push('>');
1836 }
1837
1838 format!("{prefix} {}", event.event.title)
1839 }
1840
1841 fn reminder_summary(event: &Event) -> String {
1842 event
1843 .reminders
1844 .iter()
1845 .map(|reminder| reminder_label(reminder.minutes_before))
1846 .collect::<Vec<_>>()
1847 .join(", ")
1848 }
1849
1850 fn reminder_label(minutes: u16) -> String {
1851 match minutes {
1852 value if value % (24 * 60) == 0 => format!("{}d", value / (24 * 60)),
1853 value if value % 60 == 0 => format!("{}h", value / 60),
1854 value => format!("{value}m"),
1855 }
1856 }
1857
1858 fn month_preview_labels(agenda: &DayAgenda) -> Vec<String> {
1859 agenda
1860 .holidays
1861 .iter()
1862 .map(|holiday| holiday.name.clone())
1863 .chain(
1864 agenda
1865 .all_day_events
1866 .iter()
1867 .map(|event| event.title.clone()),
1868 )
1869 .chain(
1870 agenda
1871 .timed_events
1872 .iter()
1873 .map(|event| month_event_preview_label(&event.event)),
1874 )
1875 .collect()
1876 }
1877
1878 fn month_event_preview_label(event: &Event) -> String {
1879 match event.timing {
1880 EventTiming::Timed { start, .. } => {
1881 format!(
1882 "{} {}",
1883 day_minute_label(DayMinute::from_time(start.time)),
1884 event.title
1885 )
1886 }
1887 EventTiming::AllDay { .. } => event.title.clone(),
1888 }
1889 }
1890
1891 fn month_preview_summary(count: usize, width: u16) -> String {
1892 if width >= 9 {
1893 format!("+{count} Events")
1894 } else {
1895 format!("+{count}")
1896 }
1897 }
1898
1899 fn render_title(
1900 month: &CalendarMonth,
1901 layout: &MonthGridLayout,
1902 buf: &mut Buffer,
1903 styles: MonthGridStyles,
1904 keybindings: &KeyBindings,
1905 ) {
1906 let title = format!("{} {}", month.current.month, month.current.year);
1907 write_centered(
1908 buf,
1909 layout.title_y,
1910 layout.area.x,
1911 layout.area.width,
1912 &title,
1913 styles.title,
1914 );
1915 render_title_hint(
1916 buf,
1917 layout.title_y,
1918 layout.area.x,
1919 layout.area.width,
1920 &title,
1921 styles.help_hint,
1922 keybindings,
1923 );
1924 }
1925
1926 fn render_weekdays(layout: &MonthGridLayout, buf: &mut Buffer, styles: MonthGridStyles) {
1927 for (index, weekday) in weekday_labels().into_iter().enumerate() {
1928 let content = layout.cell_content_rect(0, index);
1929 write_centered(
1930 buf,
1931 layout.weekday_y,
1932 content.x,
1933 content.width,
1934 weekday,
1935 styles.weekday,
1936 );
1937 }
1938 }
1939
1940 fn render_weekdays_for_week(layout: &WeekGridLayout, buf: &mut Buffer, styles: MonthGridStyles) {
1941 for (index, weekday) in weekday_labels().into_iter().enumerate() {
1942 let content = layout.cell_content_rect(index);
1943 write_centered(
1944 buf,
1945 layout.weekday_y,
1946 content.x,
1947 content.width,
1948 weekday,
1949 styles.weekday,
1950 );
1951 }
1952 }
1953
1954 fn render_week_title(
1955 week: &CalendarWeek,
1956 layout: &WeekGridLayout,
1957 buf: &mut Buffer,
1958 styles: MonthGridStyles,
1959 keybindings: &KeyBindings,
1960 ) {
1961 let title = week_title(week);
1962 write_centered(
1963 buf,
1964 layout.title_y,
1965 layout.area.x,
1966 layout.area.width,
1967 &title,
1968 styles.title,
1969 );
1970 render_title_hint(
1971 buf,
1972 layout.title_y,
1973 layout.area.x,
1974 layout.area.width,
1975 &title,
1976 styles.help_hint,
1977 keybindings,
1978 );
1979 }
1980
1981 fn render_cell(
1982 cell: &CalendarCell,
1983 layout: &MonthGridLayout,
1984 agenda_source: &dyn AgendaSource,
1985 buf: &mut Buffer,
1986 styles: MonthGridStyles,
1987 ) {
1988 let rect = layout.cell_rect(cell.week_index, cell.weekday_index);
1989 let content = layout.cell_content_rect(cell.week_index, cell.weekday_index);
1990 render_cell_in_rect(cell, rect, content, agenda_source, buf, styles);
1991 }
1992
1993 fn render_week_cell(
1994 cell: &CalendarCell,
1995 layout: &WeekGridLayout,
1996 agenda_source: &dyn AgendaSource,
1997 buf: &mut Buffer,
1998 styles: MonthGridStyles,
1999 ) {
2000 let rect = layout.cell_rect(cell.weekday_index);
2001 let content = layout.cell_content_rect(cell.weekday_index);
2002 render_cell_in_rect(cell, rect, content, agenda_source, buf, styles);
2003 }
2004
2005 fn render_cell_in_rect(
2006 cell: &CalendarCell,
2007 rect: Rect,
2008 content: Rect,
2009 agenda_source: &dyn AgendaSource,
2010 buf: &mut Buffer,
2011 styles: MonthGridStyles,
2012 ) {
2013 let (content_style, border_style, border_chars) = cell_style(cell, styles);
2014
2015 buf.set_style(rect, content_style);
2016 draw_border(buf, rect, border_style, border_chars);
2017
2018 if content.width == 0 || content.height == 0 {
2019 return;
2020 }
2021
2022 let label = day_label(cell, content.width);
2023 let x = label_x(content, &label);
2024 buf.set_stringn(
2025 x,
2026 content.y,
2027 label,
2028 usize::from(content.width),
2029 content_style,
2030 );
2031 render_cell_previews(cell, content, agenda_source, buf, styles);
2032 }
2033
2034 fn render_cell_previews(
2035 cell: &CalendarCell,
2036 content: Rect,
2037 agenda_source: &dyn AgendaSource,
2038 buf: &mut Buffer,
2039 styles: MonthGridStyles,
2040 ) {
2041 if !cell.is_in_visible_month || content.width == 0 || content.height <= 1 {
2042 return;
2043 }
2044
2045 let agenda = DayAgenda::from_source(cell.date, agenda_source);
2046 let previews = month_preview_labels(&agenda);
2047 if previews.is_empty() {
2048 return;
2049 }
2050
2051 let capacity = usize::from(content.height - 1);
2052 let first_y = content.y + 1;
2053 if previews.len() > capacity {
2054 let summary = month_preview_summary(previews.len(), content.width);
2055 write_left(
2056 buf,
2057 first_y,
2058 content.x,
2059 content.width,
2060 &summary,
2061 styles.preview_summary,
2062 );
2063 return;
2064 }
2065
2066 for (index, preview) in previews.into_iter().enumerate() {
2067 write_left(
2068 buf,
2069 first_y + u16::try_from(index).expect("preview index fits in u16"),
2070 content.x,
2071 content.width,
2072 &preview,
2073 styles.preview,
2074 );
2075 }
2076 }
2077
2078 fn week_title(week: &CalendarWeek) -> String {
2079 let first = week.cells[0].date;
2080 let last = week.cells[DAYS_PER_WEEK - 1].date;
2081
2082 if first.year() != last.year() {
2083 return format!(
2084 "{} {}, {} - {} {}, {}",
2085 first.month(),
2086 first.day(),
2087 first.year(),
2088 last.month(),
2089 last.day(),
2090 last.year()
2091 );
2092 }
2093
2094 if first.month() != last.month() {
2095 return format!(
2096 "{} {} - {} {}, {}",
2097 first.month(),
2098 first.day(),
2099 last.month(),
2100 last.day(),
2101 last.year()
2102 );
2103 }
2104
2105 format!(
2106 "{} {}-{}, {}",
2107 first.month(),
2108 first.day(),
2109 last.day(),
2110 last.year()
2111 )
2112 }
2113
2114 fn day_title(date: CalendarDate) -> String {
2115 format!(
2116 "{}, {} {}, {}",
2117 date.weekday(),
2118 date.month(),
2119 date.day(),
2120 date.year()
2121 )
2122 }
2123
2124 fn day_minute_label(minute: DayMinute) -> String {
2125 let minutes = minute.as_minutes();
2126 format!("{:02}:{:02}", minutes / 60, minutes % 60)
2127 }
2128
2129 fn cell_style(cell: &CalendarCell, styles: MonthGridStyles) -> (Style, Style, BorderCharacters) {
2130 if cell.is_selected {
2131 return (
2132 styles.selected,
2133 styles.selected_border,
2134 BorderCharacters::selected(),
2135 );
2136 }
2137
2138 if cell.is_today {
2139 return (styles.today, styles.today_border, BorderCharacters::today());
2140 }
2141
2142 if cell.is_in_visible_month {
2143 return (
2144 styles.in_month,
2145 styles.in_month_border,
2146 BorderCharacters::normal(),
2147 );
2148 }
2149
2150 (
2151 styles.filler,
2152 styles.filler_border,
2153 BorderCharacters::normal(),
2154 )
2155 }
2156
2157 fn day_label(cell: &CalendarCell, width: u16) -> String {
2158 let mut label = cell.date.day().to_string();
2159 if cell.is_today {
2160 label.push('*');
2161 }
2162
2163 if cell.is_selected && usize::from(width) >= label.len() + 2 {
2164 label = format!("[{label}]");
2165 }
2166
2167 label
2168 }
2169
2170 fn label_x(content: Rect, label: &str) -> u16 {
2171 let label_width = u16::try_from(label.len()).unwrap_or(u16::MAX);
2172 if content.width > label_width + 1 {
2173 content.x + 1
2174 } else {
2175 content.x
2176 }
2177 }
2178
2179 #[derive(Debug, Clone, Copy)]
2180 struct BorderCharacters {
2181 horizontal: &'static str,
2182 vertical: &'static str,
2183 corner: &'static str,
2184 }
2185
2186 impl BorderCharacters {
2187 const fn normal() -> Self {
2188 Self {
2189 horizontal: "-",
2190 vertical: "|",
2191 corner: "+",
2192 }
2193 }
2194
2195 const fn today() -> Self {
2196 Self {
2197 horizontal: "=",
2198 vertical: "!",
2199 corner: "+",
2200 }
2201 }
2202
2203 const fn selected() -> Self {
2204 Self {
2205 horizontal: "#",
2206 vertical: "#",
2207 corner: "#",
2208 }
2209 }
2210 }
2211
2212 fn draw_border(buf: &mut Buffer, rect: Rect, style: Style, chars: BorderCharacters) {
2213 if rect.width == 0 || rect.height == 0 {
2214 return;
2215 }
2216
2217 let left = rect.left();
2218 let right = rect.right().saturating_sub(1);
2219 let top = rect.top();
2220 let bottom = rect.bottom().saturating_sub(1);
2221
2222 for x in left..=right {
2223 set_cell(buf, x, top, chars.horizontal, style);
2224 set_cell(buf, x, bottom, chars.horizontal, style);
2225 }
2226
2227 for y in top..=bottom {
2228 set_cell(buf, left, y, chars.vertical, style);
2229 set_cell(buf, right, y, chars.vertical, style);
2230 }
2231
2232 set_cell(buf, left, top, chars.corner, style);
2233 set_cell(buf, right, top, chars.corner, style);
2234 set_cell(buf, left, bottom, chars.corner, style);
2235 set_cell(buf, right, bottom, chars.corner, style);
2236 }
2237
2238 fn set_cell(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
2239 if let Some(cell) = buf.cell_mut((x, y)) {
2240 cell.reset();
2241 cell.set_symbol(symbol).set_style(style);
2242 }
2243 }
2244
2245 fn fill_rect(buf: &mut Buffer, rect: Rect, style: Style) {
2246 for y in rect.top()..rect.bottom() {
2247 for x in rect.left()..rect.right() {
2248 set_cell(buf, x, y, " ", style);
2249 }
2250 }
2251 }
2252
2253 fn write_centered(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) {
2254 if width == 0 || !buf.area.contains((x, y).into()) {
2255 return;
2256 }
2257
2258 let text_width = u16::try_from(text.len()).unwrap_or(u16::MAX);
2259 let start = x + width.saturating_sub(text_width) / 2;
2260 buf.set_stringn(start, y, text, usize::from(width), style);
2261 }
2262
2263 fn render_title_hint(
2264 buf: &mut Buffer,
2265 y: u16,
2266 x: u16,
2267 width: u16,
2268 title: &str,
2269 style: Style,
2270 keybindings: &KeyBindings,
2271 ) {
2272 let hint = format!("{}: Help", keybindings.display_for(KeyCommand::Help));
2273 let hint_width = u16::try_from(hint.len()).unwrap_or(u16::MAX);
2274 let title_width = u16::try_from(title.len()).unwrap_or(u16::MAX);
2275 if width <= hint_width.saturating_add(1) || title_width >= width {
2276 return;
2277 }
2278
2279 let title_start = x + width.saturating_sub(title_width) / 2;
2280 let title_end = title_start.saturating_add(title_width);
2281 let hint_x = x + width.saturating_sub(hint_width).saturating_sub(1);
2282 if hint_x <= title_end.saturating_add(3) {
2283 return;
2284 }
2285
2286 write_left(buf, y, hint_x, hint_width, &hint, style);
2287 }
2288
2289 fn write_left(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) {
2290 if width == 0 || !buf.area.contains((x, y).into()) {
2291 return;
2292 }
2293
2294 buf.set_stringn(x, y, text, usize::from(width), style);
2295 }
2296
2297 fn write_padded_left(buf: &mut Buffer, y: u16, x: u16, width: u16, text: &str, style: Style) {
2298 if width == 0 || !buf.area.contains((x, y).into()) {
2299 return;
2300 }
2301
2302 for column in x..x.saturating_add(width) {
2303 set_cell(buf, column, y, " ", style);
2304 }
2305 buf.set_stringn(x, y, text, usize::from(width), style);
2306 }
2307
2308 fn write_toggle_value(
2309 buf: &mut Buffer,
2310 y: u16,
2311 x: u16,
2312 width: u16,
2313 value: &str,
2314 styles: CreateModalStyles,
2315 ) {
2316 write_padded_left(buf, y, x, width, value, styles.value);
2317 if width == 0 || !value.starts_with('[') {
2318 return;
2319 }
2320
2321 set_cell(buf, x, y, "[", styles.checkbox);
2322 if width > 1 {
2323 let mark = value.get(1..2).unwrap_or(" ");
2324 set_cell(buf, x.saturating_add(1), y, mark, styles.checkbox_mark);
2325 }
2326 if width > 2 && value.get(2..3) == Some("]") {
2327 set_cell(buf, x.saturating_add(2), y, "]", styles.checkbox);
2328 }
2329 }
2330
2331 fn wrap_text_lines(text: &str, width: u16) -> Vec<String> {
2332 let width = usize::from(width);
2333 if width == 0 {
2334 return vec![String::new()];
2335 }
2336
2337 let mut lines = Vec::new();
2338 for raw_line in text.split('\n') {
2339 if raw_line.is_empty() {
2340 lines.push(String::new());
2341 continue;
2342 }
2343
2344 let mut line = String::new();
2345 for character in raw_line.chars() {
2346 if line.chars().count() == width {
2347 lines.push(line);
2348 line = String::new();
2349 }
2350 line.push(character);
2351 }
2352 lines.push(line);
2353 }
2354
2355 if lines.is_empty() {
2356 lines.push(String::new());
2357 }
2358 lines
2359 }
2360
2361 const fn inset_rect(area: Rect) -> Rect {
2362 Rect::new(
2363 area.x.saturating_add(1),
2364 area.y.saturating_add(1),
2365 area.width.saturating_sub(2),
2366 area.height.saturating_sub(2),
2367 )
2368 }
2369
2370 fn weekday_labels() -> [&'static str; DAYS_PER_WEEK] {
2371 array::from_fn(|index| match index {
2372 0 => weekday_label(Weekday::Sunday),
2373 1 => weekday_label(Weekday::Monday),
2374 2 => weekday_label(Weekday::Tuesday),
2375 3 => weekday_label(Weekday::Wednesday),
2376 4 => weekday_label(Weekday::Thursday),
2377 5 => weekday_label(Weekday::Friday),
2378 6 => weekday_label(Weekday::Saturday),
2379 _ => unreachable!("weekday index stays in range"),
2380 })
2381 }
2382
2383 const fn weekday_label(weekday: Weekday) -> &'static str {
2384 match weekday {
2385 Weekday::Sunday => "Sun",
2386 Weekday::Monday => "Mon",
2387 Weekday::Tuesday => "Tue",
2388 Weekday::Wednesday => "Wed",
2389 Weekday::Thursday => "Thu",
2390 Weekday::Friday => "Fri",
2391 Weekday::Saturday => "Sat",
2392 }
2393 }
2394
2395 fn distribute<const N: usize>(total: u16) -> [u16; N] {
2396 let base = total / N as u16;
2397 let extra = usize::from(total % N as u16);
2398 array::from_fn(|index| base + u16::from(index < extra))
2399 }
2400
2401 #[cfg(test)]
2402 mod tests {
2403 use super::*;
2404 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2405 use ratatui::style::Modifier;
2406 use time::{Month, Time};
2407
2408 use crate::{
2409 agenda::{
2410 Event, EventDateTime, Holiday, InMemoryAgendaSource, RecurrenceEnd,
2411 RecurrenceFrequency, RecurrenceRule, Reminder, SourceMetadata,
2412 },
2413 app::AppAction,
2414 calendar::CalendarDate,
2415 };
2416
2417 #[derive(Debug, Clone, Copy)]
2418 struct ExpectedBorder<'a> {
2419 horizontal: &'a str,
2420 vertical: &'a str,
2421 corner: &'a str,
2422 fg: Color,
2423 bg: Color,
2424 modifier: Modifier,
2425 }
2426
2427 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
2428 CalendarDate::from_ymd(year, month, day).expect("valid test date")
2429 }
2430
2431 fn key(code: KeyCode) -> KeyEvent {
2432 KeyEvent::new(code, KeyModifiers::empty())
2433 }
2434
2435 fn render_test_buffer(month: &CalendarMonth, width: u16, height: u16) -> Buffer {
2436 let area = Rect::new(0, 0, width, height);
2437 let mut buffer = Buffer::empty(area);
2438 MonthGrid::new(month).render(area, &mut buffer);
2439 buffer
2440 }
2441
2442 fn render_app_buffer(app: &AppState, width: u16, height: u16) -> Buffer {
2443 let area = Rect::new(0, 0, width, height);
2444 let mut buffer = Buffer::empty(area);
2445 AppView::new(app).render(area, &mut buffer);
2446 buffer
2447 }
2448
2449 fn render_app_buffer_with_agenda_source<S>(
2450 app: &AppState,
2451 width: u16,
2452 height: u16,
2453 agenda_source: &S,
2454 ) -> Buffer
2455 where
2456 S: AgendaSource,
2457 {
2458 let area = Rect::new(0, 0, width, height);
2459 let mut buffer = Buffer::empty(area);
2460 AppView::with_agenda_source(app, agenda_source).render(area, &mut buffer);
2461 buffer
2462 }
2463
2464 fn buffer_lines(buffer: &Buffer) -> Vec<String> {
2465 buffer_to_string(buffer)
2466 .lines()
2467 .map(str::to_string)
2468 .collect()
2469 }
2470
2471 fn cell_for_day(month: &CalendarMonth, day: u8) -> &CalendarCell {
2472 month
2473 .cells()
2474 .find(|cell| cell.is_in_visible_month && cell.date.day() == day)
2475 .expect("day exists in month")
2476 }
2477
2478 fn assert_cell_perimeter(buffer: &Buffer, rect: Rect, expected: ExpectedBorder<'_>) {
2479 let left = rect.left();
2480 let right = rect.right().saturating_sub(1);
2481 let top = rect.top();
2482 let bottom = rect.bottom().saturating_sub(1);
2483 let border_points = [
2484 ((left, top), expected.corner),
2485 ((right, top), expected.corner),
2486 ((left, bottom), expected.corner),
2487 ((right, bottom), expected.corner),
2488 ((left + 1, top), expected.horizontal),
2489 ((left + 1, bottom), expected.horizontal),
2490 ((left, top + 1), expected.vertical),
2491 ((right, top + 1), expected.vertical),
2492 ];
2493
2494 for (position, symbol) in border_points {
2495 let cell = buffer.cell(position).expect("border cell exists");
2496 assert_eq!(cell.symbol(), symbol);
2497 assert_eq!(cell.fg, expected.fg);
2498 assert_eq!(cell.bg, expected.bg);
2499 assert!(cell.modifier.contains(expected.modifier));
2500 }
2501 }
2502
2503 fn assert_styled_cell(buffer: &Buffer, x: u16, y: u16, symbol: &str, fg: Color) {
2504 let cell = buffer.cell((x, y)).expect("text cell exists");
2505 assert_eq!(cell.symbol(), symbol);
2506 assert_eq!(cell.fg, fg);
2507 assert_eq!(cell.bg, Color::Black);
2508 assert!(!cell.modifier.contains(Modifier::DIM));
2509 assert!(!cell.modifier.contains(Modifier::BOLD));
2510 }
2511
2512 fn assert_styled_text(buffer: &Buffer, x: u16, y: u16, text: &str, fg: Color) {
2513 for (offset, character) in text.chars().enumerate() {
2514 assert_styled_cell(
2515 buffer,
2516 x + u16::try_from(offset).expect("offset fits"),
2517 y,
2518 &character.to_string(),
2519 fg,
2520 );
2521 }
2522 }
2523
2524 fn centered(width: usize, text: &str) -> String {
2525 let padding = width.saturating_sub(text.len());
2526 let left = padding / 2;
2527 let right = padding - left;
2528 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
2529 }
2530
2531 fn assert_line_has_centered_title(line: &str, width: usize, title: &str) {
2532 let start = width.saturating_sub(title.len()) / 2;
2533 assert_eq!(&line[start..start + title.len()], title);
2534 }
2535
2536 fn source_metadata() -> SourceMetadata {
2537 SourceMetadata::fixture()
2538 }
2539
2540 fn local_source_metadata() -> SourceMetadata {
2541 SourceMetadata::local()
2542 }
2543
2544 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
2545 EventDateTime::new(
2546 date,
2547 Time::from_hms(hour, minute, 0).expect("valid test time"),
2548 )
2549 }
2550
2551 fn timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
2552 Event::timed(id, title, start, end, source_metadata()).expect("valid test event")
2553 }
2554
2555 fn local_timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
2556 Event::timed(id, title, start, end, local_source_metadata())
2557 .expect("valid local timed event")
2558 }
2559
2560 fn agenda_source(events: Vec<Event>, holidays: Vec<Holiday>) -> InMemoryAgendaSource {
2561 InMemoryAgendaSource::with_events_and_holidays(events, holidays)
2562 }
2563
2564 #[test]
2565 fn fixed_month_grid_renders_stable_ascii_snapshot() {
2566 let month = CalendarMonth::for_launch_date(date(2026, Month::April, 23));
2567 let buffer = render_test_buffer(&month, 49, 20);
2568 let lines = buffer_lines(&buffer);
2569
2570 assert_eq!(lines.len(), 20);
2571 assert_line_has_centered_title(&lines[0], 49, "April 2026");
2572 assert!(lines[0].contains("?: Help"));
2573 assert_eq!(
2574 lines[1],
2575 " Sun Mon Tue Wed Thu Fri Sat "
2576 );
2577 assert!(lines.join("\n").contains("[23*]"));
2578 assert!(lines.join("\n").contains("29"));
2579 assert!(lines.join("\n").contains("+------"));
2580 }
2581
2582 #[test]
2583 fn layout_uses_available_area_for_cell_sizing() {
2584 let narrow = MonthGridLayout::new(Rect::new(0, 0, 49, 20)).expect("supported layout");
2585 let wide = MonthGridLayout::new(Rect::new(0, 0, 84, 26)).expect("supported layout");
2586
2587 assert_eq!(wide.grid_area.right(), 84);
2588 assert_eq!(wide.grid_area.bottom(), 26);
2589 assert!(wide.cell_content_rect(0, 0).width > narrow.cell_content_rect(0, 0).width);
2590 assert!(wide.cell_content_rect(0, 0).height > narrow.cell_content_rect(5, 0).height);
2591 }
2592
2593 #[test]
2594 fn active_today_and_filler_styles_are_distinct() {
2595 let month =
2596 CalendarMonth::from_dates(date(2026, Month::April, 18), date(2026, Month::April, 23));
2597 let buffer = render_test_buffer(&month, 84, 26);
2598 let layout = MonthGridLayout::new(Rect::new(0, 0, 84, 26)).expect("supported layout");
2599
2600 let selected = cell_for_day(&month, 18);
2601 let selected_content =
2602 layout.cell_content_rect(selected.week_index, selected.weekday_index);
2603 let selected_cell = buffer
2604 .cell((selected_content.x + 1, selected_content.y))
2605 .expect("selected label cell exists");
2606 assert_eq!(selected_cell.bg, Color::Blue);
2607 assert_eq!(selected_cell.fg, Color::White);
2608 assert!(selected_cell.modifier.contains(Modifier::BOLD));
2609 assert!(!selected_cell.modifier.contains(Modifier::DIM));
2610
2611 let today = cell_for_day(&month, 23);
2612 let today_content = layout.cell_content_rect(today.week_index, today.weekday_index);
2613 let today_cell = buffer
2614 .cell((today_content.x + 1, today_content.y))
2615 .expect("today label cell exists");
2616 assert_eq!(today_cell.fg, Color::Yellow);
2617 assert!(today_cell.modifier.contains(Modifier::BOLD));
2618 assert!(today_cell.modifier.contains(Modifier::DIM));
2619
2620 let today_rect = layout.cell_rect(today.week_index, today.weekday_index);
2621 assert_cell_perimeter(
2622 &buffer,
2623 today_rect,
2624 ExpectedBorder {
2625 horizontal: "=",
2626 vertical: "!",
2627 corner: "+",
2628 fg: Color::Yellow,
2629 bg: Color::Reset,
2630 modifier: Modifier::DIM,
2631 },
2632 );
2633
2634 let filler_cell = buffer.cell((2, 3)).expect("filler label cell exists");
2635 assert_eq!(filler_cell.fg, Color::DarkGray);
2636 assert!(filler_cell.modifier.contains(Modifier::DIM));
2637 }
2638
2639 #[test]
2640 fn active_cell_has_focus_perimeter() {
2641 let month = CalendarMonth::for_launch_date(date(2026, Month::April, 23));
2642 let buffer = render_test_buffer(&month, 84, 26);
2643 let layout = MonthGridLayout::new(Rect::new(0, 0, 84, 26)).expect("supported layout");
2644 let selected = month.selected_cell().expect("selected cell exists");
2645 let rect = layout.cell_rect(selected.week_index, selected.weekday_index);
2646
2647 assert_cell_perimeter(
2648 &buffer,
2649 rect,
2650 ExpectedBorder {
2651 horizontal: "#",
2652 vertical: "#",
2653 corner: "#",
2654 fg: Color::Cyan,
2655 bg: Color::Blue,
2656 modifier: Modifier::BOLD,
2657 },
2658 );
2659 }
2660
2661 #[test]
2662 fn today_week_cell_has_full_focus_perimeter() {
2663 let month =
2664 CalendarMonth::from_dates(date(2026, Month::April, 20), date(2026, Month::April, 23));
2665 let area = Rect::new(0, 0, 84, 8);
2666 let mut buffer = Buffer::empty(area);
2667 WeekGrid::new(&month).render(area, &mut buffer);
2668
2669 let layout = WeekGridLayout::new(area).expect("supported week layout");
2670 let week = selected_week(&month).expect("selected week exists");
2671 let today = week
2672 .cells
2673 .iter()
2674 .find(|cell| cell.is_today)
2675 .expect("today cell appears in selected week");
2676 let rect = layout.cell_rect(today.weekday_index);
2677
2678 assert_cell_perimeter(
2679 &buffer,
2680 rect,
2681 ExpectedBorder {
2682 horizontal: "=",
2683 vertical: "!",
2684 corner: "+",
2685 fg: Color::Yellow,
2686 bg: Color::Reset,
2687 modifier: Modifier::DIM,
2688 },
2689 );
2690 }
2691
2692 #[test]
2693 fn small_supported_month_grid_still_shows_month_and_selection() {
2694 let month = CalendarMonth::for_launch_date(date(2026, Month::April, 23));
2695 let rendered = render_month_to_string(&month, 49, 20);
2696
2697 assert!(rendered.contains("April 2026"));
2698 assert!(rendered.contains("Sun"));
2699 assert!(rendered.contains("[23*]"));
2700 }
2701
2702 #[test]
2703 fn app_view_prefers_month_grid_when_full_month_fits() {
2704 let app = AppState::new(date(2026, Month::April, 23));
2705 let rendered = render_app_to_string(&app, 49, 20);
2706
2707 assert!(rendered.contains("April 2026"));
2708 assert!(rendered.contains("Sun"));
2709 assert!(rendered.contains("[23*]"));
2710 assert!(rendered.contains("30"));
2711 }
2712
2713 #[test]
2714 fn title_hint_renders_without_moving_month_title() {
2715 let app = AppState::new(date(2026, Month::April, 23));
2716 let rendered = render_app_to_string(&app, 84, 26);
2717 let lines = rendered.lines().collect::<Vec<_>>();
2718
2719 assert_line_has_centered_title(lines[0], 84, "April 2026");
2720 assert!(lines[0].contains("?: Help"));
2721 }
2722
2723 #[test]
2724 fn create_modal_renders_over_month_view() {
2725 let mut app = AppState::new(date(2026, Month::April, 23));
2726 app.apply(AppAction::OpenCreate);
2727
2728 let rendered = render_app_to_string(&app, 84, 26);
2729
2730 assert!(rendered.contains("Create"));
2731 assert!(rendered.contains("Title"));
2732 assert!(rendered.contains("Start date"));
2733 assert!(rendered.contains("Reminder"));
2734 assert!(rendered.contains("Ctrl-S save"));
2735 }
2736
2737 #[test]
2738 fn edit_modal_uses_edit_title() {
2739 let day = date(2026, Month::April, 23);
2740 let source = agenda_source(
2741 vec![local_timed_event(
2742 "planning",
2743 "Planning",
2744 at(day, 9, 0),
2745 at(day, 10, 0),
2746 )],
2747 Vec::new(),
2748 );
2749 let mut app = AppState::new(day);
2750 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2751 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2752
2753 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2754
2755 assert!(rendered.contains("Edit"));
2756 assert!(rendered.contains("Planning"));
2757 }
2758
2759 #[test]
2760 fn recurring_edit_choice_modal_renders_over_day_view() {
2761 let day = date(2026, Month::April, 23);
2762 let recurring = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 10, 0))
2763 .with_recurrence(RecurrenceRule {
2764 frequency: RecurrenceFrequency::Daily,
2765 interval: 1,
2766 end: RecurrenceEnd::Count(2),
2767 weekdays: Vec::new(),
2768 monthly: None,
2769 yearly: None,
2770 });
2771 let source = agenda_source(vec![recurring], Vec::new());
2772 let mut app = AppState::new(day);
2773 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2774 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2775
2776 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2777
2778 assert!(rendered.contains("Edit this occurrence"));
2779 assert!(rendered.contains("Edit series"));
2780 assert!(rendered.contains("Enter select"));
2781 }
2782
2783 #[test]
2784 fn delete_choice_modal_renders_over_day_view() {
2785 let day = date(2026, Month::April, 23);
2786 let source = agenda_source(
2787 vec![local_timed_event(
2788 "planning",
2789 "Planning",
2790 at(day, 9, 0),
2791 at(day, 10, 0),
2792 )],
2793 Vec::new(),
2794 );
2795 let mut app = AppState::new(day);
2796 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2797 app.apply_with_agenda_source(AppAction::OpenDelete, &source);
2798
2799 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2800
2801 assert!(rendered.contains("Delete"));
2802 assert!(rendered.contains("Delete event"));
2803 assert!(rendered.contains("Enter select"));
2804 }
2805
2806 #[test]
2807 fn copy_choice_modal_renders_over_day_view() {
2808 let day = date(2026, Month::April, 23);
2809 let source = agenda_source(
2810 vec![local_timed_event(
2811 "planning",
2812 "Planning",
2813 at(day, 9, 0),
2814 at(day, 10, 0),
2815 )],
2816 Vec::new(),
2817 );
2818 let mut app = AppState::new(day);
2819 app.apply_with_agenda_source(AppAction::OpenDay, &source);
2820 app.apply_with_agenda_source(AppAction::OpenCopy, &source);
2821
2822 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2823
2824 assert!(rendered.contains("Copy"));
2825 assert!(rendered.contains("Copy event"));
2826 assert!(rendered.contains("Enter select"));
2827 }
2828
2829 #[test]
2830 fn help_modal_shows_month_week_keys_in_month_mode() {
2831 let mut app = AppState::new(date(2026, Month::April, 23));
2832 app.apply(AppAction::OpenHelp);
2833
2834 let rendered = render_app_to_string(&app, 84, 26);
2835
2836 assert!(rendered.contains("Month / Week keys"));
2837 assert!(rendered.contains("Open the focused day view"));
2838 assert!(rendered.contains("Esc / Enter close"));
2839 assert!(!rendered.contains("Delete the selected local event"));
2840 }
2841
2842 #[test]
2843 fn help_modal_shows_day_keys_in_day_mode() {
2844 let mut app = AppState::new(date(2026, Month::April, 23));
2845 app.apply(AppAction::OpenDay);
2846 app.apply(AppAction::OpenHelp);
2847
2848 let rendered = render_app_to_string(&app, 84, 26);
2849
2850 assert!(rendered.contains("Day keys"));
2851 assert!(rendered.contains("Copy the selected event"));
2852 assert!(rendered.contains("Delete the selected event"));
2853 assert!(rendered.contains("Move to the previous or next day"));
2854 }
2855
2856 #[test]
2857 fn recurring_instances_render_without_repeat_marker() {
2858 let day = date(2026, Month::April, 23);
2859 let recurring = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 10, 0))
2860 .with_recurrence(RecurrenceRule {
2861 frequency: RecurrenceFrequency::Daily,
2862 interval: 1,
2863 end: RecurrenceEnd::Count(2),
2864 weekdays: Vec::new(),
2865 monthly: None,
2866 yearly: None,
2867 });
2868 let source = agenda_source(vec![recurring], Vec::new());
2869 let mut app = AppState::new(day.add_days(1));
2870 app.apply(AppAction::OpenDay);
2871
2872 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2873
2874 assert!(rendered.contains("09:00-10:00 Standup"));
2875 assert!(!rendered.contains("repeat"));
2876 assert!(!rendered.contains("recurring"));
2877 }
2878
2879 #[test]
2880 fn create_modal_clears_background_content() {
2881 let selected = date(2026, Month::April, 23);
2882 let mut app = AppState::new(selected);
2883 app.apply(AppAction::OpenCreate);
2884 let source = agenda_source(
2885 vec![timed_event(
2886 "behind",
2887 "BackdropGhost",
2888 at(selected, 9, 0),
2889 at(selected, 10, 0),
2890 )],
2891 Vec::new(),
2892 );
2893
2894 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
2895
2896 assert!(rendered.contains("Create"));
2897 assert!(!rendered.contains("Backdrop"));
2898 assert!(!rendered.contains("Ghost"));
2899 }
2900
2901 #[test]
2902 fn create_modal_uses_bright_labels_gray_values_and_checkbox_styles() {
2903 let selected = date(2026, Month::April, 23);
2904 let mut app = AppState::new(selected);
2905 app.apply(AppAction::OpenCreate);
2906 let _ = app.handle_create_key(key(KeyCode::Char('A')));
2907 let _ = app.handle_create_key(key(KeyCode::Tab));
2908 let _ = app.handle_create_key(key(KeyCode::Tab));
2909 let _ = app.handle_create_key(key(KeyCode::Enter));
2910
2911 let area = Rect::new(0, 0, 84, 26);
2912 let buffer = render_app_buffer(&app, area.width, area.height);
2913 let modal = create_modal_area(area, app.create_form().expect("form stays open"));
2914 let content = inset_rect(modal);
2915 let row_y = content.y.saturating_add(2);
2916 let label_x = content.x.saturating_add(2);
2917 let label_width = 12.min(content.width.saturating_sub(1));
2918 let value_x = label_x.saturating_add(label_width).saturating_add(1);
2919
2920 assert_styled_text(&buffer, content.x, row_y + 2, ">", Color::White);
2921 assert_styled_text(&buffer, label_x, row_y, "Title", Color::White);
2922 assert_styled_text(&buffer, label_x, row_y + 1, "Calendar", Color::White);
2923 assert_styled_text(&buffer, label_x, row_y + 2, "All day", Color::White);
2924 assert_styled_text(&buffer, label_x, row_y + 3, "Start date", Color::White);
2925 assert_styled_text(&buffer, value_x, row_y, "A", Color::Gray);
2926 assert_styled_text(&buffer, value_x, row_y + 1, "Local", Color::Gray);
2927 assert_styled_text(&buffer, value_x, row_y + 3, "2026-04-23", Color::Gray);
2928 assert_styled_text(&buffer, value_x, row_y + 4, "09:00", Color::Gray);
2929 assert_styled_cell(&buffer, value_x, row_y + 2, "[", Color::Yellow);
2930 assert_styled_cell(&buffer, value_x + 1, row_y + 2, "x", Color::White);
2931 assert_styled_cell(&buffer, value_x + 2, row_y + 2, "]", Color::Yellow);
2932 assert_styled_cell(&buffer, value_x, row_y + 9, "[", Color::Yellow);
2933 assert_styled_cell(&buffer, value_x + 1, row_y + 9, " ", Color::White);
2934 assert_styled_cell(&buffer, value_x + 2, row_y + 9, "]", Color::Yellow);
2935 assert_styled_text(&buffer, value_x + 4, row_y + 9, "5m", Color::Gray);
2936 }
2937
2938 #[test]
2939 fn create_modal_wraps_long_notes_and_shifts_following_rows() {
2940 let selected = date(2026, Month::April, 23);
2941 let mut app = AppState::new(selected);
2942 app.apply(AppAction::OpenCreate);
2943 for _ in 0..8 {
2944 let _ = app.handle_create_key(key(KeyCode::Tab));
2945 }
2946 let notes = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
2947 for character in notes.chars() {
2948 let _ = app.handle_create_key(key(KeyCode::Char(character)));
2949 }
2950
2951 let area = Rect::new(0, 0, 58, 32);
2952 let buffer = render_app_buffer(&app, area.width, area.height);
2953 let modal = create_modal_area(area, app.create_form().expect("form stays open"));
2954 let content = inset_rect(modal);
2955 let row_y = content.y.saturating_add(2);
2956 let notes_y = row_y.saturating_add(8);
2957 let label_x = content.x.saturating_add(2);
2958 let label_width = 12.min(content.width.saturating_sub(1));
2959 let value_x = label_x.saturating_add(label_width).saturating_add(1);
2960
2961 assert!(modal.height > 22);
2962 assert_styled_text(&buffer, label_x, notes_y, "Notes", Color::White);
2963 assert_styled_text(
2964 &buffer,
2965 value_x,
2966 notes_y,
2967 "abcdefghijklmnopqrstuvwxyz0123456789A",
2968 Color::Gray,
2969 );
2970 assert_styled_text(
2971 &buffer,
2972 value_x,
2973 notes_y + 1,
2974 "BCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl",
2975 Color::Gray,
2976 );
2977 assert_styled_text(&buffer, label_x, notes_y + 4, "Reminder", Color::White);
2978 }
2979
2980 #[test]
2981 fn create_modal_uses_full_screen_area_when_tight() {
2982 let mut app = AppState::new(date(2026, Month::April, 23));
2983 app.apply(AppAction::OpenCreate);
2984
2985 let rendered = render_app_to_string(&app, 40, 10);
2986 let lines = rendered.lines().collect::<Vec<_>>();
2987
2988 assert_eq!(lines.len(), 10);
2989 assert!(lines[1].contains("Create"));
2990 assert!(rendered.contains("Title"));
2991 assert!(rendered.contains("Left/Right"));
2992 assert!(rendered.contains("Ctrl-S save"));
2993 }
2994
2995 #[test]
2996 fn hit_test_selects_date_in_month_grid() {
2997 let app = AppState::new(date(2026, Month::April, 23));
2998 let area = Rect::new(0, 0, 84, 26);
2999 let month = app.calendar_month();
3000 let layout = MonthGridLayout::new(area).expect("supported layout");
3001 let target = cell_for_day(&month, 18);
3002 let content = layout.cell_content_rect(target.week_index, target.weekday_index);
3003
3004 assert_eq!(
3005 hit_test_app_date(&app, area, content.x, content.y),
3006 Some(date(2026, Month::April, 18))
3007 );
3008 assert_eq!(
3009 hit_test_app_date(
3010 &app,
3011 area,
3012 layout.cell_rect(target.week_index, target.weekday_index).x,
3013 layout.cell_rect(target.week_index, target.weekday_index).y,
3014 ),
3015 Some(date(2026, Month::April, 18))
3016 );
3017 }
3018
3019 #[test]
3020 fn hit_test_ignores_month_grid_chrome() {
3021 let app = AppState::new(date(2026, Month::April, 23));
3022 let area = Rect::new(0, 0, 84, 26);
3023
3024 assert_eq!(hit_test_app_date(&app, area, 0, 0), None);
3025 assert_eq!(hit_test_app_date(&app, area, 83, 1), None);
3026 }
3027
3028 #[test]
3029 fn app_view_uses_week_fallback_under_height_pressure() {
3030 let app = AppState::new(date(2026, Month::April, 23));
3031 let rendered = render_app_to_string(&app, 84, 8);
3032
3033 assert!(rendered.contains("April 19-25, 2026"));
3034 assert!(rendered.contains("Sun"));
3035 assert!(rendered.contains("[23*]"));
3036 assert!(!rendered.contains("April 2026"));
3037 assert!(!rendered.contains("29"));
3038 }
3039
3040 #[test]
3041 fn hit_test_selects_date_in_week_fallback() {
3042 let app = AppState::new(date(2026, Month::April, 23));
3043 let area = Rect::new(0, 0, 84, 8);
3044 let month = app.calendar_month();
3045 let week = selected_week(&month).expect("selected week exists");
3046 let target = week
3047 .cells
3048 .iter()
3049 .find(|cell| cell.date.day() == 21)
3050 .expect("target date appears in selected week");
3051 let layout = WeekGridLayout::new(area).expect("supported week layout");
3052 let content = layout.cell_content_rect(target.weekday_index);
3053
3054 assert_eq!(
3055 hit_test_app_date(&app, area, content.x, content.y),
3056 Some(date(2026, Month::April, 21))
3057 );
3058 }
3059
3060 #[test]
3061 fn app_view_uses_day_fallback_when_week_cannot_fit() {
3062 let app = AppState::new(date(2026, Month::April, 23));
3063 let rendered = render_app_to_string(&app, 35, 10);
3064
3065 assert!(rendered.contains("April 23, 2026"));
3066 assert!(rendered.contains("No agenda loaded"));
3067 assert!(!rendered.contains("Sun"));
3068 }
3069
3070 #[test]
3071 fn hit_test_ignores_day_fallback_and_focused_day_view() {
3072 let month_app = AppState::new(date(2026, Month::April, 23));
3073 assert_eq!(
3074 hit_test_app_date(&month_app, Rect::new(0, 0, 35, 10), 10, 2),
3075 None
3076 );
3077
3078 let mut day_app = AppState::new(date(2026, Month::April, 23));
3079 day_app.apply(AppAction::OpenDay);
3080 assert_eq!(
3081 hit_test_app_date(&day_app, Rect::new(0, 0, 84, 14), 10, 2),
3082 None
3083 );
3084 }
3085
3086 #[test]
3087 fn constrained_week_fallback_has_stable_ascii_snapshot() {
3088 let app = AppState::new(date(2026, Month::April, 23));
3089 let rendered = render_app_to_string(&app, 36, 6);
3090 let lines: Vec<_> = rendered.lines().collect();
3091
3092 assert_eq!(lines.len(), 6);
3093 assert_eq!(lines[0], " April 19-25, 2026 ");
3094 assert_eq!(lines[1], " Sun Mon Tue Wed Thu Fri Sat ");
3095 assert!(rendered.contains("23*"));
3096 assert!(rendered.contains("#"));
3097 assert!(rendered.contains("+----+"));
3098 }
3099
3100 #[test]
3101 fn responsive_resize_recomputes_without_changing_selection() {
3102 let app = AppState::from_dates(date(2026, Month::April, 18), date(2026, Month::April, 23));
3103 let selected = app.selected_date();
3104
3105 let month = render_app_to_string(&app, 84, 26);
3106 let week = render_app_to_string(&app, 84, 8);
3107 let day = render_app_to_string(&app, 35, 10);
3108
3109 assert_eq!(app.selected_date(), selected);
3110 assert!(month.contains("April 2026"));
3111 assert!(month.contains("[18]"));
3112 assert!(week.contains("April 12-18, 2026"));
3113 assert!(week.contains("[18]"));
3114 assert!(day.contains("April 18, 2026"));
3115 }
3116
3117 #[test]
3118 fn day_view_layout_splits_when_width_allows() {
3119 let layout = DayViewLayout::new(Rect::new(0, 0, 84, 14));
3120
3121 assert_eq!(layout.mode, DayViewLayoutMode::Split);
3122 assert_eq!(layout.agenda_area.expect("agenda area").width, 55);
3123 assert_eq!(layout.timeline_area.expect("timeline area").width, 28);
3124 }
3125
3126 #[test]
3127 fn day_view_layout_stacks_when_width_is_constrained() {
3128 let layout = DayViewLayout::new(Rect::new(0, 0, 49, 14));
3129
3130 assert_eq!(layout.mode, DayViewLayoutMode::Stacked);
3131 assert!(layout.agenda_area.expect("agenda area").height > 0);
3132 assert!(layout.timeline_area.expect("timeline area").height > 0);
3133 }
3134
3135 #[test]
3136 fn day_view_layout_simplifies_when_panels_cannot_fit() {
3137 let layout = DayViewLayout::new(Rect::new(0, 0, 35, 10));
3138
3139 assert_eq!(layout.mode, DayViewLayoutMode::Minimal);
3140 assert!(layout.agenda_area.is_none());
3141 assert!(layout.timeline_area.is_none());
3142 }
3143
3144 #[test]
3145 fn app_view_renders_focused_empty_day_shell() {
3146 let app = {
3147 let mut app = AppState::new(date(2026, Month::April, 23));
3148 app.apply(AppAction::OpenDay);
3149 app
3150 };
3151 let rendered = render_app_to_string(&app, 84, 14);
3152 let lines: Vec<_> = rendered.lines().collect();
3153
3154 assert_line_has_centered_title(lines[0], 84, "Thursday, April 23, 2026");
3155 assert!(lines[0].contains("?: Help"));
3156 assert_eq!(
3157 lines[1],
3158 centered(84, "0 holidays | 0 events | Esc returns to month")
3159 );
3160 assert!(rendered.contains("Agenda"));
3161 assert!(rendered.contains("Holidays"));
3162 assert!(rendered.contains("No events scheduled"));
3163 assert!(rendered.contains("24-hour timeline"));
3164 assert!(rendered.contains("No timed events"));
3165 assert!(!rendered.contains("April 2026"));
3166 }
3167
3168 #[test]
3169 fn day_view_render_follows_arrow_selected_date() {
3170 let app = {
3171 let mut app = AppState::new(date(2026, Month::April, 23));
3172 app.apply(AppAction::OpenDay);
3173 app.apply(AppAction::MoveDays(-1));
3174 app
3175 };
3176
3177 let rendered = render_app_to_string(&app, 84, 14);
3178
3179 assert!(rendered.contains("Wednesday, April 22, 2026"));
3180 assert!(!rendered.contains("Thursday, April 23, 2026"));
3181 }
3182
3183 #[test]
3184 fn day_view_renders_holidays_events_and_timeline_blocks() {
3185 let app = {
3186 let mut app = AppState::new(date(2026, Month::April, 23));
3187 app.apply(AppAction::OpenDay);
3188 app
3189 };
3190 let mut source = InMemoryAgendaSource::development_fixture();
3191 source.push_holiday(Holiday::new(
3192 "earth-day",
3193 "Earth Day",
3194 date(2026, Month::April, 23),
3195 source_metadata(),
3196 ));
3197 let rendered = render_app_to_string_with_agenda_source(&app, 84, 16, &source);
3198
3199 assert!(rendered.contains("1 holidays | 4 events | Esc returns to month"));
3200 assert!(rendered.contains("* Earth Day"));
3201 assert!(rendered.contains("- Release day"));
3202 assert!(rendered.contains("09:00-09:30 Standup"));
3203 assert!(rendered.contains("09:15-10:00 Review"));
3204 assert!(rendered.contains("23:00-24:00> Late deploy"));
3205 assert!(!rendered.contains("No timed events"));
3206 }
3207
3208 #[test]
3209 fn day_view_renders_event_details_when_space_allows() {
3210 let day = date(2026, Month::April, 23);
3211 let app = {
3212 let mut app = AppState::new(day);
3213 app.apply(AppAction::OpenDay);
3214 app
3215 };
3216 let event = timed_event("planning", "Planning", at(day, 9, 0), at(day, 10, 0))
3217 .with_location("War room")
3218 .with_notes("Bring notes")
3219 .with_reminders(vec![
3220 Reminder::minutes_before(10),
3221 Reminder::minutes_before(60),
3222 ]);
3223 let source = agenda_source(vec![event], Vec::new());
3224
3225 let rendered = render_app_to_string_with_agenda_source(&app, 84, 18, &source);
3226
3227 assert!(rendered.contains("09:00-10:00 Planning"));
3228 assert!(rendered.contains("@ War room"));
3229 assert!(rendered.contains("Reminders: 10m, 1h"));
3230 assert!(rendered.contains("Bring notes"));
3231 }
3232
3233 #[test]
3234 fn day_view_highlights_selected_local_event() {
3235 let day = date(2026, Month::April, 23);
3236 let source = agenda_source(
3237 vec![local_timed_event(
3238 "planning",
3239 "Planning",
3240 at(day, 9, 0),
3241 at(day, 10, 0),
3242 )],
3243 Vec::new(),
3244 );
3245 let mut app = AppState::new(day);
3246 app.apply_with_agenda_source(AppAction::OpenDay, &source);
3247
3248 let buffer = render_app_buffer_with_agenda_source(&app, 84, 18, &source);
3249 let rendered = buffer_to_string(&buffer);
3250 let selected_position = rendered
3251 .lines()
3252 .enumerate()
3253 .find_map(|(row, line)| {
3254 line.find("> 09:00-10:00 Planning")
3255 .map(|column| (column, row))
3256 })
3257 .expect("selected event is rendered");
3258 let selected_cell = buffer
3259 .cell((
3260 u16::try_from(selected_position.0).expect("column fits"),
3261 u16::try_from(selected_position.1).expect("row fits"),
3262 ))
3263 .expect("selected cell exists");
3264
3265 assert_eq!(selected_cell.bg, Color::Blue);
3266 assert!(selected_cell.modifier.contains(Modifier::BOLD));
3267 }
3268
3269 #[test]
3270 fn overlapping_timeline_events_keep_distinct_labels() {
3271 let day = date(2026, Month::April, 23);
3272 let app = {
3273 let mut app = AppState::new(day);
3274 app.apply(AppAction::OpenDay);
3275 app
3276 };
3277 let source = agenda_source(
3278 vec![
3279 timed_event("alpha", "Alpha", at(day, 9, 0), at(day, 10, 0)),
3280 timed_event("beta", "Beta", at(day, 9, 30), at(day, 10, 30)),
3281 ],
3282 Vec::new(),
3283 );
3284 let rendered = render_app_to_string_with_agenda_source(&app, 84, 14, &source);
3285 let lines = rendered.lines().collect::<Vec<_>>();
3286 let alpha_line = lines
3287 .iter()
3288 .position(|line| line.contains("09:00-10:00 Alpha"))
3289 .expect("alpha appears on timeline");
3290 let beta_line = lines
3291 .iter()
3292 .position(|line| line.contains("09:30-10:30 Beta"))
3293 .expect("beta appears on timeline");
3294
3295 assert_ne!(alpha_line, beta_line);
3296 assert!(rendered.contains("0 holidays | 2 events | Esc returns to month"));
3297 }
3298
3299 #[test]
3300 fn month_cells_render_previews_when_space_allows() {
3301 let day = date(2026, Month::April, 23);
3302 let app = AppState::new(day);
3303 let source = agenda_source(
3304 vec![timed_event("call", "Call", at(day, 9, 0), at(day, 9, 30))],
3305 vec![Holiday::new("holiday", "Holiday", day, source_metadata())],
3306 );
3307 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
3308
3309 assert!(rendered.contains("Holiday"));
3310 assert!(rendered.contains("09:00 Call"));
3311 assert!(!rendered.contains("+2 Events"));
3312 }
3313
3314 #[test]
3315 fn month_cells_use_compact_summary_when_event_count_exceeds_space() {
3316 let day = date(2026, Month::April, 23);
3317 let app = AppState::new(day);
3318 let source = agenda_source(
3319 vec![
3320 timed_event("one", "One", at(day, 9, 0), at(day, 9, 30)),
3321 timed_event("two", "Two", at(day, 10, 0), at(day, 10, 30)),
3322 timed_event("three", "Three", at(day, 11, 0), at(day, 11, 30)),
3323 ],
3324 Vec::new(),
3325 );
3326 let rendered = render_app_to_string_with_agenda_source(&app, 84, 26, &source);
3327
3328 assert!(rendered.contains("+3 Events"));
3329 assert!(!rendered.contains("09:00 One"));
3330 }
3331
3332 #[test]
3333 fn responsive_day_fallback_omits_close_hint() {
3334 let app = AppState::new(date(2026, Month::April, 23));
3335 let rendered = render_app_to_string(&app, 35, 10);
3336
3337 assert!(rendered.contains("April 23, 2026"));
3338 assert!(rendered.contains("No agenda loaded"));
3339 assert!(rendered.contains("0 holidays | 0 events"));
3340 assert!(!rendered.contains("Esc returns to month"));
3341 }
3342 }
3343