1use alloc::format;
12use alloc::vec::Vec;
13
14use hashbrown::HashMap;
15use ratatui_core::buffer::Buffer;
16use ratatui_core::layout::{Alignment, Constraint, Layout, Rect};
17use ratatui_core::style::Style;
18use ratatui_core::text::{Line, Span};
19use ratatui_core::widgets::Widget;
20use time::{Date, Duration};
21
22use crate::block::{Block, BlockExt};
23
24#[derive(Debug, Clone, Eq, PartialEq, Hash)]
26pub struct Monthly<'a, DS: DateStyler> {
27 display_date: Date,
28 events: DS,
29 show_surrounding: Option<Style>,
30 show_weekday: Option<Style>,
31 show_month: Option<Style>,
32 default_style: Style,
33 block: Option<Block<'a>>,
34}
35
36impl<'a, DS: DateStyler> Monthly<'a, DS> {
37 pub const fn new(display_date: Date, events: DS) -> Self {
39 Self {
40 display_date,
41 events,
42 show_surrounding: None,
43 show_weekday: None,
44 show_month: None,
45 default_style: Style::new(),
46 block: None,
47 }
48 }
49
50 #[must_use = "method moves the value of self and returns the modified value"]
59 pub fn show_surrounding<S: Into<Style>>(mut self, style: S) -> Self {
60 self.show_surrounding = Some(style.into());
61 self
62 }
63
64 #[must_use = "method moves the value of self and returns the modified value"]
71 pub fn show_weekdays_header<S: Into<Style>>(mut self, style: S) -> Self {
72 self.show_weekday = Some(style.into());
73 self
74 }
75
76 #[must_use = "method moves the value of self and returns the modified value"]
83 pub fn show_month_header<S: Into<Style>>(mut self, style: S) -> Self {
84 self.show_month = Some(style.into());
85 self
86 }
87
88 #[must_use = "method moves the value of self and returns the modified value"]
95 pub fn default_style<S: Into<Style>>(mut self, style: S) -> Self {
96 self.default_style = style.into();
97 self
98 }
99
100 #[must_use = "method moves the value of self and returns the modified value"]
102 pub fn block(mut self, block: Block<'a>) -> Self {
103 self.block = Some(block);
104 self
105 }
106
107 #[must_use]
109 pub fn width(&self) -> u16 {
110 const DAYS_PER_WEEK: u16 = 7;
111 const GUTTER_WIDTH: u16 = 1;
112 const DAY_WIDTH: u16 = 2;
113
114 let mut width = DAYS_PER_WEEK * (GUTTER_WIDTH + DAY_WIDTH);
115 if let Some(block) = &self.block {
116 let (left, right) = block.horizontal_space();
117 width = width.saturating_add(left).saturating_add(right);
118 }
119 width
120 }
121
122 #[must_use]
124 pub fn height(&self) -> u16 {
125 let mut height = u16::from(sunday_based_weeks(self.display_date))
126 .saturating_add(u16::from(self.show_month.is_some()))
127 .saturating_add(u16::from(self.show_weekday.is_some()));
128
129 if let Some(block) = &self.block {
130 let (top, bottom) = block.vertical_space();
131 height = height.saturating_add(top).saturating_add(bottom);
132 }
133
134 height
135 }
136
137 const fn default_bg(&self) -> Style {
139 match self.default_style.bg {
140 None => Style::new(),
141 Some(c) => Style::new().bg(c),
142 }
143 }
144
145 fn format_date(&self, date: Date) -> Span<'_> {
147 if date.month() == self.display_date.month() {
148 Span::styled(
149 format!("{:2?}", date.day()),
150 self.default_style.patch(self.events.get_style(date)),
151 )
152 } else {
153 match self.show_surrounding {
154 None => Span::styled(" ", self.default_bg()),
155 Some(s) => {
156 let style = self
157 .default_style
158 .patch(s)
159 .patch(self.events.get_style(date));
160 Span::styled(format!("{:2?}", date.day()), style)
161 }
162 }
163 }
164 }
165}
166
167impl<DS: DateStyler> Widget for Monthly<'_, DS> {
168 fn render(self, area: Rect, buf: &mut Buffer) {
169 Widget::render(&self, area, buf);
170 }
171}
172
173impl<DS: DateStyler> Widget for &Monthly<'_, DS> {
174 fn render(self, area: Rect, buf: &mut Buffer) {
175 self.block.as_ref().render(area, buf);
176 let inner = self.block.inner_if_some(area);
177 self.render_monthly(inner, buf);
178 }
179}
180
181impl<DS: DateStyler> Monthly<'_, DS> {
182 fn render_monthly(&self, area: Rect, buf: &mut Buffer) {
183 let layout = Layout::vertical([
184 Constraint::Length(self.show_month.is_some().into()),
185 Constraint::Length(self.show_weekday.is_some().into()),
186 Constraint::Fill(1),
187 ]);
188 let [month_header, days_header, days_area] = layout.areas(area);
189
190 if let Some(style) = self.show_month {
192 Line::styled(
193 format!("{} {}", self.display_date.month(), self.display_date.year()),
194 style,
195 )
196 .alignment(Alignment::Center)
197 .render(month_header, buf);
198 }
199
200 if let Some(style) = self.show_weekday {
202 Span::styled(" Su Mo Tu We Th Fr Sa", style).render(days_header, buf);
203 }
204
205 let first_of_month = self.display_date.replace_day(1).unwrap();
207 let offset = Duration::days(first_of_month.weekday().number_days_from_sunday().into());
208 let mut curr_day = first_of_month - offset;
209
210 let mut y = days_area.y;
211 while curr_day.month() != self.display_date.month().next() {
213 let mut spans = Vec::with_capacity(14);
214 for i in 0..7 {
215 if i == 0 {
218 spans.push(Span::styled(" ", Style::default()));
219 } else {
220 spans.push(Span::styled(" ", self.default_bg()));
221 }
222 spans.push(self.format_date(curr_day));
223 curr_day += Duration::DAY;
224 }
225 if buf.area.height > y {
226 buf.set_line(days_area.x, y, &spans.into(), area.width);
227 }
228 y += 1;
229 }
230 }
231}
232
233fn sunday_based_weeks(display_date: Date) -> u8 {
238 let first_of_month = display_date
239 .replace_day(1)
240 .expect("valid first day of month");
241 let last_of_month = first_of_month
242 .replace_day(first_of_month.month().length(first_of_month.year()))
243 .expect("valid last of month");
244 let first_week = first_of_month.sunday_based_week();
245 let last_week = last_of_month.sunday_based_week();
246 last_week.saturating_sub(first_week) + 1
247}
248
249pub trait DateStyler {
252 fn get_style(&self, date: Date) -> Style;
254}
255
256#[derive(Debug, Clone, Eq, PartialEq)]
258pub struct CalendarEventStore(pub HashMap<Date, Style>);
259
260impl CalendarEventStore {
261 #[cfg(feature = "std")]
268 pub fn today<S: Into<Style>>(style: S) -> Self {
269 use time::OffsetDateTime;
270 let mut res = Self::default();
271 res.add(
272 OffsetDateTime::now_local()
273 .unwrap_or_else(|_| OffsetDateTime::now_utc())
274 .date(),
275 style.into(),
276 );
277 res
278 }
279
280 pub fn add<S: Into<Style>>(&mut self, date: Date, style: S) {
287 let _ = self.0.insert(date, style.into());
289 }
290
291 fn lookup_style(&self, date: Date) -> Style {
293 self.0.get(&date).copied().unwrap_or_default()
294 }
295}
296
297impl DateStyler for CalendarEventStore {
298 fn get_style(&self, date: Date) -> Style {
299 self.lookup_style(date)
300 }
301}
302
303impl DateStyler for &CalendarEventStore {
304 fn get_style(&self, date: Date) -> Style {
305 self.lookup_style(date)
306 }
307}
308
309impl Default for CalendarEventStore {
310 fn default() -> Self {
311 Self(HashMap::with_capacity(4))
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use ratatui_core::style::{Color, Style};
318 use time::Month;
319
320 use super::*;
321 use crate::block::{Block, Padding};
322
323 #[test]
324 fn event_store() {
325 let a = (
326 Date::from_calendar_date(2023, Month::January, 1).unwrap(),
327 Style::default(),
328 );
329 let b = (
330 Date::from_calendar_date(2023, Month::January, 2).unwrap(),
331 Style::default().bg(Color::Red).fg(Color::Blue),
332 );
333 let mut s = CalendarEventStore::default();
334 s.add(b.0, b.1);
335
336 assert_eq!(
337 s.get_style(a.0),
338 a.1,
339 "Date not added to the styler should look up as Style::default()"
340 );
341 assert_eq!(
342 s.get_style(b.0),
343 b.1,
344 "Date added to styler should return the provided style"
345 );
346 }
347
348 #[test]
349 fn test_today() {
350 CalendarEventStore::today(Style::default());
351 }
352
353 #[test]
354 fn render_in_minimal_buffer() {
355 let mut buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
356 let calendar = Monthly::new(
357 Date::from_calendar_date(1984, Month::January, 1).unwrap(),
358 CalendarEventStore::default(),
359 );
360 calendar.render(buffer.area, &mut buffer);
362 assert_eq!(buffer, Buffer::with_lines([" "]));
363 }
364
365 #[test]
366 fn render_in_zero_size_buffer() {
367 let mut buffer = Buffer::empty(Rect::ZERO);
368 let calendar = Monthly::new(
369 Date::from_calendar_date(1984, Month::January, 1).unwrap(),
370 CalendarEventStore::default(),
371 );
372 calendar.render(buffer.area, &mut buffer);
374 }
375
376 #[test]
377 fn calendar_width_reflects_grid_layout() {
378 let date = Date::from_calendar_date(2023, Month::January, 1).unwrap();
379 let calendar = Monthly::new(date, CalendarEventStore::default());
380 assert_eq!(calendar.width(), 21);
381 }
382
383 #[test]
384 fn calendar_height_counts_weeks_and_headers() {
385 let date = Date::from_calendar_date(2015, Month::February, 1).unwrap();
386 let base_calendar = Monthly::new(date, CalendarEventStore::default());
387 assert_eq!(base_calendar.height(), 4);
388
389 let decorated_calendar = Monthly::new(date, CalendarEventStore::default())
390 .show_month_header(Style::default())
391 .show_weekdays_header(Style::default());
392 assert_eq!(decorated_calendar.height(), 6);
393 }
394
395 #[test]
396 fn calendar_dimensions_examples() {
397 let feb_2015 = Date::from_calendar_date(2015, Month::February, 1).unwrap();
399 let cal = Monthly::new(feb_2015, CalendarEventStore::default());
400 assert_eq!(cal.width(), 21, "4w base width");
401 assert_eq!(cal.height(), 4, "Feb 2015 rows");
402
403 let cal = Monthly::new(feb_2015, CalendarEventStore::default())
404 .show_month_header(Style::default())
405 .show_weekdays_header(Style::default());
406 assert_eq!(cal.height(), 6, "Headers add 2 rows");
407
408 let block = Block::bordered().padding(Padding::new(2, 3, 1, 2));
409 let cal = Monthly::new(feb_2015, CalendarEventStore::default()).block(block);
410 assert_eq!(cal.width(), 28, "Padding widens width");
411 assert_eq!(cal.height(), 9, "Padding grows height");
412
413 let feb_2024 = Date::from_calendar_date(2024, Month::February, 1).unwrap();
415 let cal = Monthly::new(feb_2024, CalendarEventStore::default());
416 assert_eq!(cal.width(), 21, "5w base width");
417 assert_eq!(cal.height(), 5, "Feb 2024 rows");
418
419 let cal = Monthly::new(feb_2024, CalendarEventStore::default())
420 .show_month_header(Style::default())
421 .show_weekdays_header(Style::default());
422 assert_eq!(cal.height(), 7, "Headers add 2 rows (5w)");
423
424 let cal = Monthly::new(feb_2024, CalendarEventStore::default()).block(Block::bordered());
425 assert_eq!(cal.width(), 23, "Border adds 2 cols");
426 assert_eq!(cal.height(), 7, "Border adds 2 rows");
427
428 let apr_2023 = Date::from_calendar_date(2023, Month::April, 1).unwrap();
430 let cal = Monthly::new(apr_2023, CalendarEventStore::default());
431 assert_eq!(cal.width(), 21, "6w base width");
432 assert_eq!(cal.height(), 6, "Apr 2023 rows");
433
434 let cal = Monthly::new(apr_2023, CalendarEventStore::default())
435 .show_month_header(Style::default())
436 .show_weekdays_header(Style::default());
437 assert_eq!(cal.height(), 8, "Headers add 2 rows (6w)");
438
439 let block = Block::bordered().padding(Padding::symmetric(1, 1));
440 let cal = Monthly::new(apr_2023, CalendarEventStore::default()).block(block);
441 assert_eq!(cal.width(), 25, "Symmetric padding width");
442 assert_eq!(cal.height(), 10, "Symmetric padding height");
443 }
444
445 #[test]
446 fn sunday_based_weeks_shapes() {
447 let sunday_start =
448 Date::from_calendar_date(2015, Month::February, 11).expect("valid test date");
449 let saturday_start =
450 Date::from_calendar_date(2023, Month::April, 9).expect("valid test date");
451 let leap_year =
452 Date::from_calendar_date(2024, Month::February, 29).expect("valid test date");
453
454 assert_eq!(sunday_based_weeks(sunday_start), 4);
455 assert_eq!(sunday_based_weeks(saturday_start), 6);
456 assert_eq!(sunday_based_weeks(leap_year), 5);
457 }
458}