pcal/
formatter.rs

1//! Plain-text calendar formatting without ANSI colors.
2//!
3//! Used for generating uncolored output and in unit tests. Produces fixed-width
4//! text suitable for side-by-side month layouts.
5
6use std::fmt::Write as _;
7
8use crate::calendar::MonthGrid;
9use crate::persian::{
10    self, MONTH_NAMES, MONTH_NAMES_ROMANIZED, WEEKDAY_NAMES_EN, WEEKDAY_NAMES_FA,
11};
12
13/// Width of a single month block in characters
14const MONTH_WIDTH: usize = 20; // 7 * 3 - 1 = 20
15const MONTH_WIDTH_JULIAN: usize = 27; // 7 * 4 - 1 = 27
16const MONTHS_PER_ROW: usize = 3;
17const GAP: &str = "  ";
18
19/// Render a single month as lines of text.
20/// Each line is exactly `MONTH_WIDTH` characters wide.
21#[must_use]
22pub fn format_month(grid: &MonthGrid, julian: bool, persian: bool) -> Vec<String> {
23    let cell_width = if julian { 4 } else { 3 };
24    let width = if julian {
25        MONTH_WIDTH_JULIAN
26    } else {
27        MONTH_WIDTH
28    };
29
30    let weekday_names = if persian {
31        &WEEKDAY_NAMES_FA
32    } else {
33        &WEEKDAY_NAMES_EN
34    };
35
36    let mut lines = Vec::new();
37
38    // Title: "month_name year" centered
39    let month_name = if persian {
40        MONTH_NAMES[grid.month as usize - 1].to_string()
41    } else {
42        capitalize(MONTH_NAMES_ROMANIZED[grid.month as usize - 1])
43    };
44    let year_str = if persian {
45        persian::to_persian_digits(&grid.year.to_string())
46    } else {
47        grid.year.to_string()
48    };
49    let title = format!("{month_name} {year_str}");
50    lines.push(center_persian(&title, width));
51
52    // Weekday header
53    let header: String = weekday_names
54        .iter()
55        .enumerate()
56        .map(|(i, name)| {
57            if i == 0 {
58                format!("{:>width$}", name, width = cell_width - 1)
59            } else {
60                format!("{name:>cell_width$}")
61            }
62        })
63        .collect::<String>();
64    lines.push(header);
65
66    // Day rows
67    let rows = grid.row_count();
68    for r in 0..rows {
69        let mut row_str = String::new();
70        for c in 0..7 {
71            let cell = match grid.grid[r][c] {
72                Some(day) => {
73                    let num = if julian { grid.day_of_year(day) } else { day };
74                    let s = num.to_string();
75                    if persian {
76                        persian::to_persian_digits(&s)
77                    } else {
78                        s
79                    }
80                }
81                None => String::new(),
82            };
83            if c == 0 {
84                write!(row_str, "{:>width$}", cell, width = cell_width - 1).unwrap();
85            } else {
86                write!(row_str, "{cell:>cell_width$}").unwrap();
87            }
88        }
89        lines.push(row_str);
90    }
91
92    // Pad remaining rows so all months have the same height (6 data rows)
93    while lines.len() < 8 {
94        // 1 title + 1 header + 6 data rows
95        lines.push(String::new());
96    }
97
98    // Pad all lines to exact width
99    for line in &mut lines {
100        let current_len = display_width(line);
101        if current_len < width {
102            line.push_str(&" ".repeat(width - current_len));
103        }
104    }
105
106    lines
107}
108
109/// Render multiple months side-by-side, 3 per row.
110#[must_use]
111pub fn format_months(grids: &[MonthGrid], julian: bool, persian: bool) -> Vec<String> {
112    let mut output = Vec::new();
113
114    for chunk in grids.chunks(MONTHS_PER_ROW) {
115        let formatted: Vec<Vec<String>> = chunk
116            .iter()
117            .map(|g| format_month(g, julian, persian))
118            .collect();
119
120        let max_lines = formatted.iter().map(|f| f.len()).max().unwrap_or(0);
121
122        let width = if julian {
123            MONTH_WIDTH_JULIAN
124        } else {
125            MONTH_WIDTH
126        };
127
128        for line_idx in 0..max_lines {
129            let mut combined = String::new();
130            for (i, month_lines) in formatted.iter().enumerate() {
131                if i > 0 {
132                    combined.push_str(GAP);
133                }
134                if line_idx < month_lines.len() {
135                    combined.push_str(&month_lines[line_idx]);
136                } else {
137                    combined.push_str(&" ".repeat(width));
138                }
139            }
140            output.push(combined.trim_end().to_string());
141        }
142
143        // Blank line between rows of months
144        output.push(String::new());
145    }
146
147    // Remove trailing blank line
148    if output.last().is_some_and(|l| l.is_empty()) {
149        output.pop();
150    }
151
152    output
153}
154
155/// Render all 12 months of a year as lines of text (4 rows × 3 columns).
156#[must_use]
157pub fn format_year(year: i32, julian: bool, persian: bool) -> Vec<String> {
158    let width = if julian {
159        MONTH_WIDTH_JULIAN
160    } else {
161        MONTH_WIDTH
162    };
163    let total_width = width * MONTHS_PER_ROW + GAP.len() * (MONTHS_PER_ROW - 1);
164
165    let mut output = Vec::new();
166
167    // Year title centered
168    let year_str = if persian {
169        persian::to_persian_digits(&year.to_string())
170    } else {
171        year.to_string()
172    };
173    output.push(center_str(&year_str, total_width));
174    output.push(String::new());
175
176    let grids: Vec<MonthGrid> = (1..=12).map(|m| MonthGrid::new(year, m)).collect();
177
178    output.extend(format_months(&grids, julian, persian));
179    output
180}
181
182/// Capitalize the first character of a string.
183fn capitalize(s: &str) -> String {
184    let mut chars = s.chars();
185    match chars.next() {
186        None => String::new(),
187        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
188    }
189}
190
191/// Center an ASCII string within a given width.
192fn center_str(s: &str, width: usize) -> String {
193    let len = s.len();
194    if len >= width {
195        return s.to_string();
196    }
197    let padding = (width - len) / 2;
198    format!("{}{}", " ".repeat(padding), s)
199}
200
201/// Center a string that may contain Persian/Unicode characters,
202/// using visible width for padding calculation.
203fn center_persian(s: &str, width: usize) -> String {
204    let dw = display_width(s);
205    if dw >= width {
206        return s.to_string();
207    }
208    let padding = (width - dw) / 2;
209    format!("{}{}", " ".repeat(padding), s)
210}
211
212/// Get the display width of a string, skipping zero-width Unicode control characters.
213fn display_width(s: &str) -> usize {
214    s.chars()
215        .filter(|c| !matches!(c, '\u{200e}' | '\u{200f}'))
216        .map(|_| 1)
217        .sum()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_format_month_default() {
226        let grid = MonthGrid::new(2584, 1);
227        let lines = format_month(&grid, false, false);
228        assert_eq!(lines.len(), 8);
229        // Default uses English: "Farvardin 2584"
230        assert!(lines[0].contains("Farvardin"));
231        assert!(lines[0].contains("2584"));
232        // Header should have English weekday names
233        assert!(lines[1].contains("Sa"));
234    }
235
236    #[test]
237    fn test_format_month_persian() {
238        let grid = MonthGrid::new(2584, 1);
239        let lines = format_month(&grid, false, true);
240        assert!(lines[0].contains("فروردین"));
241        assert!(lines[0].contains("۲۵۸۴"));
242    }
243
244    #[test]
245    fn test_format_year() {
246        let lines = format_year(2584, false, false);
247        assert!(lines[0].contains("2584"));
248        // Should have all 12 month names in English
249        for name in MONTH_NAMES_ROMANIZED {
250            assert!(lines.iter().any(|l| l.to_lowercase().contains(name)));
251        }
252    }
253}