//! Calendar formatting and display with localization and color support.
use chrono::{Datelike, Locale, NaiveDate, Weekday};
use unicode_width::UnicodeWidthStr;
use crate::types::{
COLOR_RED, COLOR_RESET, COLOR_REVERSE, COLOR_SAND_YELLOW, COLOR_TEAL, CalContext,
GUTTER_WIDTH_YEAR, MonthData,
};
#[cfg(feature = "plugins")]
use std::sync::Mutex;
#[cfg(feature = "plugins")]
static PLUGIN: Mutex> = Mutex::new(None);
#[cfg(feature = "plugins")]
static COUNTRY: Mutex > = Mutex::new(None);
/// Cache for holiday data: (year, month, data) or (year, 0, full_year_data)
#[cfg(feature = "plugins")]
static HOLIDAY_CACHE: Mutex > = Mutex::new(None);
/// Preload holiday data for entire year (called when -y flag is used)
#[cfg(feature = "plugins")]
pub fn preload_year_holidays(ctx: &CalContext, year: i32) {
if !ctx.holidays {
return;
}
// Check if already cached
{
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
if let Some((cached_year, cached_month, _)) = &*cache_guard
&& *cached_year == year
&& *cached_month == 0
{
return;
}
}
if !init_plugin() {
return;
}
let plugin_guard = PLUGIN.lock().unwrap();
let country_guard = COUNTRY.lock().unwrap();
if let (Some(plugin), Some(country)) = (&*plugin_guard, &*country_guard)
&& let Some(data) = plugin.get_year_holidays(year, country)
{
let mut cache_guard = HOLIDAY_CACHE.lock().unwrap();
*cache_guard = Some((year, 0, data));
}
}
#[cfg(not(feature = "plugins"))]
pub fn preload_year_holidays(_ctx: &CalContext, _year: i32) {}
#[cfg(feature = "plugins")]
fn init_plugin() -> bool {
let mut plugin_guard = PLUGIN.lock().unwrap();
if plugin_guard.is_some() {
return true;
}
if let Some(plugin) = crate::plugin_api::try_load_plugin() {
let country = plugin.get_country_from_locale();
let mut country_guard = COUNTRY.lock().unwrap();
*country_guard = Some(country.clone());
*plugin_guard = Some(plugin);
true
} else {
false
}
}
#[cfg(feature = "plugins")]
fn get_holiday_code(ctx: &CalContext, year: i32, month: u32, day: u32) -> i32 {
if !ctx.holidays {
return 0;
}
// Check cache (including full year cache with month=0)
{
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
if let Some((cached_year, cached_month, data)) = &*cache_guard
&& *cached_year == year
{
let day_idx = if *cached_month == 0 {
// Full year data - calculate day of year
let date = chrono::NaiveDate::from_ymd_opt(year, month, day);
if let Some(d) = date {
d.ordinal() as usize - 1
} else {
return 0;
}
} else if *cached_month == month {
// Month data
(day - 1) as usize
} else {
return 0;
};
if day_idx < data.len() {
return data
.chars()
.nth(day_idx)
.and_then(|c| c.to_digit(10).map(|d| d as i32))
.unwrap_or(0);
}
}
}
// Cache miss - fetch data for the month
if !init_plugin() {
return 0;
}
let plugin_guard = PLUGIN.lock().unwrap();
let country_guard = COUNTRY.lock().unwrap();
if let (Some(plugin), Some(country)) = (&*plugin_guard, &*country_guard)
&& let Some(data) = plugin.get_holidays(year, month, country)
{
// Don't overwrite full year cache with month data
let mut cache_guard = HOLIDAY_CACHE.lock().unwrap();
let should_update = match &*cache_guard {
Some((cached_year, cached_month, _)) => !(*cached_year == year && *cached_month == 0),
None => true,
};
if should_update {
*cache_guard = Some((year, month, data.clone()));
}
let day_idx = (day - 1) as usize;
if day_idx < data.len() {
return data
.chars()
.nth(day_idx)
.and_then(|c| c.to_digit(10).map(|d| d as i32))
.unwrap_or(0);
}
}
0
}
#[cfg(not(feature = "plugins"))]
fn get_holiday_code(_ctx: &CalContext, _year: i32, _month: u32, _day: u32) -> i32 {
0
}
#[cfg(feature = "plugins")]
pub fn preload_holidays(ctx: &CalContext, year: i32, month: u32) {
if !ctx.holidays {
return;
}
{
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
if let Some((cached_year, cached_month, _)) = &*cache_guard
&& *cached_year == year
&& (*cached_month == month || *cached_month == 0)
{
return;
}
}
let _ = get_holiday_code(ctx, year, month, 1);
}
#[cfg(not(feature = "plugins"))]
pub fn preload_holidays(_ctx: &CalContext, _year: i32, _month: u32) {}
/// Get system locale from environment (LC_ALL > LC_TIME > LANG > en_US).
pub fn get_system_locale() -> Locale {
std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_TIME"))
.or_else(|_| std::env::var("LANG"))
.unwrap_or_else(|_| "en_US.UTF-8".to_string())
.split('.')
.next()
.unwrap_or("en_US")
.split('@')
.next()
.unwrap_or("en_US")
.parse()
.unwrap_or(Locale::en_US)
}
/// Get month name in nominative case for current locale.
pub fn get_month_name(month: u32) -> String {
let locale = get_system_locale();
match locale {
Locale::ru_RU => [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
][(month - 1) as usize]
.to_string(),
Locale::uk_UA => [
"Січень",
"Лютий",
"Березень",
"Квітень",
"Травень",
"Червень",
"Липень",
"Серпень",
"Вересень",
"Жовтень",
"Листопад",
"Грудень",
][(month - 1) as usize]
.to_string(),
Locale::be_BY => [
"Студзень",
"Люты",
"Сакавік",
"Красавік",
"Май",
"Чэрвень",
"Ліпень",
"Жнівень",
"Верасень",
"Кастрычнік",
"Лістапад",
"Снежань",
][(month - 1) as usize]
.to_string(),
_ => {
let date = NaiveDate::from_ymd_opt(2000, month, 1).unwrap();
date.format_localized("%B", locale).to_string()
}
}
}
/// Parse month from string (numeric 1-12 or name in English/Russian).
pub fn parse_month(s: &str) -> Option {
if let Ok(n) = s.parse::()
&& (1..=12).contains(&n)
{
return Some(n);
}
let s_lower = s.to_lowercase();
let month_names: [(&str, u32); 35] = [
// English full names
("january", 1),
("february", 2),
("march", 3),
("april", 4),
("may", 5),
("june", 6),
("july", 7),
("august", 8),
("september", 9),
("october", 10),
("november", 11),
("december", 12),
// Russian full names
("январь", 1),
("февраль", 2),
("март", 3),
("апрель", 4),
("май", 5),
("июнь", 6),
("июль", 7),
("август", 8),
("сентябрь", 9),
("октябрь", 10),
("ноябрь", 11),
("декабрь", 12),
// English short forms
("jan", 1),
("feb", 2),
("mar", 3),
("apr", 4),
("jun", 6),
("jul", 7),
("aug", 8),
("sep", 9),
("oct", 10),
("nov", 11),
("dec", 12),
];
month_names
.iter()
.find(|(name, _)| *name == s_lower)
.map(|(_, num)| *num)
}
/// Format month header with optional year and color.
pub fn format_month_header(
year: i32,
month: u32,
width: usize,
show_year: bool,
color: bool,
) -> String {
let month_name = get_month_name(month);
let header = if show_year {
format!("{} {}", month_name, year)
} else {
month_name
};
let centered = center_text(&header, width);
if color {
format!("{}{}{}", COLOR_TEAL, centered, COLOR_RESET)
} else {
centered
}
}
/// Center text within a specified width, accounting for Unicode character widths.
fn center_text(text: &str, width: usize) -> String {
let text_width = text.width();
if text_width >= width {
return text.to_string();
}
let total_padding = width - text_width;
let left_padding = total_padding.div_ceil(2);
let right_padding = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
text,
" ".repeat(right_padding)
)
}
/// Get weekday order based on week start day.
pub fn get_weekday_order(week_start: Weekday) -> [Weekday; 7] {
match week_start {
Weekday::Mon => [
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
Weekday::Sat,
Weekday::Sun,
],
Weekday::Sun => [
Weekday::Sun,
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
Weekday::Sat,
],
_ => unreachable!(),
}
}
/// Get 2-character weekday abbreviation for current locale.
pub fn get_weekday_short_name(weekday: Weekday, locale: Locale) -> String {
let base_date = NaiveDate::from_ymd_opt(2000, 1, 3).unwrap();
let offset = weekday.num_days_from_monday() as i64;
let date = base_date + chrono::Duration::days(offset);
let day_name = date.format_localized("%a", locale).to_string();
day_name.chars().take(2).collect()
}
/// Format weekday header row with optional week numbers and color.
pub fn format_weekday_headers(ctx: &CalContext, week_numbers: bool) -> String {
let locale = get_system_locale();
let mut result = String::new();
if week_numbers {
result.push_str(" ");
}
if ctx.julian {
result.push(' ');
}
let weekday_order = get_weekday_order(ctx.week_start);
if ctx.color {
result.push_str(COLOR_SAND_YELLOW);
}
for (i, &weekday) in weekday_order.iter().enumerate() {
let short_name = get_weekday_short_name(weekday, locale);
if ctx.julian {
if i < 6 {
result.push_str(&format!("{} ", short_name));
} else {
result.push_str(&format!(" {}", short_name));
}
} else if i < 6 {
result.push_str(&format!("{} ", short_name));
} else {
result.push_str(&short_name);
}
}
if ctx.color {
result.push_str(COLOR_RESET);
}
result
}
/// Format day cell with color highlighting.
///
/// Color priority: today > shortened day > weekend/holiday > regular
fn format_day(
ctx: &CalContext,
day: u32,
month: u32,
year: i32,
weekday: Weekday,
is_last: bool,
) -> String {
let is_today = ctx.color
&& ctx.today.day() == day
&& ctx.today.month() == month
&& ctx.today.year() == year;
let is_weekend = ctx.color && ctx.is_weekend(weekday);
let holiday_code = if ctx.color {
get_holiday_code(ctx, year, month, day)
} else {
0
};
let day_str = format!("{:>2}", day);
let formatted = if is_today {
format!("{}{}{}", COLOR_REVERSE, day_str, COLOR_RESET)
} else if holiday_code == 2 {
format!("{}{}{}", COLOR_TEAL, day_str, COLOR_RESET)
} else if is_weekend || holiday_code == 1 || holiday_code == 8 {
format!("{}{}{}", COLOR_RED, day_str, COLOR_RESET)
} else {
day_str
};
if is_last {
formatted
} else {
format!("{} ", formatted)
}
}
/// Format month as grid of lines (horizontal layout).
pub fn format_month_grid(ctx: &CalContext, month: &MonthData) -> Vec {
let mut lines = Vec::with_capacity(8);
let header_width = if ctx.julian {
27
} else if ctx.week_numbers {
23
} else {
20
};
let month_header = format_month_header(
month.year,
month.month,
header_width,
ctx.show_year_in_header,
ctx.color,
);
lines.push(month_header);
let weekday_header = format_weekday_headers(ctx, ctx.week_numbers);
lines.push(weekday_header);
let mut day_idx = 0;
let total_days = month.days.len();
// Generate 6 weeks of calendar
for _week in 0..6 {
let mut line = String::new();
if ctx.week_numbers {
let week_wn = (0..7)
.filter_map(|d| {
let idx = day_idx + d;
if idx < total_days {
month.week_numbers.get(idx).copied().flatten()
} else {
None
}
})
.next();
if let Some(wn) = week_wn {
line.push_str(&format!("{:>2} ", wn));
} else {
line.push_str(" ");
}
}
for day_in_week in 0..7 {
if day_idx >= total_days {
break;
}
let is_last = (day_in_week + 1) % 7 == 0;
if let Some(day) = month.days[day_idx] {
if ctx.julian {
let doy = ctx.day_of_year(month.year, month.month, day);
let doy_str = format!("{:>3}", doy);
if is_last {
line.push_str(&doy_str);
} else {
line.push_str(&format!("{} ", doy_str));
}
} else {
let weekday = month.weekdays[day_idx].unwrap();
line.push_str(&format_day(
ctx,
day,
month.month,
month.year,
weekday,
is_last,
));
}
} else if ctx.julian {
if is_last {
line.push_str(" ");
} else {
line.push_str(" ");
}
} else if is_last {
line.push_str(" ");
} else {
line.push_str(" ");
}
day_idx += 1;
}
lines.push(line);
if day_idx >= total_days {
break;
}
}
lines
}
/// Print single month in horizontal (default) or vertical layout.
pub fn print_month(ctx: &CalContext, year: i32, month: u32) {
preload_holidays(ctx, year, month);
let month_data = MonthData::new(ctx, year, month);
if ctx.vertical {
print_month_vertical(ctx, &month_data, true);
} else {
let lines = format_month_grid(ctx, &month_data);
for line in lines {
println!("{}", line);
}
}
}
/// Print single month in vertical layout (days in columns).
pub fn print_month_vertical(ctx: &CalContext, month: &MonthData, is_first: bool) {
let month_name = get_month_name(month.month);
let header = if ctx.show_year_in_header {
format!("{} {}", month_name, month.year)
} else {
month_name.to_string()
};
let month_width = 18;
let padded_header = if is_first {
format!(
" {: = weekday_order
.iter()
.map(|&w| get_weekday_short_name(w, locale))
.collect();
for (row, weekday) in weekday_order.iter().enumerate() {
let day_short = &weekday_names[row];
if ctx.color {
print!("{}{}{}", COLOR_SAND_YELLOW, day_short, COLOR_RESET);
} else {
print!("{}", day_short);
}
for week in 0..6 {
let day_idx = (*weekday as usize) + 7 * week;
if day_idx < month.days.len() {
if let Some(day) = month.days[day_idx] {
print_day_vertical(ctx, day, month, *weekday);
} else {
print!(" ");
}
}
}
println!();
}
}
/// Print day cell in vertical layout with color highlighting.
fn print_day_vertical(ctx: &CalContext, day: u32, month: &MonthData, weekday: Weekday) {
let is_today = ctx.color
&& ctx.today.day() == day
&& ctx.today.month() == month.month
&& ctx.today.year() == month.year;
let is_weekend = ctx.color && ctx.is_weekend(weekday);
let holiday_code = if ctx.color {
get_holiday_code(ctx, month.year, month.month, day)
} else {
0
};
let day_str = day.to_string();
let padding = 3 - day_str.len();
let formatted = if is_today {
format!(
"{}{}{}{}",
" ".repeat(padding),
COLOR_REVERSE,
day,
COLOR_RESET
)
} else if holiday_code == 2 {
format!(
"{}{}{}{}",
" ".repeat(padding),
COLOR_TEAL,
day,
COLOR_RESET
)
} else if is_weekend || holiday_code == 1 || holiday_code == 8 {
format!("{}{}{}{}", " ".repeat(padding), COLOR_RED, day, COLOR_RESET)
} else {
format!("{:>3}", day)
};
print!("{}", formatted);
}
/// Print three months side by side (prev, current, next).
pub fn print_three_months(ctx: &CalContext, year: i32, month: u32) {
let prev_month = if month == 1 { 12 } else { month - 1 };
let prev_year = if month == 1 { year - 1 } else { year };
let next_month = if month == 12 { 1 } else { month + 1 };
let next_year = if month == 12 { year + 1 } else { year };
preload_holidays(ctx, prev_year, prev_month);
preload_holidays(ctx, year, month);
preload_holidays(ctx, next_year, next_month);
let months = vec![
MonthData::new(ctx, prev_year, prev_month),
MonthData::new(ctx, year, month),
MonthData::new(ctx, next_year, next_month),
];
if ctx.vertical {
print_three_months_vertical(ctx, &months);
} else {
print_months_side_by_side(ctx, &months);
}
}
/// Print multiple months side by side in horizontal layout.
pub fn print_months_side_by_side(ctx: &CalContext, months: &[MonthData]) {
let grids: Vec> = months.iter().map(|m| format_month_grid(ctx, m)).collect();
let max_height = grids.iter().map(|g| g.len()).max().unwrap_or(0);
let month_width: usize = if ctx.julian {
27
} else if ctx.week_numbers {
23
} else {
20
};
for row in 0..max_height {
let mut line = String::new();
for (i, grid) in grids.iter().enumerate() {
if row < grid.len() {
let text = &grid[row];
let text_width = text.width();
line.push_str(text);
let padding = month_width.saturating_sub(text_width);
for _ in 0..padding {
line.push(' ');
}
if i < grids.len() - 1 {
for _ in 0..ctx.gutter_width {
line.push(' ');
}
}
} else {
let width = if i < grids.len() - 1 {
month_width + ctx.gutter_width
} else {
month_width
};
for _ in 0..width {
line.push(' ');
}
}
}
println!("{}", line);
}
}
/// Print all 12 months of a year.
pub fn print_year(ctx: &CalContext, year: i32) {
if ctx.vertical {
println!("{}", center_text(&year.to_string(), 62));
} else {
println!("{}", center_text(&year.to_string(), 66));
}
println!();
if ctx.holidays {
preload_year_holidays(ctx, year);
}
let mut month_ctx = ctx.clone();
month_ctx.show_year_in_header = false;
month_ctx.gutter_width = if ctx.vertical { 1 } else { GUTTER_WIDTH_YEAR };
// Group months into rows of 3
let mut month_rows = Vec::new();
for month_row in 0..4 {
let mut months = Vec::new();
for col in 0..3 {
let month = (month_row * 3 + col + 1) as u32;
if month <= 12 {
months.push(MonthData::new(&month_ctx, year, month));
}
}
if !months.is_empty() {
month_rows.push(months);
}
}
if ctx.vertical {
for months in month_rows.iter() {
print_three_months_vertical(&month_ctx, months);
}
} else {
for months in month_rows.iter() {
print_months_side_by_side(&month_ctx, months);
}
}
}
/// Print three months in vertical layout.
pub fn print_three_months_vertical(ctx: &CalContext, months: &[MonthData]) {
let month_width = 18;
// Print headers
for (i, month) in months.iter().enumerate() {
let month_name = get_month_name(month.month);
let header = if ctx.show_year_in_header {
format!("{} {}", month_name, month.year)
} else {
month_name.to_string()
};
let padded_header = if i == 0 {
format!(
" {: = weekday_order
.iter()
.map(|&w| get_weekday_short_name(w, locale))
.collect();
for (row, &weekday) in weekday_order.iter().enumerate() {
let day_short = &weekday_names[row];
if ctx.color {
print!("{}{}{}", COLOR_SAND_YELLOW, day_short, COLOR_RESET);
} else {
print!("{}", day_short);
}
for (month_idx, month) in months.iter().enumerate() {
if month_idx > 0 {
for _ in 0..ctx.gutter_width {
print!(" ");
}
}
for week in 0..6 {
let day_idx = (weekday as usize) + 7 * week;
if day_idx < month.days.len() {
if let Some(day) = month.days[day_idx] {
print_day_vertical(ctx, day, month, weekday);
} else {
print!(" ");
}
}
}
}
println!();
}
println!();
}
/// Print 12 months starting from a given month (--twelve mode).
pub fn print_twelve_months(ctx: &CalContext, start_year: i32, start_month: u32) {
// Preload holiday data for all 12 months
if ctx.holidays {
for i in 0..12 {
let mut month = start_month + i;
let mut year = start_year;
while month > 12 {
month -= 12;
year += 1;
}
preload_holidays(ctx, year, month);
}
}
let mut month_ctx = ctx.clone();
month_ctx.show_year_in_header = true;
month_ctx.gutter_width = GUTTER_WIDTH_YEAR;
let months = (0..12)
.map(|i| {
let mut month = start_month + i;
let mut year = start_year;
while month > 12 {
month -= 12;
year += 1;
}
MonthData::new(&month_ctx, year, month)
})
.collect::>();
if ctx.vertical {
for month_data in &months {
print_month_vertical(&month_ctx, month_data, true);
println!();
}
} else {
for chunk in months.chunks(3) {
print_months_side_by_side(&month_ctx, chunk);
}
}
}
/// Print a specified number of months (-n mode).
pub fn print_months_count(
ctx: &CalContext,
start_year: i32,
start_month: u32,
count: u32,
) -> Result<(), String> {
let months_per_row = ctx.months_per_row();
// Calculate start month for span mode (center around current month)
let (actual_start_year, actual_start_month) = if ctx.span && count > 1 {
let total_months = start_year * 12 + (start_month - 1) as i32;
let half = (count as i32 - 1) / 2;
let start = total_months - half;
let year = start.div_euclid(12);
let month = (start.rem_euclid(12) + 1) as u32;
(year, month)
} else {
(start_year, start_month)
};
// Preload holiday data for all months
if ctx.holidays {
for i in 0..count {
let mut month = actual_start_month + i;
let mut year = actual_start_year;
while month > 12 {
month -= 12;
year += 1;
}
while month < 1 {
month += 12;
year -= 1;
}
preload_holidays(ctx, year, month);
}
}
let months = (0..count)
.map(|i| {
let mut month = actual_start_month + i;
let mut year = actual_start_year;
while month > 12 {
month -= 12;
year += 1;
}
while month < 1 {
month += 12;
year -= 1;
}
MonthData::new(ctx, year, month)
})
.collect::>();
if ctx.vertical {
for month_data in &months {
print_month_vertical(ctx, month_data, true);
println!();
}
} else {
for chunk in months.chunks(months_per_row as usize) {
print_months_side_by_side(ctx, chunk);
}
}
Ok(())
}