Skip to main content

rich_ext/chart/
status.rs

1//! Health levels, and matrices of states such as a CI run's results.
2
3use rich::cells::char_cell_width;
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::{
8    Charset, Line, cells, centre, entries_width, has_colour, lines_to_segments, theme_style,
9    truncate, user_style, wrap_entries,
10};
11
12/// How healthy a value is, for a [`KpiCard`](super::KpiCard).
13///
14/// Each status is a symbol, a word and a colour, so it reads without colour:
15///
16/// | Status | Symbol | ASCII | Word | Theme key |
17/// |---|---|---|---|---|
18/// | [`Ok`](Self::Ok) | `✓` | `+` | `ok` | `chart.ok` |
19/// | [`Warning`](Self::Warning) | `!` | `!` | `warning` | `chart.warning` |
20/// | [`Critical`](Self::Critical) | `✗` | `x` | `critical` | `chart.critical` |
21/// | [`Unknown`](Self::Unknown) | `?` | `?` | `unknown` | `chart.unknown` |
22///
23/// ```
24/// use rich_ext::chart::Status;
25///
26/// assert_eq!(Status::Critical.text(false), "✗ critical");
27/// assert_eq!(Status::Critical.text(true), "x critical");
28/// ```
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum Status {
31    /// Within its limits.
32    Ok,
33    /// Needs a look.
34    Warning,
35    /// Out of its limits.
36    Critical,
37    /// Not known (no data, or not checked).
38    Unknown,
39}
40
41impl Status {
42    /// The symbol, ASCII when `ascii`.
43    pub fn symbol(self, ascii: bool) -> char {
44        match (self, ascii) {
45            (Status::Ok, false) => '✓',
46            (Status::Ok, true) => '+',
47            (Status::Warning, _) => '!',
48            (Status::Critical, false) => '✗',
49            (Status::Critical, true) => 'x',
50            (Status::Unknown, _) => '?',
51        }
52    }
53
54    /// The word: `ok`, `warning`, `critical` or `unknown`.
55    pub fn word(self) -> &'static str {
56        match self {
57            Status::Ok => "ok",
58            Status::Warning => "warning",
59            Status::Critical => "critical",
60            Status::Unknown => "unknown",
61        }
62    }
63
64    /// The theme key: `chart.ok`, `chart.warning`, `chart.critical` or
65    /// `chart.unknown`.
66    pub fn key(self) -> &'static str {
67        match self {
68            Status::Ok => "chart.ok",
69            Status::Warning => "chart.warning",
70            Status::Critical => "chart.critical",
71            Status::Unknown => "chart.unknown",
72        }
73    }
74
75    /// The symbol and the word: `✓ ok`.
76    pub fn text(self, ascii: bool) -> String {
77        format!("{} {}", self.symbol(ascii), self.word())
78    }
79}
80
81/// One state a [`StatusMatrix`] cell can be in: a name, a symbol, an ASCII
82/// symbol and a style.
83///
84/// The built-in states:
85///
86/// | State | Symbol | ASCII | Theme key |
87/// |---|---|---|---|
88/// | [`pass`](Self::pass) | `✓` | `+` | `chart.state.pass` |
89/// | [`fail`](Self::fail) | `✗` | `X` | `chart.state.fail` |
90/// | [`skip`](Self::skip) | `○` | `-` | `chart.state.skip` |
91/// | [`flaky`](Self::flaky) | `≈` | `~` | `chart.state.flaky` |
92///
93/// ```
94/// use rich_ext::chart::State;
95///
96/// let blocked = State::new("blocked", '⊘', '#', "bold magenta");
97/// assert_eq!(blocked.symbol(false), '⊘');
98/// assert_eq!(blocked.symbol(true), '#');
99/// ```
100#[derive(Clone, Debug, PartialEq, Eq, Hash)]
101pub struct State {
102    name: String,
103    symbol: char,
104    ascii: char,
105    style: String,
106}
107
108impl State {
109    /// A state called `name`, drawn as `symbol` (`ascii` on an ASCII
110    /// console) in `style`, a theme key or a definition.
111    ///
112    /// A symbol must take one cell: a wide or zero-width `symbol` is
113    /// replaced by `ascii`, and an `ascii` above U+007F by `?`.
114    pub fn new(
115        name: impl Into<String>,
116        symbol: char,
117        ascii: char,
118        style: impl Into<String>,
119    ) -> Self {
120        let ascii = if ascii.is_ascii_graphic() || ascii == ' ' {
121            ascii
122        } else {
123            '?'
124        };
125        let symbol = if char_cell_width(symbol) == 1 {
126            symbol
127        } else {
128            ascii
129        };
130        State {
131            name: name.into(),
132            symbol,
133            ascii,
134            style: style.into(),
135        }
136    }
137
138    /// Passed: `✓`, `+` in ASCII, `chart.state.pass`.
139    pub fn pass() -> Self {
140        State::new("pass", '✓', '+', "chart.state.pass")
141    }
142
143    /// Failed: `✗`, `X` in ASCII, `chart.state.fail`.
144    pub fn fail() -> Self {
145        State::new("fail", '✗', 'X', "chart.state.fail")
146    }
147
148    /// Skipped: `○`, `-` in ASCII, `chart.state.skip`.
149    pub fn skip() -> Self {
150        State::new("skip", '○', '-', "chart.state.skip")
151    }
152
153    /// Passed only on a retry: `≈`, `~` in ASCII, `chart.state.flaky`.
154    pub fn flaky() -> Self {
155        State::new("flaky", '≈', '~', "chart.state.flaky")
156    }
157
158    /// The name cells refer to it by.
159    pub fn name(&self) -> &str {
160        &self.name
161    }
162
163    /// The symbol, ASCII when `ascii`.
164    pub fn symbol(&self, ascii: bool) -> char {
165        if ascii { self.ascii } else { self.symbol }
166    }
167
168    /// The style: a theme key or a definition.
169    pub fn style(&self) -> &str {
170        &self.style
171    }
172}
173
174/// How a matrix fits its width.
175#[derive(Clone, Copy, Debug, PartialEq)]
176struct Grid {
177    /// Cells for the row labels (0: none).
178    label: usize,
179    /// Cells per column.
180    column: usize,
181    /// Cells between columns.
182    gap: usize,
183    /// Columns that fit, from the first.
184    shown: usize,
185    /// Whether the column headers are written.
186    headers: bool,
187}
188
189/// Rows and columns of states: a test suite across platforms, services
190/// across regions, checks across hosts.
191///
192/// Each cell is a [`State`] named by a string. `pass`, `fail`, `skip` and
193/// `flaky` are built in; [`state`](Self::state) adds your own or restyles a
194/// built-in one. Every state is a **symbol and a colour**, never colour
195/// alone, and the legend under the grid names each symbol with how many
196/// cells are in that state. A name with no state is drawn `?` in
197/// `chart.state.unknown` and named in the legend as it is.
198///
199/// - Columns are as wide as their widest header, with a cell between them,
200///   and the symbol is centred. Given less width, headers are cut (with
201///   `…`, or `.` in ASCII, and left out below three cells) down to the
202///   symbol, then the row labels are cut,
203///   then the gaps go; columns that still do not fit are left off the
204///   right.
205/// - A row shorter than the headers leaves its last cells blank.
206///
207/// ```
208/// use rich::Console;
209/// use rich_ext::chart::StatusMatrix;
210///
211/// let console = Console::builder().width(40).color_system(None).build();
212/// let matrix = StatusMatrix::new()
213///     .columns(["linux", "macos", "win"])
214///     .row("unit", ["pass", "pass", "fail"])
215///     .row("e2e", ["pass", "flaky", "skip"]);
216/// assert_eq!(
217///     console.render_export(&matrix),
218///     concat!(
219///         "     linux macos  win                  \n",
220///         "unit   ✓     ✓     ✗                   \n",
221///         "e2e    ✓     ≈     ○                   \n",
222///         "✓ pass 3  ✗ fail 1  ○ skip 1  ≈ flaky 1\n",
223///     )
224/// );
225/// ```
226#[derive(Clone, Debug, PartialEq)]
227#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
228pub struct StatusMatrix {
229    columns: Vec<String>,
230    rows: Vec<(String, Vec<String>)>,
231    states: Vec<State>,
232    charset: Charset,
233    legend: bool,
234}
235
236impl Default for StatusMatrix {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242impl StatusMatrix {
243    /// An empty matrix that knows `pass`, `fail`, `skip` and `flaky`.
244    pub fn new() -> Self {
245        StatusMatrix {
246            columns: Vec::new(),
247            rows: Vec::new(),
248            states: vec![State::pass(), State::fail(), State::skip(), State::flaky()],
249            charset: Charset::Auto,
250            legend: true,
251        }
252    }
253
254    /// The column headers.
255    pub fn columns<S: Into<String>>(mut self, headers: impl IntoIterator<Item = S>) -> Self {
256        self.columns = headers.into_iter().map(Into::into).collect();
257        self
258    }
259
260    /// Add a row: its label and the name of each cell's state, in column
261    /// order.
262    pub fn row<S: Into<String>>(
263        mut self,
264        label: impl Into<String>,
265        states: impl IntoIterator<Item = S>,
266    ) -> Self {
267        self.rows
268            .push((label.into(), states.into_iter().map(Into::into).collect()));
269        self
270    }
271
272    /// Add a state, or replace the one with the same name. The legend lists
273    /// states in the order they were added (built-in ones first).
274    pub fn state(mut self, state: State) -> Self {
275        match self.states.iter_mut().find(|s| s.name == state.name) {
276            Some(existing) => *existing = state,
277            None => self.states.push(state),
278        }
279        self
280    }
281
282    /// Show the legend (default on).
283    pub fn legend(mut self, show: bool) -> Self {
284        self.legend = show;
285        self
286    }
287
288    /// Glyphs to draw with: [`Charset::Ascii`] asks for the ASCII symbols;
289    /// anything else draws the states' own symbols (ASCII on an ASCII-only
290    /// console).
291    pub fn charset(mut self, charset: Charset) -> Self {
292        self.charset = charset;
293        self
294    }
295
296    fn find(&self, name: &str) -> Option<&State> {
297        self.states.iter().find(|s| s.name == name)
298    }
299
300    /// How many cells are in each state that appears, in legend order:
301    /// known states in the order they were added, then unknown names in the
302    /// order they first appear.
303    pub fn counts(&self) -> Vec<(String, usize)> {
304        let mut out: Vec<(String, usize)> =
305            self.states.iter().map(|s| (s.name.clone(), 0)).collect();
306        for (_, cells) in &self.rows {
307            for name in cells {
308                match out.iter_mut().find(|(n, _)| n == name) {
309                    Some((_, count)) => *count += 1,
310                    None => out.push((name.clone(), 1)),
311                }
312            }
313        }
314        out.retain(|(_, count)| *count > 0);
315        out
316    }
317
318    fn column_count(&self) -> usize {
319        self.rows
320            .iter()
321            .map(|(_, c)| c.len())
322            .max()
323            .unwrap_or(0)
324            .max(self.columns.len())
325    }
326
327    fn header(&self, i: usize) -> &str {
328        self.columns.get(i).map(String::as_str).unwrap_or("")
329    }
330
331    fn label_width(&self) -> usize {
332        self.rows.iter().map(|(l, _)| cells(l)).max().unwrap_or(0)
333    }
334
335    fn header_width(&self) -> usize {
336        self.columns.iter().map(|h| cells(h)).max().unwrap_or(0)
337    }
338
339    fn natural_width(&self) -> usize {
340        let n = self.column_count();
341        let l = self.label_width();
342        let c = self.header_width().max(1);
343        l + usize::from(l > 0) + n * c + n.saturating_sub(1)
344    }
345
346    fn layout(&self, width: usize) -> Grid {
347        let n = self.column_count();
348        let l = self.label_width();
349        let h = self.header_width();
350        let natural = h.max(1);
351        let part = |l: usize| l + usize::from(l > 0);
352        let total = |l: usize, c: usize, gap: usize| part(l) + n * c + n.saturating_sub(1) * gap;
353        if total(l, natural, 1) <= width {
354            return Grid {
355                label: l,
356                column: natural,
357                gap: 1,
358                shown: n,
359                headers: h > 0,
360            };
361        }
362        // Narrow the columns, cutting the headers.
363        let room = (width.saturating_sub(part(l)) + 1) / n.max(1);
364        if room >= 2 {
365            let column = room - 1;
366            return Grid {
367                label: l,
368                column,
369                gap: 1,
370                shown: n,
371                headers: h > 0 && (column >= 3 || h <= column),
372            };
373        }
374        // Cut the row labels, keeping three cells of them.
375        let keep = l.min(3);
376        let headers = h == 1;
377        if let Some(label) = width.checked_sub(total(0, 1, 1)) {
378            let label = label.saturating_sub(1).min(l);
379            if label >= keep && (label > 0 || l == 0) {
380                return Grid {
381                    label,
382                    column: 1,
383                    gap: 1,
384                    shown: n,
385                    headers,
386                };
387            }
388        }
389        // No gaps, then as many columns as fit.
390        let label = if width > part(keep) { keep } else { 0 };
391        let shown = n.min(width.saturating_sub(part(label)));
392        Grid {
393            label,
394            column: 1,
395            gap: 0,
396            shown,
397            headers,
398        }
399    }
400
401    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
402        let width = options.max_width;
403        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
404        let colour = has_colour(console);
405        if self.column_count() == 0 {
406            let mut line = Line::new();
407            line.push(&truncate("no data", width, ascii), None);
408            return vec![line];
409        }
410        let grid = self.layout(width);
411        let label_style = colour.then(|| theme_style(console, "chart.label"));
412        let unknown = colour.then(|| theme_style(console, "chart.state.unknown"));
413        let state_style = |state: &State| colour.then(|| user_style(console, &state.style));
414        let mut out = Vec::new();
415        let lead = |line: &mut Line| {
416            if grid.label > 0 {
417                line.pad(grid.label + 1);
418            }
419        };
420        if grid.headers && grid.shown > 0 {
421            let mut line = Line::new();
422            lead(&mut line);
423            for i in 0..grid.shown {
424                if i > 0 {
425                    line.pad(grid.gap);
426                }
427                centre(
428                    &mut line,
429                    self.header(i),
430                    grid.column,
431                    label_style.clone(),
432                    ascii,
433                );
434            }
435            out.push(line);
436        }
437        for (label, states) in &self.rows {
438            let mut line = Line::new();
439            if grid.label > 0 {
440                let text = truncate(label, grid.label, ascii);
441                let pad = grid.label - cells(&text);
442                line.push(&text, label_style.clone());
443                line.pad(pad + 1);
444            }
445            for i in 0..grid.shown {
446                if i > 0 {
447                    line.pad(grid.gap);
448                }
449                let (symbol, style) = match states.get(i) {
450                    None => (' ', None),
451                    Some(name) => match self.find(name) {
452                        Some(state) => (state.symbol(ascii), state_style(state)),
453                        None => ('?', unknown.clone()),
454                    },
455                };
456                let pad = grid.column - 1;
457                line.pad(pad / 2);
458                line.push(&symbol.to_string(), style);
459                line.pad(pad - pad / 2);
460            }
461            out.push(line);
462        }
463        if self.legend {
464            out.extend(wrap_entries(
465                self.legend_entries(ascii, colour, console),
466                width,
467            ));
468        }
469        out
470    }
471
472    fn legend_entries(
473        &self,
474        ascii: bool,
475        colour: bool,
476        console: &Console,
477    ) -> Vec<Vec<(String, Option<Style>)>> {
478        let label_style = colour.then(|| theme_style(console, "chart.label"));
479        self.counts()
480            .into_iter()
481            .map(|(name, count)| {
482                let (symbol, style) = match self.find(&name) {
483                    Some(state) => (
484                        state.symbol(ascii),
485                        colour.then(|| user_style(console, &state.style)),
486                    ),
487                    None => (
488                        '?',
489                        colour.then(|| theme_style(console, "chart.state.unknown")),
490                    ),
491                };
492                let name = if ascii {
493                    crate::fidelity::ascii_text(&name)
494                } else {
495                    name
496                };
497                vec![
498                    (symbol.to_string(), style),
499                    (format!(" {name} {count}"), label_style.clone()),
500                ]
501            })
502            .collect()
503    }
504}
505
506impl crate::surface::Draw for StatusMatrix {
507    fn draw(
508        &self,
509        console: &Console,
510        options: &ConsoleOptions,
511        screen: &mut crate::surface::Screen,
512        rect: crate::surface::Rect,
513    ) {
514        super::draw_lines(
515            self.lines(console, options),
516            options.max_width,
517            options,
518            screen,
519            rect,
520        );
521    }
522}
523
524impl Renderable for StatusMatrix {
525    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
526        lines_to_segments(self.lines(console, options), options.max_width)
527    }
528
529    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
530        let n = self.column_count();
531        if n == 0 {
532            return Measurement::new(7, 7).with_maximum(options.max_width);
533        }
534        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
535        let legend = if self.legend {
536            entries_width(&self.legend_entries(ascii, false, console))
537        } else {
538            0
539        };
540        let max = self.natural_width().max(legend);
541        let min = (2 * n - 1).min(max);
542        Measurement::new(min, max)
543            .with_maximum(options.max_width)
544            .normalize()
545    }
546}
547
548/// `Status matrix, 3 rows by 4 columns: 9 pass, 2 fail, 1 skip`, then the
549/// cells of every state but `pass` and `skip`, each `row on column` (`fail:
550/// unit on windows, lint on macos`), ten at most.
551impl crate::a11y::AccessibleText for StatusMatrix {
552    fn accessible_text(&self, _width: usize) -> String {
553        use super::describe::{capped, count, counted};
554        let columns = self.column_count();
555        if self.rows.is_empty() || columns == 0 {
556            return "Status matrix, no cells".to_string();
557        }
558        let counts = self.counts();
559        let tally: Vec<String> = counts
560            .iter()
561            .map(|(name, n)| format!("{} {name}", count(*n)))
562            .collect();
563        let mut out = vec![format!(
564            "Status matrix, {} by {}: {}",
565            counted(self.rows.len(), "row", "rows"),
566            counted(columns, "column", "columns"),
567            tally.join(", ")
568        )];
569        let header = |c: usize| match self.columns.get(c).filter(|h| !h.is_empty()) {
570            Some(h) => h.clone(),
571            None => format!("column {}", c + 1),
572        };
573        for (name, _) in counts.iter().filter(|(n, _)| n != "pass" && n != "skip") {
574            let cells: Vec<String> = self
575                .rows
576                .iter()
577                .flat_map(|(label, states)| {
578                    states
579                        .iter()
580                        .enumerate()
581                        .filter(|(_, s)| *s == name)
582                        .map(move |(c, _)| match label.as_str() {
583                            "" => header(c),
584                            row => format!("{row} on {}", header(c)),
585                        })
586                })
587                .collect();
588            out.push(format!("{name}: {}", capped(&cells, 10)));
589        }
590        out.join("\n")
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::a11y::AccessibleText;
598
599    #[test]
600    fn accessible_text_counts_and_names_the_failures() {
601        let matrix = StatusMatrix::new()
602            .columns(["linux", "macos", "windows"])
603            .row("unit", ["pass", "pass", "fail"])
604            .row("lint", ["pass", "flaky", "skip"])
605            .row("docs", ["pass", "fail", "blocked"]);
606        assert_eq!(
607            matrix.accessible_text(80),
608            "Status matrix, 3 rows by 3 columns: 4 pass, 2 fail, 1 skip, 1 flaky, 1 blocked\n\
609             fail: unit on windows, docs on macos\n\
610             flaky: lint on macos\n\
611             blocked: docs on windows"
612        );
613        assert_eq!(
614            StatusMatrix::new().accessible_text(80),
615            "Status matrix, no cells"
616        );
617    }
618}