ratatui_widgets/
calendar.rs

1//! A simple calendar widget. `(feature: widget-calendar)`
2//!
3//!
4//!
5//! The [`Monthly`] widget will display a calendar for the month provided in `display_date`. Days
6//! are styled using the default style unless:
7//! * `show_surrounding` is set, then days not in the `display_date` month will use that style.
8//! * a style is returned by the [`DateStyler`] for the day
9//!
10//! [`Monthly`] has several controls for what should be displayed
11use 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/// Display a month calendar for the month containing `display_date`
25#[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    /// Construct a calendar for the `display_date` and highlight the `events`
38    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    /// Fill the calendar slots for days not in the current month also, this causes each line to be
51    /// completely filled. If there is an event style for a date, this style will be patched with
52    /// the event's style
53    ///
54    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
55    /// your own type that implements [`Into<Style>`]).
56    ///
57    /// [`Color`]: ratatui_core::style::Color
58    #[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    /// Display a header containing weekday abbreviations
65    ///
66    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
67    /// your own type that implements [`Into<Style>`]).
68    ///
69    /// [`Color`]: ratatui_core::style::Color
70    #[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    /// Display a header containing the month and year
77    ///
78    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
79    /// your own type that implements [`Into<Style>`]).
80    ///
81    /// [`Color`]: ratatui_core::style::Color
82    #[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    /// How to render otherwise unstyled dates
89    ///
90    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
91    /// your own type that implements [`Into<Style>`]).
92    ///
93    /// [`Color`]: ratatui_core::style::Color
94    #[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    /// Render the calendar within a [Block]
101    #[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    /// Return the width required to render the calendar.
108    #[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    /// Return the height required to render the calendar.
123    #[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    /// Return a style with only the background from the default style
138    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    /// All logic to style a date goes here.
146    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        // Draw the month name and year
191        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        // Draw days of week
201        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        // Set the start of the calendar to the Sunday before the 1st (or the sunday of the first)
206        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        // go through all the weeks containing a day in the target month.
212        while curr_day.month() != self.display_date.month().next() {
213            let mut spans = Vec::with_capacity(14);
214            for i in 0..7 {
215                // Draw the gutter. Do it here so we can avoid worrying about
216                // styling the ' ' in the format_date method
217                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
233/// Compute how many Sunday-based week rows are needed to render `display_date`.
234///
235/// Mirrors the rendering logic by taking the difference between the first and last day
236/// Sunday-based week numbers (inclusive).
237fn 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
249/// Provides a method for styling a given date. [Monthly] is generic on this trait, so any type
250/// that implements this trait can be used.
251pub trait DateStyler {
252    /// Given a date, return a style for that date
253    fn get_style(&self, date: Date) -> Style;
254}
255
256/// A simple `DateStyler` based on a [`HashMap`]
257#[derive(Debug, Clone, Eq, PartialEq)]
258pub struct CalendarEventStore(pub HashMap<Date, Style>);
259
260impl CalendarEventStore {
261    /// Construct a store that has the current date styled.
262    ///
263    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
264    /// your own type that implements [`Into<Style>`]).
265    ///
266    /// [`Color`]: ratatui_core::style::Color
267    #[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    /// Add a date and style to the store
281    ///
282    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
283    /// your own type that implements [`Into<Style>`]).
284    ///
285    /// [`Color`]: ratatui_core::style::Color
286    pub fn add<S: Into<Style>>(&mut self, date: Date, style: S) {
287        // to simplify style nonsense, last write wins
288        let _ = self.0.insert(date, style.into());
289    }
290
291    /// Helper for trait impls
292    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        // This should not panic, even if the buffer is too small to render the calendar.
361        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        // This should not panic, even if the buffer has zero size.
373        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        // Feb 2015 starts Sunday and spans 4 rows.
398        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        // Feb 2024 starts Thursday and spans 5 rows.
414        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        // Apr 2023 starts Saturday and spans 6 rows.
429        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}