use jiff::civil::{Date, Weekday};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::config::CalendarConfig;
use crate::frame::Binding;
use crate::panel::{KeyOutcome, Panel, RenderContext};
use crate::theme::Theme;
const BINDINGS: &[Binding] = &[
Binding::primary("n/p", "month"),
Binding::primary("t", "today"),
Binding::extra("←/→", "month"),
Binding::extra("↑/↓", "year"),
Binding::extra("wheel", "month"),
];
const MONTH_WIDTH: u16 = 20;
const GAP: u16 = 3;
const MONTH_HEIGHT: u16 = 8;
const WEEK_ROWS: usize = 6;
const FRAME_WIDTH: u16 = 4;
const FRAME_HEIGHT: u16 = 2;
const MAX_MONTHS: usize = 12;
type MonthOffset = i32;
pub struct CalendarPanel {
config: CalendarConfig,
offset: MonthOffset,
today: Date,
}
impl CalendarPanel {
pub fn new(config: CalendarConfig) -> Self {
Self {
config,
offset: 0,
today: jiff::Zoned::now().date(),
}
}
fn week_start(&self) -> Weekday {
if self.config.week_starts.eq_ignore_ascii_case("monday") {
Weekday::Monday
} else {
Weekday::Sunday
}
}
fn scroll(&mut self, delta: MonthOffset) {
let next = self.offset.saturating_add(delta);
if shift_month(first_of_month(self.today), next).is_some() {
self.offset = next;
}
}
}
fn first_of_month(date: Date) -> Date {
date.first_of_month()
}
fn shift_month(anchor: Date, delta: MonthOffset) -> Option<Date> {
let months = MonthOffset::from(anchor.year()) * 12 + MonthOffset::from(anchor.month()) - 1;
let total = months.checked_add(delta)?;
let year = i16::try_from(total.div_euclid(12)).ok()?;
let month = i8::try_from(total.rem_euclid(12) + 1).ok()?;
Date::new(year, month, 1).ok()
}
fn leading_blanks(first: Date, week_start: Weekday) -> usize {
let offset = if week_start == Weekday::Monday {
first.weekday().to_monday_zero_offset()
} else {
first.weekday().to_sunday_zero_offset()
};
usize::try_from(offset).unwrap_or(0)
}
fn weekday_headings(week_start: Weekday) -> [&'static str; 7] {
const FROM_SUNDAY: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const FROM_MONDAY: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
if week_start == Weekday::Monday {
FROM_MONDAY
} else {
FROM_SUNDAY
}
}
fn month_name(month: i8) -> &'static str {
const NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
NAMES
.get(usize::try_from(month - 1).unwrap_or(0))
.copied()
.unwrap_or("")
}
fn centred(text: &str) -> String {
let width = usize::from(MONTH_WIDTH);
let len = text.chars().count();
if len >= width {
return text.to_string();
}
let left = (width - len) / 2;
let right = width - len - left;
format!("{:left$}{text}{:right$}", "", "")
}
fn month_block(
first: Date,
today: Date,
week_start: Weekday,
theme: &Theme,
focused: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(usize::from(MONTH_HEIGHT));
lines.push(Line::from(Span::styled(
centred(&format!("{} {}", month_name(first.month()), first.year())),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
weekday_headings(week_start).join(" "),
Style::default().fg(theme.label),
)));
let days = usize::try_from(first.days_in_month()).unwrap_or(0);
let blanks = leading_blanks(first, week_start);
let is_today_month = today.year() == first.year() && today.month() == first.month();
let mut day = 1usize;
for week in 0..WEEK_ROWS {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(13);
for column in 0..7 {
if column > 0 {
spans.push(Span::raw(" "));
}
let leading = week == 0 && column < blanks;
if leading || day > days {
spans.push(Span::raw(" "));
continue;
}
let is_today = is_today_month && day == usize::try_from(today.day()).unwrap_or(0);
let style = if is_today {
let base = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::REVERSED | Modifier::BOLD);
if focused {
base
} else {
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::REVERSED)
}
} else {
Style::default().fg(theme.text)
};
spans.push(Span::styled(format!("{day:>2}"), style));
day += 1;
}
lines.push(Line::from(spans));
}
lines
}
fn grid_shape(area: Rect, months_across: usize) -> (usize, usize) {
let fits_across = usize::from((area.width + GAP) / (MONTH_WIDTH + GAP)).max(1);
let columns = fits_across.min(months_across.max(1));
let fits_down = usize::from(area.height / MONTH_HEIGHT).max(1);
let rows = fits_down.min(MAX_MONTHS.div_ceil(columns).max(1));
(columns, rows)
}
impl Panel for CalendarPanel {
fn title(&self) -> String {
"Calendar".to_string()
}
fn counter(&self) -> Option<String> {
(self.offset != 0).then(|| format!("{:+} mo", self.offset))
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
let months = u16::from(self.config.months.clamp(1, 12));
Some(months * MONTH_WIDTH + (months - 1) * GAP + FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
let columns = usize::from(self.config.months.clamp(1, 12));
let rows = u16::try_from(MAX_MONTHS.div_ceil(columns)).unwrap_or(1);
Some(rows * MONTH_HEIGHT + FRAME_HEIGHT)
}
fn refresh_interval(&self) -> std::time::Duration {
std::time::Duration::from_secs(30)
}
fn tick(&mut self) {
self.today = jiff::Zoned::now().date();
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
match key.code {
KeyCode::Char('n' | 'l') | KeyCode::Right => {
self.scroll(1);
KeyOutcome::Consumed
}
KeyCode::Char('p' | 'h') | KeyCode::Left => {
self.scroll(-1);
KeyOutcome::Consumed
}
KeyCode::Char('j') | KeyCode::Down => {
self.scroll(12);
KeyOutcome::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll(-12);
KeyOutcome::Consumed
}
KeyCode::Char('t') => {
self.offset = 0;
KeyOutcome::Consumed
}
_ => KeyOutcome::Ignored,
}
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
match event.kind {
MouseEventKind::ScrollDown => {
self.scroll(1);
KeyOutcome::Consumed
}
MouseEventKind::ScrollUp => {
self.scroll(-1);
KeyOutcome::Consumed
}
_ => KeyOutcome::Ignored,
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
if area.width == 0 || area.height == 0 {
return;
}
let across = usize::from(self.config.months.clamp(1, 12));
let (columns, rows) = grid_shape(area, across);
let week_start = self.week_start();
let anchor = first_of_month(self.today);
for row in 0..rows {
let mut strip: Vec<Line<'static>> = vec![Line::default(); usize::from(MONTH_HEIGHT)];
let mut drew_any = false;
for column in 0..columns {
let index = row * columns + column;
if index >= MAX_MONTHS {
break;
}
let Some(first) = shift_month(
anchor,
self.offset
.saturating_add(MonthOffset::try_from(index).unwrap_or(0)),
) else {
continue;
};
drew_any = true;
let block = month_block(first, self.today, week_start, ctx.theme, ctx.focused);
for (line_index, line) in block.into_iter().enumerate() {
let Some(target) = strip.get_mut(line_index) else {
continue;
};
if column > 0 {
target.spans.push(Span::raw(" ".repeat(usize::from(GAP))));
}
target.spans.extend(line.spans);
}
}
if !drew_any {
continue;
}
let y = area.y + u16::try_from(row).unwrap_or(0) * MONTH_HEIGHT;
if y >= area.y + area.height {
break;
}
let height = MONTH_HEIGHT.min(area.y + area.height - y);
frame.render_widget(
Paragraph::new(strip),
Rect::new(area.x, y, area.width, height),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn panel() -> CalendarPanel {
CalendarPanel::new(CalendarConfig::default())
}
#[test]
fn a_month_matches_what_cal_prints() {
let july = Date::new(2026, 7, 1).unwrap();
let theme = Theme::default();
let block = month_block(july, july, Weekday::Sunday, &theme, true);
let text: Vec<String> = block
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(text[0].trim(), "July 2026");
assert_eq!(text[1], "Su Mo Tu We Th Fr Sa");
assert_eq!(text[2], " 1 2 3 4");
assert_eq!(text[3], " 5 6 7 8 9 10 11");
assert_eq!(text[4], "12 13 14 15 16 17 18");
assert_eq!(text[5], "19 20 21 22 23 24 25");
assert_eq!(text[6], "26 27 28 29 30 31");
}
#[test]
fn a_month_block_is_always_the_same_height() {
let theme = Theme::default();
for (year, month) in [(2026, 2), (2026, 8), (2026, 3)] {
let first = Date::new(year, month, 1).unwrap();
let block = month_block(first, first, Weekday::Sunday, &theme, true);
assert_eq!(
block.len(),
usize::from(MONTH_HEIGHT),
"{month}/{year} was {} lines",
block.len()
);
}
}
#[test]
fn today_is_the_only_reversed_day() {
let theme = Theme::default();
let today = Date::new(2026, 7, 25).unwrap();
let block = month_block(today.first_of_month(), today, Weekday::Sunday, &theme, true);
let reversed: Vec<String> = block
.iter()
.flat_map(|line| line.spans.iter())
.filter(|s| s.style.add_modifier.contains(Modifier::REVERSED))
.map(|s| s.content.trim().to_string())
.collect();
assert_eq!(reversed, vec!["25".to_string()]);
}
#[test]
fn today_is_not_marked_in_a_month_it_does_not_fall_in() {
let theme = Theme::default();
let today = Date::new(2026, 7, 25).unwrap();
let august = Date::new(2026, 8, 1).unwrap();
let block = month_block(august, today, Weekday::Sunday, &theme, true);
assert!(
!block
.iter()
.flat_map(|line| line.spans.iter())
.any(|s| s.style.add_modifier.contains(Modifier::REVERSED)),
"a different month must not mark the 25th"
);
}
#[test]
fn a_monday_week_shifts_the_headings_and_the_blanks() {
let theme = Theme::default();
let july = Date::new(2026, 7, 1).unwrap();
let block = month_block(july, july, Weekday::Monday, &theme, true);
let header: String = block[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(header, "Mo Tu We Th Fr Sa Su");
assert_eq!(leading_blanks(july, Weekday::Monday), 2);
assert_eq!(leading_blanks(july, Weekday::Sunday), 3);
}
#[test]
fn shifting_months_crosses_year_boundaries_in_both_directions() {
let december = Date::new(2026, 12, 1).unwrap();
assert_eq!(
shift_month(december, 1).unwrap(),
Date::new(2027, 1, 1).unwrap()
);
assert_eq!(
shift_month(december, 13).unwrap(),
Date::new(2028, 1, 1).unwrap()
);
let january = Date::new(2026, 1, 1).unwrap();
assert_eq!(
shift_month(january, -1).unwrap(),
Date::new(2025, 12, 1).unwrap()
);
assert_eq!(
shift_month(january, -13).unwrap(),
Date::new(2024, 12, 1).unwrap()
);
}
#[test]
fn shifting_off_the_end_of_the_calendar_is_refused_rather_than_wrapping() {
let anchor = Date::new(2026, 7, 1).unwrap();
assert!(shift_month(anchor, MonthOffset::MAX).is_none());
assert!(shift_month(anchor, MonthOffset::MIN).is_none());
}
#[test]
fn scrolling_saturates_instead_of_blanking_the_panel() {
let mut p = panel();
for _ in 0..40 {
p.scroll(MonthOffset::MAX / 2);
}
assert!(
shift_month(first_of_month(p.today), p.offset).is_some(),
"offset {} does not resolve to a real month",
p.offset
);
}
#[test]
fn t_returns_to_today_from_anywhere() {
let mut p = panel();
p.scroll(7);
assert_ne!(p.offset, 0);
p.handle_key(KeyEvent::from(KeyCode::Char('t')));
assert_eq!(p.offset, 0);
assert_eq!(p.counter(), None, "no counter while showing today");
}
#[test]
fn the_wheel_moves_one_month_at_a_time() {
use ratatui::crossterm::event::KeyModifiers;
let mut p = panel();
let wheel = |kind| MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
p.handle_mouse(wheel(MouseEventKind::ScrollDown), Rect::new(0, 0, 40, 10));
assert_eq!(p.offset, 1);
p.handle_mouse(wheel(MouseEventKind::ScrollUp), Rect::new(0, 0, 40, 10));
assert_eq!(p.offset, 0);
}
#[test]
fn the_counter_shows_which_way_the_view_moved() {
let mut p = panel();
p.scroll(2);
assert_eq!(p.counter(), Some("+2 mo".to_string()));
p.scroll(-4);
assert_eq!(p.counter(), Some("-2 mo".to_string()));
}
#[test]
fn every_month_title_is_centred_over_its_own_block() {
let theme = Theme::default();
let july = Date::new(2026, 7, 1).unwrap();
for (year, month) in [(2026, 7), (2026, 8), (2026, 9), (2026, 12)] {
let first = Date::new(year, month, 1).unwrap();
let block = month_block(first, july, Weekday::Sunday, &theme, true);
for (index, line) in block.iter().enumerate() {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
text.chars().count(),
usize::from(MONTH_WIDTH),
"{month}/{year} line {index} is {text:?}, not {MONTH_WIDTH} cells"
);
}
}
}
#[test]
fn a_title_sits_over_the_weekday_header_it_belongs_to() {
let theme = Theme::default();
let text_of = |line: &Line<'static>| -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
};
for name in ["July 2026", "August 2026", "September 2026", "May 2026"] {
let line = centred(name);
let leading = line.len() - line.trim_start().len();
let trailing = line.len() - line.trim_end().len();
assert_eq!(line.chars().count(), usize::from(MONTH_WIDTH));
assert!(
trailing >= leading && trailing - leading <= 1,
"`{name}` is off centre: {leading} left, {trailing} right"
);
}
let block = month_block(
Date::new(2026, 9, 1).unwrap(),
Date::new(2026, 9, 1).unwrap(),
Weekday::Sunday,
&theme,
true,
);
assert_eq!(text_of(&block[1]), "Su Mo Tu We Th Fr Sa");
}
#[test]
fn narrow_panels_still_get_one_month() {
assert_eq!(grid_shape(Rect::new(0, 0, 5, 20), 2).0, 1);
assert_eq!(grid_shape(Rect::new(0, 0, 1, 1), 2), (1, 1));
}
#[test]
fn months_sit_side_by_side_up_to_the_configured_width() {
let wide = Rect::new(0, 0, MONTH_WIDTH * 2 + GAP, MONTH_HEIGHT);
assert_eq!(grid_shape(wide, 2), (2, 1), "both months fit across");
let wider = Rect::new(0, 0, MONTH_WIDTH * 6, MONTH_HEIGHT);
assert_eq!(grid_shape(wider, 2).0, 2, "the width cap holds");
}
#[test]
fn a_tall_panel_stacks_another_row_of_months_rather_than_leaving_a_void() {
let one_row = Rect::new(0, 0, MONTH_WIDTH * 2 + GAP, MONTH_HEIGHT);
assert_eq!(grid_shape(one_row, 2), (2, 1));
let two_rows = Rect::new(0, 0, MONTH_WIDTH * 2 + GAP, MONTH_HEIGHT * 2);
assert_eq!(
grid_shape(two_rows, 2),
(2, 2),
"twice the height is four months, not two and a gap"
);
let three_rows = Rect::new(0, 0, MONTH_WIDTH * 2 + GAP, MONTH_HEIGHT * 3);
assert_eq!(grid_shape(three_rows, 2), (2, 3));
}
#[test]
fn a_single_narrow_column_stacks_downwards() {
let tall = Rect::new(0, 0, MONTH_WIDTH, MONTH_HEIGHT * 4);
assert_eq!(grid_shape(tall, 2), (1, 4), "one across, four down");
}
#[test]
fn a_year_is_the_most_that_is_ever_shown() {
let enormous = Rect::new(0, 0, MONTH_WIDTH * 2 + GAP, MONTH_HEIGHT * 40);
let (columns, rows) = grid_shape(enormous, 2);
assert!(
columns * rows <= MAX_MONTHS,
"{columns}x{rows} is more than a year"
);
assert_eq!(rows, 6, "six rows of two is exactly a year");
}
#[test]
fn the_declared_width_matches_what_the_months_actually_need() {
let panel = CalendarPanel::new(CalendarConfig {
months: 2,
..CalendarConfig::default()
});
let declared = panel.max_width().unwrap();
let interior = declared - FRAME_WIDTH;
assert_eq!(
grid_shape(Rect::new(0, 0, interior, MONTH_HEIGHT), 2),
(2, 1),
"the declared width must fit exactly the months it claims"
);
assert_eq!(
grid_shape(Rect::new(0, 0, interior - 1, MONTH_HEIGHT), 2).0,
1,
"and one column less must not"
);
}
}