use std::collections::HashMap;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 => 31,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
3 => 31,
4 => 30,
5 => 31,
6 => 30,
7 => 31,
8 => 31,
9 => 30,
10 => 31,
11 => 30,
12 => 31,
_ => 30, }
}
fn day_of_week(year: i32, month: u32, day: u32) -> u32 {
const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = if month < 3 { year - 1 } else { year };
let result = (y + y / 4 - y / 100 + y / 400 + T[(month - 1) as usize] + day as i32) % 7;
((result + 7) % 7) as u32
}
fn month_name_for(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Unknown",
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum CalendarMessage {
NextMonth,
PrevMonth,
NextYear,
PrevYear,
SelectDay(u32),
SelectPrevDay,
SelectNextDay,
SelectPrevWeek,
SelectNextWeek,
ConfirmSelection,
Today {
year: i32,
month: u32,
day: u32,
},
SetDate {
year: i32,
month: u32,
},
AddEvent {
year: i32,
month: u32,
day: u32,
color: Color,
},
ClearEvents,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CalendarOutput {
DateSelected(i32, u32, u32),
MonthChanged(i32, u32),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct CalendarState {
year: i32,
month: u32,
selected_day: Option<u32>,
events: HashMap<(i32, u32, u32), Color>,
title: Option<String>,
}
impl Default for CalendarState {
fn default() -> Self {
Self::new(1970, 1)
}
}
impl CalendarState {
pub fn new(year: i32, month: u32) -> Self {
Self {
year,
month,
selected_day: None,
events: HashMap::new(),
title: None,
}
}
pub fn with_selected_day(mut self, day: u32) -> Self {
self.selected_day = Some(day);
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_event(mut self, year: i32, month: u32, day: u32, color: Color) -> Self {
self.events.insert((year, month, day), color);
self
}
pub fn year(&self) -> i32 {
self.year
}
pub fn month(&self) -> u32 {
self.month
}
pub fn selected_day(&self) -> Option<u32> {
self.selected_day
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn set_selected_day(&mut self, day: Option<u32>) {
self.selected_day = day;
}
pub fn month_name(&self) -> &str {
month_name_for(self.month)
}
pub fn add_event(&mut self, year: i32, month: u32, day: u32, color: Color) {
self.events.insert((year, month, day), color);
}
pub fn clear_events(&mut self) {
self.events.clear();
}
pub fn has_event(&self, year: i32, month: u32, day: u32) -> bool {
self.events.contains_key(&(year, month, day))
}
pub fn update(&mut self, msg: CalendarMessage) -> Option<CalendarOutput> {
Calendar::update(self, msg)
}
}
pub struct Calendar;
impl Calendar {
fn go_prev_month(state: &mut CalendarState) {
if state.month == 1 {
state.month = 12;
state.year -= 1;
} else {
state.month -= 1;
}
if let Some(day) = state.selected_day {
let max_day = days_in_month(state.year, state.month);
if day > max_day {
state.selected_day = Some(max_day);
}
}
}
fn go_next_month(state: &mut CalendarState) {
if state.month == 12 {
state.month = 1;
state.year += 1;
} else {
state.month += 1;
}
if let Some(day) = state.selected_day {
let max_day = days_in_month(state.year, state.month);
if day > max_day {
state.selected_day = Some(max_day);
}
}
}
}
impl Component for Calendar {
type State = CalendarState;
type Message = CalendarMessage;
type Output = CalendarOutput;
fn init() -> Self::State {
CalendarState::new(2026, 1)
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
CalendarMessage::NextMonth => {
Self::go_next_month(state);
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::PrevMonth => {
Self::go_prev_month(state);
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::NextYear => {
state.year += 1;
if let Some(day) = state.selected_day {
let max_day = days_in_month(state.year, state.month);
if day > max_day {
state.selected_day = Some(max_day);
}
}
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::PrevYear => {
state.year -= 1;
if let Some(day) = state.selected_day {
let max_day = days_in_month(state.year, state.month);
if day > max_day {
state.selected_day = Some(max_day);
}
}
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::SelectDay(day) => {
let max_day = days_in_month(state.year, state.month);
let clamped = day.min(max_day).max(1);
state.selected_day = Some(clamped);
None
}
CalendarMessage::SelectPrevDay => {
let current_day = state.selected_day.unwrap_or(1);
if current_day <= 1 {
Self::go_prev_month(state);
let last_day = days_in_month(state.year, state.month);
state.selected_day = Some(last_day);
Some(CalendarOutput::MonthChanged(state.year, state.month))
} else {
state.selected_day = Some(current_day - 1);
None
}
}
CalendarMessage::SelectNextDay => {
let current_day = state.selected_day.unwrap_or(1);
let max_day = days_in_month(state.year, state.month);
if current_day >= max_day {
Self::go_next_month(state);
state.selected_day = Some(1);
Some(CalendarOutput::MonthChanged(state.year, state.month))
} else {
state.selected_day = Some(current_day + 1);
None
}
}
CalendarMessage::SelectPrevWeek => {
let current_day = state.selected_day.unwrap_or(1);
if current_day <= 7 {
let days_back = 7 - current_day;
Self::go_prev_month(state);
let prev_max = days_in_month(state.year, state.month);
state.selected_day = Some(prev_max - days_back);
Some(CalendarOutput::MonthChanged(state.year, state.month))
} else {
state.selected_day = Some(current_day - 7);
None
}
}
CalendarMessage::SelectNextWeek => {
let current_day = state.selected_day.unwrap_or(1);
let max_day = days_in_month(state.year, state.month);
if current_day + 7 > max_day {
let overflow = current_day + 7 - max_day;
Self::go_next_month(state);
state.selected_day = Some(overflow);
Some(CalendarOutput::MonthChanged(state.year, state.month))
} else {
state.selected_day = Some(current_day + 7);
None
}
}
CalendarMessage::ConfirmSelection => {
if let Some(day) = state.selected_day {
Some(CalendarOutput::DateSelected(state.year, state.month, day))
} else {
None
}
}
CalendarMessage::Today { year, month, day } => {
state.year = year;
state.month = month;
let max_day = days_in_month(year, month);
state.selected_day = Some(day.min(max_day).max(1));
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::SetDate { year, month } => {
state.year = year;
state.month = month;
if let Some(day) = state.selected_day {
let max_day = days_in_month(year, month);
if day > max_day {
state.selected_day = Some(max_day);
}
}
Some(CalendarOutput::MonthChanged(state.year, state.month))
}
CalendarMessage::AddEvent {
year,
month,
day,
color,
} => {
state.events.insert((year, month, day), color);
None
}
CalendarMessage::ClearEvents => {
state.events.clear();
None
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Left | Key::Char('h') => Some(CalendarMessage::SelectPrevDay),
Key::Right | Key::Char('l') => Some(CalendarMessage::SelectNextDay),
Key::Up | Key::Char('k') => Some(CalendarMessage::SelectPrevWeek),
Key::Down | Key::Char('j') => Some(CalendarMessage::SelectNextWeek),
Key::PageUp => Some(CalendarMessage::PrevMonth),
Key::PageDown => Some(CalendarMessage::NextMonth),
Key::Enter | Key::Char(' ') => Some(CalendarMessage::ConfirmSelection),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height == 0 || ctx.area.width == 0 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"Calendar".to_string(),
))
.with_id("calendar")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let normal_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.normal_style()
};
let header_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_bold_style()
} else {
Style::default()
.fg(ctx.theme.foreground)
.add_modifier(Modifier::BOLD)
};
let day_header_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
Style::default()
.fg(ctx.theme.primary)
.add_modifier(Modifier::BOLD)
};
let title_text = if let Some(ref title) = state.title {
format!("{} - {} {}", title, state.month_name(), state.year)
} else {
format!("{} {}", state.month_name(), state.year)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(format!(" {title_text} "), header_style));
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if inner.height == 0 || inner.width == 0 {
return;
}
let dow_line = Line::from(vec![Span::styled(
" Su Mo Tu We Th Fr Sa",
day_header_style,
)]);
let mut lines: Vec<Line<'_>> = Vec::new();
lines.push(dow_line);
let first_dow = day_of_week(state.year, state.month, 1);
let total_days = days_in_month(state.year, state.month);
let selected_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.selected_highlight_style(ctx.focused)
};
let mut day = 1u32;
for week in 0..6 {
if day > total_days {
break;
}
let mut spans: Vec<Span<'_>> = Vec::new();
for dow in 0..7u32 {
if week == 0 && dow < first_dow {
spans.push(Span::styled(" ", normal_style));
} else if day > total_days {
spans.push(Span::styled(" ", normal_style));
} else {
let is_selected = state.selected_day == Some(day);
let has_event = state.events.contains_key(&(state.year, state.month, day));
let event_color = state.events.get(&(state.year, state.month, day));
let day_str = if has_event {
format!("{day:>3}\u{2022}")
} else {
format!("{day:>3} ")
};
let style = if is_selected {
selected_style
} else if let Some(&color) = event_color {
Style::default().fg(color)
} else {
normal_style
};
spans.push(Span::styled(day_str, style));
day += 1;
}
}
lines.push(Line::from(spans));
}
let footer_style = if ctx.disabled {
ctx.theme.disabled_style()
} else {
ctx.theme.placeholder_style()
};
lines.push(Line::from(vec![Span::styled(
" \u{25c0} PgUp PgDn \u{25b6}",
footer_style,
)]));
let paragraph = Paragraph::new(lines).style(normal_style);
ctx.frame.render_widget(paragraph, inner);
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod view_tests;