pcal/
display.rs

1//! Colored terminal output for the calendar.
2//!
3//! Renders month grids with ANSI colors: today is highlighted with reverse
4//! video, titles are bold. Respects `--no-color` and the `NO_COLOR` env
5//! variable. In Persian mode, lines are wrapped with Unicode LTR embedding
6//! characters to prevent BiDi reordering.
7
8use std::fmt::Write as _;
9
10use colored::Colorize;
11
12use crate::calendar::MonthGrid;
13use crate::persian::{
14    self, MONTH_NAMES, MONTH_NAMES_ROMANIZED, WEEKDAY_NAMES_EN, WEEKDAY_NAMES_FA,
15};
16
17/// LTR embedding character — forces entire line to render left-to-right
18const LRE: &str = "\u{202a}";
19/// Pop directional formatting — ends the LRE scope
20const PDF: &str = "\u{202c}";
21
22/// Width of a single month block: 7 columns × 3 chars - 1 = 20.
23const MONTH_WIDTH: usize = 20;
24/// Width of a single month block in Julian mode: 7 columns × 4 chars - 1 = 27.
25const MONTH_WIDTH_JULIAN: usize = 27;
26/// Number of months displayed side-by-side in multi-month views.
27const MONTHS_PER_ROW: usize = 3;
28/// Gap between side-by-side month blocks.
29const GAP: &str = "  ";
30
31/// Render a single month with color highlighting for today.
32pub fn print_month(grid: &MonthGrid, today: Option<(i32, u32, u32)>, julian: bool, persian: bool) {
33    let lines = render_month(grid, today, julian, persian);
34    for line in lines {
35        println!("{}", line.trim_end());
36    }
37}
38
39/// Render multiple months side-by-side and print them.
40pub fn print_months(
41    grids: &[MonthGrid],
42    today: Option<(i32, u32, u32)>,
43    julian: bool,
44    persian: bool,
45) {
46    let width = if julian {
47        MONTH_WIDTH_JULIAN
48    } else {
49        MONTH_WIDTH
50    };
51
52    for chunk in grids.chunks(MONTHS_PER_ROW) {
53        let rendered: Vec<Vec<String>> = chunk
54            .iter()
55            .map(|g| render_month(g, today, julian, persian))
56            .collect();
57
58        let max_lines = rendered.iter().map(|r| r.len()).max().unwrap_or(0);
59
60        for line_idx in 0..max_lines {
61            let mut combined = String::new();
62            for (i, month_lines) in rendered.iter().enumerate() {
63                if i > 0 {
64                    combined.push_str(GAP);
65                }
66                if line_idx < month_lines.len() {
67                    combined.push_str(&month_lines[line_idx]);
68                } else {
69                    combined.push_str(&" ".repeat(width));
70                }
71            }
72            println!("{}", combined.trim_end());
73        }
74        println!();
75    }
76}
77
78/// Render a full year and print it.
79pub fn print_year(year: i32, today: Option<(i32, u32, u32)>, julian: bool, persian: bool) {
80    let width = if julian {
81        MONTH_WIDTH_JULIAN
82    } else {
83        MONTH_WIDTH
84    };
85    let total_width = width * MONTHS_PER_ROW + GAP.len() * (MONTHS_PER_ROW - 1);
86
87    let year_str = if persian {
88        persian::to_persian_digits(&year.to_string())
89    } else {
90        year.to_string()
91    };
92    let padding = (total_width.saturating_sub(year_str.len())) / 2;
93    if persian {
94        println!("{}{}{}{}", LRE, " ".repeat(padding), year_str.bold(), PDF);
95    } else {
96        println!("{}{}", " ".repeat(padding), year_str.bold());
97    }
98    println!();
99
100    let grids: Vec<MonthGrid> = (1..=12).map(|m| MonthGrid::new(year, m)).collect();
101    print_months(&grids, today, julian, persian);
102}
103
104/// Render a single month to a vec of strings with ANSI color codes.
105fn render_month(
106    grid: &MonthGrid,
107    today: Option<(i32, u32, u32)>,
108    julian: bool,
109    persian: bool,
110) -> Vec<String> {
111    let cell_width = if julian { 4 } else { 3 };
112    let width = if julian {
113        MONTH_WIDTH_JULIAN
114    } else {
115        MONTH_WIDTH
116    };
117
118    let weekday_names = if persian {
119        &WEEKDAY_NAMES_FA
120    } else {
121        &WEEKDAY_NAMES_EN
122    };
123
124    let is_today = |day: u32| -> bool {
125        if let Some((ty, tm, td)) = today {
126            ty == grid.year && tm == grid.month && td == day
127        } else {
128            false
129        }
130    };
131
132    let mut lines = Vec::new();
133
134    // Title: month name + year
135    let month_name = if persian {
136        MONTH_NAMES[grid.month as usize - 1].to_string()
137    } else {
138        capitalize(MONTH_NAMES_ROMANIZED[grid.month as usize - 1])
139    };
140    let year_str = if persian {
141        persian::to_persian_digits(&grid.year.to_string())
142    } else {
143        grid.year.to_string()
144    };
145    let title = format!("{month_name} {year_str}");
146    let dw = display_width(&title);
147    let padding = width.saturating_sub(dw) / 2;
148    lines.push(format!("{}{}", " ".repeat(padding), title.bold()));
149
150    // Weekday header
151    let header: String = weekday_names
152        .iter()
153        .enumerate()
154        .map(|(i, name)| {
155            let w: usize = if i == 0 { cell_width - 1 } else { cell_width };
156            let vis_len = display_width(name);
157            let pad = w.saturating_sub(vis_len);
158            format!("{}{}", " ".repeat(pad), name)
159        })
160        .collect();
161    lines.push(header);
162
163    // Day rows
164    let rows = grid.row_count();
165    for r in 0..rows {
166        let mut row_str = String::new();
167        for c in 0..7 {
168            let w = if c == 0 { cell_width - 1 } else { cell_width };
169            match grid.grid[r][c] {
170                Some(day) => {
171                    let num = if julian { grid.day_of_year(day) } else { day };
172                    let s = if persian {
173                        persian::to_persian_digits(&num.to_string())
174                    } else {
175                        num.to_string()
176                    };
177                    if is_today(day) {
178                        let formatted = format!("{s:>w$}");
179                        write!(row_str, "{}", formatted.reversed()).unwrap();
180                    } else {
181                        write!(row_str, "{s:>w$}").unwrap();
182                    }
183                }
184                None => {
185                    row_str.push_str(&" ".repeat(w));
186                }
187            }
188        }
189        lines.push(row_str);
190    }
191
192    // Pad to 8 lines (1 title + 1 header + 6 data rows)
193    while lines.len() < 8 {
194        lines.push(String::new());
195    }
196
197    // Pad lines to width
198    for line in &mut lines {
199        let current = visible_width(line);
200        if current < width {
201            line.push_str(&" ".repeat(width - current));
202        }
203    }
204
205    // Wrap lines in LTR embedding to prevent BiDi reordering
206    if persian {
207        for line in &mut lines {
208            *line = format!("{LRE}{line}{PDF}");
209        }
210    }
211
212    lines
213}
214
215/// Capitalize the first character of a string.
216fn capitalize(s: &str) -> String {
217    let mut chars = s.chars();
218    match chars.next() {
219        None => String::new(),
220        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
221    }
222}
223
224/// Get the display width of a string, skipping zero-width Unicode control characters.
225fn display_width(s: &str) -> usize {
226    s.chars()
227        .filter(|c| !matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}' | '\u{202c}'))
228        .count()
229}
230
231/// Get the visible width of a string, ignoring both ANSI escape sequences
232/// and zero-width Unicode directional formatting characters.
233fn visible_width(s: &str) -> usize {
234    let mut width = 0;
235    let mut in_escape = false;
236    for c in s.chars() {
237        if in_escape {
238            if c == 'm' {
239                in_escape = false;
240            }
241        } else if c == '\x1b' {
242            in_escape = true;
243        } else if !matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}' | '\u{202c}') {
244            width += 1;
245        }
246    }
247    width
248}