Skip to main content

ytcli/render/
bar.rs

1//! Ratios drawn as a bar, where there is a terminal to draw on.
2//!
3//! One rule, and it is the whole design: **decoration follows the painter.**
4//! The numbers are the contract — `4 of 6` is what a caller reads, greps and
5//! parses, and it appears in both modes unchanged. The blocks are chrome, and a
6//! pipe gets none of them, so no piped output grows a byte for a person's
7//! benefit and nothing an agent parses changes shape.
8//!
9//! This is the same switch colour is on (`Context::is_human`), for the same
10//! reason: an agent pays per token for anything a terminal draws.
11
12use crate::render::Context;
13use crate::render::style::Palette;
14
15/// How wide a bar is, in cells.
16///
17/// Short on purpose. It sits inside a line that already carries the numbers, so
18/// its job is to be read at a glance rather than to be measured.
19const CELLS: usize = 10;
20
21/// `▓▓▓▓░░░░░░ 4 of 6`, or just `4 of 6`.
22///
23/// `total` of zero has no ratio to show: nothing is drawn, and the numbers are
24/// still printed, because "0 of 0" is an answer and a blank line is not.
25#[must_use]
26pub fn ratio(done: u64, total: u64, ctx: &Context) -> String {
27    let numbers = format!("{done} of {total}");
28    if !ctx.is_human() || total == 0 {
29        return numbers;
30    }
31
32    let filled = cells(done, total);
33    let paint = ctx.painter();
34    let colour = if done >= total {
35        Palette::ok()
36    } else {
37        Palette::warn()
38    };
39
40    format!(
41        "{}{} {numbers}",
42        paint.paint(&"▓".repeat(filled), colour),
43        paint.paint(&"░".repeat(CELLS - filled), Palette::label()),
44    )
45}
46
47/// How many cells of [`CELLS`] are filled.
48///
49/// Rounded down, and never rounded up to full: a bar that reads as finished
50/// while one item is outstanding is worse than no bar. The same holds at the
51/// bottom — any progress at all shows one cell, so "started" and "not started"
52/// never look alike.
53fn cells(done: u64, total: u64) -> usize {
54    if done >= total {
55        return CELLS;
56    }
57    if done == 0 {
58        return 0;
59    }
60    let cells = u64::try_from(CELLS).unwrap_or(u64::MAX);
61    let filled = usize::try_from(done.saturating_mul(cells) / total.max(1)).unwrap_or(CELLS);
62    filled.clamp(1, CELLS - 1)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::render::{Audience, Format};
69
70    fn ctx(audience: Audience) -> Context {
71        Context {
72            format: Format::Text,
73            audience,
74            description_lines: None,
75            extra_fields: Vec::new(),
76            width: 80,
77            images: false,
78            inline: crate::render::image::Inline::default(),
79        }
80    }
81
82    /// The rule this module exists for.
83    #[test]
84    fn a_pipe_gets_the_numbers_and_nothing_else() {
85        assert_eq!(ratio(4, 6, &ctx(Audience::Machine)), "4 of 6");
86        assert_eq!(ratio(0, 0, &ctx(Audience::Machine)), "0 of 0");
87    }
88
89    #[test]
90    fn a_terminal_gets_the_same_numbers_with_a_bar_in_front() {
91        let drawn = ratio(4, 6, &ctx(Audience::Human));
92        assert!(drawn.ends_with("4 of 6"), "{drawn}");
93        assert!(drawn.contains('▓'), "{drawn}");
94        assert!(drawn.contains('░'), "{drawn}");
95    }
96
97    /// Nothing to divide by, so nothing is drawn — but the answer is still given.
98    #[test]
99    fn a_ratio_of_nothing_draws_no_bar() {
100        assert_eq!(ratio(0, 0, &ctx(Audience::Human)), "0 of 0");
101    }
102
103    #[test]
104    fn a_full_bar_means_finished_and_only_that() {
105        assert_eq!(cells(6, 6), CELLS);
106        assert_eq!(cells(7, 6), CELLS);
107        // One item outstanding must not read as done.
108        assert_eq!(cells(99, 100), CELLS - 1);
109    }
110
111    #[test]
112    fn any_progress_at_all_is_visible() {
113        assert_eq!(cells(0, 100), 0);
114        assert_eq!(cells(1, 100), 1);
115    }
116}