Skip to main content

dejavu/commands/
render.rs

1//! Width-aware render helpers for human-facing command output.
2//!
3//! Layout adapts to the terminal: tables fit naturally when they can, the last
4//! column wraps with a hanging indent when they cannot, and path-heavy lists
5//! use one record per entry instead of an unbounded-width column. When stdout
6//! is not a terminal (pipes, tests), a stable width of 100 is used.
7//!
8//! Styling: ANSI colors are applied AFTER layout (never inside width math),
9//! and only when stdout is a terminal, `NO_COLOR` is unset, and `TERM` is not
10//! `dumb` — piped output stays plain.
11
12use std::io::IsTerminal;
13use std::sync::OnceLock;
14
15const FALLBACK_WIDTH: usize = 100;
16const MIN_WIDTH: usize = 50;
17const MAX_WIDTH: usize = 200;
18const GAP: usize = 2;
19
20/// Effective output width, detected once.
21pub fn width() -> usize {
22    static W: OnceLock<usize> = OnceLock::new();
23    *W.get_or_init(|| {
24        if std::io::stdout().is_terminal() {
25            terminal_size::terminal_size()
26                .map(|(w, _)| (w.0 as usize).clamp(MIN_WIDTH, MAX_WIDTH))
27                .unwrap_or(FALLBACK_WIDTH)
28        } else {
29            FALLBACK_WIDTH
30        }
31    })
32}
33
34/// Text styles, resolved to ANSI codes only when color is enabled.
35#[derive(Clone, Copy, PartialEq)]
36pub enum Style {
37    Plain,
38    Bold,
39    Dim,
40    Cyan,
41    Green,
42    Yellow,
43    Red,
44    BoldGreen,
45    BoldCyan,
46    /// Semantic: colors by the cell's own text (`ok`/`warn`/`fail`, `active`/`disabled`).
47    Status,
48}
49
50fn color_enabled() -> bool {
51    static C: OnceLock<bool> = OnceLock::new();
52    *C.get_or_init(|| {
53        std::io::stdout().is_terminal()
54            && std::env::var_os("NO_COLOR").is_none()
55            && std::env::var_os("TERM").is_none_or(|t| t != "dumb")
56    })
57}
58
59/// Paint `s` with `style` (no-op when color is disabled).
60pub fn paint(style: Style, s: &str) -> String {
61    if !color_enabled() || style == Style::Plain {
62        return s.to_string();
63    }
64    let code = match style {
65        Style::Plain => unreachable!(),
66        Style::Bold => "1",
67        Style::Dim => "2",
68        Style::Cyan => "36",
69        Style::Green => "32",
70        Style::Yellow => "33",
71        Style::Red => "31",
72        Style::BoldGreen => "1;32",
73        Style::BoldCyan => "1;36",
74        Style::Status => {
75            return match s.trim() {
76                "ok" | "active" => paint(Style::Green, s),
77                "warn" | "disabled" => paint(Style::Yellow, s),
78                "fail" | "error" => paint(Style::Red, s),
79                _ => s.to_string(),
80            }
81        }
82    };
83    format!("\x1b[{code}m{s}\x1b[0m")
84}
85
86pub fn title(text: &str) {
87    println!("{}", paint(Style::BoldGreen, text));
88    println!("{}", paint(Style::Green, &"=".repeat(text.chars().count())));
89}
90
91pub fn section(text: &str) {
92    println!();
93    println!("{}", paint(Style::BoldCyan, text));
94    println!("{}", paint(Style::Dim, &"-".repeat(text.chars().count())));
95}
96
97/// A labeled progress meter, colored by value: `label  ████░░░░ 42.0%`.
98/// Higher is better (reduction, savings).
99pub fn meter(label: &str, pct: f64) -> String {
100    const CELLS: usize = 20;
101    let clamped = pct.clamp(0.0, 100.0);
102    let filled = ((clamped / 100.0) * CELLS as f64).round() as usize;
103    let style = if clamped >= 60.0 {
104        Style::Green
105    } else if clamped >= 30.0 {
106        Style::Yellow
107    } else {
108        Style::Red
109    };
110    let bar = format!(
111        "{}{}",
112        paint(style, &"█".repeat(filled)),
113        paint(Style::Dim, &"░".repeat(CELLS - filled))
114    );
115    format!("{label}  {bar} {}", paint(style, &format!("{clamped:.1}%")))
116}
117
118/// Middle-ellipsis for one-line fields. Path-friendly: keeps more of the tail
119/// (the end of a path is usually the discriminating part).
120pub fn truncate_middle(s: &str, max: usize) -> String {
121    let n = s.chars().count();
122    if n <= max || max < 8 {
123        return s.to_string();
124    }
125    let keep = max - 1;
126    let head = keep / 3;
127    let tail = keep - head;
128    let head_s: String = s.chars().take(head).collect();
129    let tail_s: String = s.chars().skip(n - tail).collect();
130    format!("{head_s}…{tail_s}")
131}
132
133/// Greedy word-wrap to `max` columns; words longer than a line (paths, hashes)
134/// are hard-broken rather than overflowing.
135pub fn wrap(text: &str, max: usize) -> Vec<String> {
136    let max = max.max(8);
137    let mut lines = Vec::new();
138    let mut current = String::new();
139    let mut current_len = 0usize;
140    for word in text.split_whitespace() {
141        let mut word_chars = word.chars().count();
142        let mut word = word.to_string();
143        // Hard-break oversized words.
144        while word_chars > max {
145            if current_len > 0 {
146                lines.push(std::mem::take(&mut current));
147                current_len = 0;
148            }
149            let piece: String = word.chars().take(max).collect();
150            word = word.chars().skip(max).collect();
151            word_chars -= max;
152            lines.push(piece);
153        }
154        let needed = if current_len == 0 {
155            word_chars
156        } else {
157            current_len + 1 + word_chars
158        };
159        if needed > max && current_len > 0 {
160            lines.push(std::mem::take(&mut current));
161            current.push_str(&word);
162            current_len = word_chars;
163        } else {
164            if current_len > 0 {
165                current.push(' ');
166            }
167            current.push_str(&word);
168            current_len = needed;
169        }
170    }
171    if !current.is_empty() {
172        lines.push(current);
173    }
174    if lines.is_empty() {
175        lines.push(String::new());
176    }
177    lines
178}
179
180/// Aligned key–value block; long values wrap with a hanging indent.
181pub fn kv(rows: &[(&str, String)]) {
182    let key_w = rows
183        .iter()
184        .map(|(k, _)| k.chars().count())
185        .max()
186        .unwrap_or(0);
187    let value_w = width().saturating_sub(key_w + GAP).max(16);
188    for (key, value) in rows {
189        let lines = wrap(value, value_w);
190        let padded = format!("{key:<key_w$}");
191        println!(
192            "{}{}{}",
193            paint(Style::Dim, &padded),
194            " ".repeat(GAP),
195            lines[0]
196        );
197        for line in &lines[1..] {
198            println!("{}{line}", " ".repeat(key_w + GAP));
199        }
200    }
201}
202
203/// One block per entry, for lists whose main field is a path or another
204/// unbounded string: a colored status bullet + bold main line (middle-truncated
205/// when needed), details `·`-joined underneath, dimmed, wrapping with an indent.
206pub fn record(bullet: Style, main: &str, details: &[String]) {
207    let w = width();
208    println!(
209        "{} {}",
210        paint(bullet, "●"),
211        paint(Style::Bold, &truncate_middle(main, w.saturating_sub(2)))
212    );
213    let detail = details.join(" · ");
214    if detail.is_empty() {
215        return;
216    }
217    for line in wrap(&detail, w.saturating_sub(GAP)) {
218        println!("{}{}", " ".repeat(GAP), paint(Style::Dim, &line));
219    }
220}
221
222/// Column-aligned table. Fits naturally when it can; otherwise the LAST column
223/// becomes flexible and wraps with a hanging indent. Never overflows `width()`.
224pub fn table(headers: &[&str], rows: &[Vec<String>]) {
225    table_styled(headers, rows, &[]);
226}
227
228/// `table` with a per-column style, applied to every fragment after layout
229/// (missing columns fall back to `Plain`).
230pub fn table_styled(headers: &[&str], rows: &[Vec<String>], styles: &[Style]) {
231    if headers.is_empty() {
232        return;
233    }
234    let w = width();
235    let cols = headers.len();
236    let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect();
237    for row in rows {
238        for (idx, cell) in row.iter().enumerate().take(cols) {
239            widths[idx] = widths[idx].max(cell.chars().count());
240        }
241    }
242
243    let fixed: usize = widths[..cols - 1].iter().sum::<usize>() + GAP * (cols - 1);
244    let natural = fixed + widths[cols - 1];
245    if natural > w {
246        // Flex the last column into whatever room remains.
247        widths[cols - 1] = w
248            .saturating_sub(fixed)
249            .max(16)
250            .max(headers[cols - 1].chars().count());
251    }
252
253    let dim_all = vec![Style::Dim; cols];
254    let headers_owned: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
255    print_wrapped_row(&headers_owned, &widths, &dim_all);
256    let separators: Vec<String> = widths.iter().map(|cw| "-".repeat(*cw)).collect();
257    print_wrapped_row(&separators, &widths, &dim_all);
258    for row in rows {
259        print_wrapped_row(row, &widths, styles);
260    }
261}
262
263/// Print one row; the last column wraps onto continuation lines aligned under
264/// its own start. Layout is computed on plain text; styles are applied per
265/// printed fragment (padding stays outside the escape codes).
266fn print_wrapped_row(cells: &[String], widths: &[usize], styles: &[Style]) {
267    let style_of = |idx: usize| styles.get(idx).copied().unwrap_or(Style::Plain);
268    let cols = widths.len();
269    let mut line = String::new();
270    let mut indent = 0usize;
271    for (idx, cw) in widths.iter().enumerate().take(cols - 1) {
272        let cell = cells.get(idx).map(String::as_str).unwrap_or("");
273        line.push_str(&paint(style_of(idx), &format!("{cell:<cw$}")));
274        line.push_str(&" ".repeat(GAP));
275        indent += cw + GAP;
276    }
277    let last = cells.get(cols - 1).map(String::as_str).unwrap_or("");
278    let wrapped = wrap(last, widths[cols - 1]);
279    println!("{line}{}", paint(style_of(cols - 1), &wrapped[0]));
280    for cont in &wrapped[1..] {
281        println!("{}{}", " ".repeat(indent), paint(style_of(cols - 1), cont));
282    }
283}
284
285/// `2026-07-06T18:25:25.713220Z` → `2026-07-06 18:25` (display only).
286pub fn human_time(rfc3339: &str) -> String {
287    if rfc3339.len() >= 16 && rfc3339.as_bytes()[10] == b'T' {
288        let mut s: String = rfc3339.chars().take(16).collect();
289        s.replace_range(10..11, " ");
290        s
291    } else {
292        rfc3339.to_string()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn truncate_middle_keeps_head_and_tail() {
302        let s = "/Users/alexis/workspace/perso/projects/software/dejavu/some/deep/dir";
303        let t = truncate_middle(s, 30);
304        assert_eq!(t.chars().count(), 30);
305        assert!(t.ends_with("deep/dir"));
306        assert!(t.contains('…'));
307        assert_eq!(truncate_middle("short", 30), "short");
308    }
309
310    #[test]
311    fn wrap_breaks_on_words_and_hard_breaks_long_tokens() {
312        let lines = wrap("alpha beta gamma delta", 11);
313        assert_eq!(lines, vec!["alpha beta", "gamma delta"]);
314        let lines = wrap(&"x".repeat(25), 10);
315        assert_eq!(lines, vec!["x".repeat(10), "x".repeat(10), "x".repeat(5)]);
316    }
317
318    #[test]
319    fn human_time_shortens_rfc3339() {
320        assert_eq!(
321            human_time("2026-07-06T18:25:25.713220Z"),
322            "2026-07-06 18:25"
323        );
324        assert_eq!(human_time("never"), "never");
325    }
326}