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