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