Skip to main content

rich_ext/chart/
kpi.rs

1//! KPI cards: a label, a value, its change and its trend.
2
3use rich::measure::Measurement;
4use rich::{Console, ConsoleOptions, Renderable, Segment};
5
6use super::{
7    Charset, Line, Sparkline, Status, ValueFormat, cells, has_colour, lines_to_segments,
8    theme_style, truncate,
9};
10
11/// The narrowest card that keeps its border.
12const MIN_BORDERED: usize = 5;
13
14/// A change, as written on the card.
15#[derive(Clone, Copy, Debug, PartialEq)]
16enum Delta {
17    Absolute(f64),
18    Percent(f64),
19}
20
21impl Delta {
22    fn value(self) -> f64 {
23        match self {
24            Delta::Absolute(v) | Delta::Percent(v) => v,
25        }
26    }
27}
28
29/// One key number: a label, the value, its change with a direction, an
30/// optional sparkline and an optional [`Status`].
31///
32/// ```text
33/// ╭───────────────────────╮
34/// │ Requests         ✓ ok │
35/// │ 12.4k/s               │
36/// │ ▲ +8.49% vs last week │
37/// │ ▁▂▂▄▆▅▃▆█▇▅▃          │
38/// ╰───────────────────────╯
39/// ```
40///
41/// - The delta is an arrow and a signed number: `▲ +8.5%`, `▼ -120`,
42///   `= 0` (`^`, `v` and `=` in ASCII). It is styled `chart.delta.good` or
43///   `chart.delta.bad` by whether the change is for the better
44///   ([`higher_is_better`](Self::higher_is_better), on by default), and
45///   the arrow and sign carry the direction without colour.
46/// - The status is a symbol and a word (`✓ ok`, `! warning`,
47///   `✗ critical`), right-aligned on the label's line.
48/// - The card measures to its content, so cards sit side by side in a
49///   `Columns` or a `Table` grid; [`width`](Self::width) fixes the width and
50///   [`expand`](Self::expand) fills what it is given, so a row of cards
51///   lines up. Every card with the same parts has the same height.
52/// - Given less width the label is cut, then the other lines; the
53///   sparkline resamples. Below 5 cells the border goes.
54///
55/// ```
56/// use rich::Console;
57/// use rich_ext::chart::{Charset, KpiCard, Status};
58///
59/// let console = Console::builder().width(40).color_system(None).build();
60/// let card = KpiCard::new("Errors", 42.0)
61///     .previous(56.0)
62///     .higher_is_better(false)
63///     .caption("vs yesterday")
64///     .status(Status::Warning);
65/// assert_eq!(
66///     console.render_export(&card),
67///     concat!(
68///         "╭─────────────────────╮\n",
69///         "│ Errors    ! warning │\n",
70///         "│ 42                  │\n",
71///         "│ ▼ -25% vs yesterday │\n",
72///         "╰─────────────────────╯\n",
73///     )
74/// );
75/// ```
76#[derive(Clone, Debug, PartialEq)]
77#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
78pub struct KpiCard {
79    label: String,
80    value: f64,
81    format: ValueFormat,
82    unit: String,
83    delta: Option<Delta>,
84    caption: Option<String>,
85    higher_is_better: bool,
86    sparkline: Option<Sparkline>,
87    status: Option<Status>,
88    width: Option<usize>,
89    expand: bool,
90    border: bool,
91    charset: Charset,
92}
93
94impl KpiCard {
95    /// A card called `label` showing `value`.
96    pub fn new(label: impl Into<String>, value: f64) -> Self {
97        KpiCard {
98            label: label.into(),
99            value,
100            format: ValueFormat::Compact,
101            unit: String::new(),
102            delta: None,
103            caption: None,
104            higher_is_better: true,
105            sparkline: None,
106            status: None,
107            width: None,
108            expand: false,
109            border: true,
110            charset: Charset::Auto,
111        }
112    }
113
114    /// How the value and an absolute delta are written (default
115    /// [`ValueFormat::Compact`]).
116    pub fn format(mut self, format: ValueFormat) -> Self {
117        self.format = format;
118        self
119    }
120
121    /// Written right after the value, such as `%`, `/s` or ` ms`.
122    pub fn unit(mut self, unit: impl Into<String>) -> Self {
123        self.unit = unit.into();
124        self
125    }
126
127    /// The change as an amount, written in the value's format.
128    pub fn delta(mut self, delta: f64) -> Self {
129        self.delta = Some(Delta::Absolute(delta));
130        self
131    }
132
133    /// The change as a percentage.
134    pub fn delta_percent(mut self, percent: f64) -> Self {
135        self.delta = Some(Delta::Percent(percent));
136        self
137    }
138
139    /// The change from `previous` to the value, as a percentage (as an
140    /// amount when `previous` is 0).
141    pub fn previous(mut self, previous: f64) -> Self {
142        let change = self.value - previous;
143        self.delta = Some(if previous != 0.0 && previous.is_finite() {
144            Delta::Percent(change / previous.abs() * 100.0)
145        } else {
146            Delta::Absolute(change)
147        });
148        self
149    }
150
151    /// Words after the delta, such as `vs last week`.
152    pub fn caption(mut self, caption: impl Into<String>) -> Self {
153        self.caption = Some(caption.into());
154        self
155    }
156
157    /// Whether a rise is good news (default on); off for errors, latency
158    /// and costs.
159    pub fn higher_is_better(mut self, higher: bool) -> Self {
160        self.higher_is_better = higher;
161        self
162    }
163
164    /// A sparkline under the delta.
165    pub fn sparkline(mut self, sparkline: Sparkline) -> Self {
166        self.sparkline = Some(sparkline);
167        self
168    }
169
170    /// A sparkline of `values` under the delta.
171    pub fn trend(self, values: impl IntoIterator<Item = f64>) -> Self {
172        self.sparkline(Sparkline::new(values))
173    }
174
175    /// The status, right-aligned on the label's line.
176    pub fn status(mut self, status: Status) -> Self {
177        self.status = Some(status);
178        self
179    }
180
181    /// Fix the card's width, border included.
182    pub fn width(mut self, width: usize) -> Self {
183        self.width = Some(width.max(1));
184        self
185    }
186
187    /// Fill the width given (default off: the content's width).
188    pub fn expand(mut self, expand: bool) -> Self {
189        self.expand = expand;
190        self
191    }
192
193    /// Draw the border (default on).
194    pub fn border(mut self, border: bool) -> Self {
195        self.border = border;
196        self
197    }
198
199    /// Glyphs to draw with: [`Charset::Ascii`] asks for ASCII; anything else
200    /// draws box drawing and arrows (ASCII on an ASCII-only console). A
201    /// sparkline keeps its own charset unless this asks for ASCII.
202    pub fn charset(mut self, charset: Charset) -> Self {
203        self.charset = charset;
204        self
205    }
206
207    fn value_text(&self) -> String {
208        let value = self.format.format(self.value);
209        if self.value.is_finite() {
210            format!("{value}{}", self.unit)
211        } else {
212            value
213        }
214    }
215
216    fn delta_text(&self, ascii: bool) -> Option<String> {
217        let delta = self.delta?;
218        let v = delta.value();
219        // NaN, or a change too large for an `f64`: flat, with no sign.
220        let direction = v.is_finite().then(|| v.partial_cmp(&0.0)).flatten();
221        let arrow = match (direction, ascii) {
222            (Some(std::cmp::Ordering::Greater), false) => "▲",
223            (Some(std::cmp::Ordering::Greater), true) => "^",
224            (Some(std::cmp::Ordering::Less), false) => "▼",
225            (Some(std::cmp::Ordering::Less), true) => "v",
226            _ => "=",
227        };
228        let number = match delta {
229            Delta::Absolute(v) => self.format.format(v),
230            Delta::Percent(v) => {
231                let text = ValueFormat::Compact.format(v);
232                if v.is_finite() {
233                    format!("{text}%")
234                } else {
235                    text
236                }
237            }
238        };
239        let sign = if v > 0.0 && v.is_finite() && number != "0" && number != "0%" {
240            "+"
241        } else {
242            ""
243        };
244        Some(format!("{arrow} {sign}{number}"))
245    }
246
247    fn delta_key(&self) -> &'static str {
248        let v = self.delta.map_or(0.0, Delta::value);
249        if !v.is_finite() || v == 0.0 {
250            "chart.delta.flat"
251        } else if (v > 0.0) == self.higher_is_better {
252            "chart.delta.good"
253        } else {
254            "chart.delta.bad"
255        }
256    }
257
258    fn charsets(&self, console: &Console, options: &ConsoleOptions) -> (bool, Option<Charset>) {
259        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
260        let spark = self.sparkline.as_ref().map(|s| {
261            if ascii {
262                Charset::Ascii
263            } else {
264                s.resolved_charset(console, options)
265            }
266        });
267        (ascii, spark)
268    }
269
270    /// The content's natural width, border not included.
271    fn inner_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
272        let header = cells(&self.label)
273            + self.status.map_or(0, |s| {
274                cells(&s.text(ascii)) + usize::from(!self.label.is_empty())
275            });
276        let delta = match (self.delta_text(ascii), &self.caption) {
277            (Some(d), Some(c)) => cells(&d) + 1 + cells(c),
278            (Some(d), None) => cells(&d),
279            (None, Some(c)) => cells(c),
280            (None, None) => 0,
281        };
282        let spark = match (&self.sparkline, spark) {
283            (Some(s), Some(charset)) => s.natural_width(charset),
284            _ => 0,
285        };
286        header
287            .max(cells(&self.value_text()))
288            .max(delta)
289            .max(spark)
290            .max(1)
291    }
292
293    fn natural_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
294        self.inner_width(ascii, spark) + if self.border { 4 } else { 0 }
295    }
296
297    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
298        let given = options.max_width;
299        let (ascii, spark) = self.charsets(console, options);
300        let colour = has_colour(console);
301        let total = if self.expand {
302            given
303        } else {
304            self.width
305                .unwrap_or_else(|| self.natural_width(ascii, spark))
306                .min(given)
307        };
308        let border = self.border && total >= MIN_BORDERED;
309        let inner = if border { total - 4 } else { total };
310        let style = |key: &str| colour.then(|| theme_style(console, key));
311
312        let mut content = Vec::new();
313        // The label, and the status on the right.
314        let mut header = Line::new();
315        let status = self.status.map(|s| truncate(&s.text(ascii), inner, ascii));
316        let status_w = status.as_deref().map_or(0, cells);
317        let label_room = inner.saturating_sub(status_w + usize::from(status_w > 0));
318        let label = truncate(&self.label, label_room, ascii);
319        header.push(&label, style("chart.kpi.label"));
320        if let (Some(text), Some(s)) = (&status, self.status) {
321            header.pad(inner - cells(&label) - status_w);
322            header.push(text, style(s.key()));
323        }
324        content.push(header);
325        let mut value = Line::new();
326        value.push(
327            &truncate(&self.value_text(), inner, ascii),
328            style("chart.kpi.value"),
329        );
330        content.push(value);
331        let delta = self.delta_text(ascii);
332        if delta.is_some() || self.caption.is_some() {
333            let mut line = Line::new();
334            if let Some(d) = &delta {
335                line.push(&truncate(d, inner, ascii), style(self.delta_key()));
336            }
337            if let Some(caption) = &self.caption {
338                let room = inner.saturating_sub(line.width() + usize::from(line.width() > 0));
339                if room > 0 {
340                    if line.width() > 0 {
341                        line.pad(1);
342                    }
343                    line.push(&truncate(caption, room, ascii), style("chart.label"));
344                }
345            }
346            content.push(line);
347        }
348        if let (Some(s), Some(charset)) = (&self.sparkline, spark) {
349            content.push(s.line(console, charset, inner));
350        }
351
352        if !border {
353            for line in &mut content {
354                line.crop(inner);
355                let fill = inner - line.width();
356                line.pad(fill);
357            }
358            return content;
359        }
360        let edge = style("chart.kpi.border");
361        let (tl, tr, bl, br, h, v) = if ascii {
362            ('+', '+', '+', '+', '-', '|')
363        } else {
364            ('╭', '╮', '╰', '╯', '─', '│')
365        };
366        let rule = |l: char, r: char| {
367            let mut line = Line::new();
368            line.push(
369                &format!("{l}{}{r}", h.to_string().repeat(total - 2)),
370                edge.clone(),
371            );
372            line
373        };
374        let mut out = vec![rule(tl, tr)];
375        for mut body in content {
376            body.crop(inner);
377            let fill = inner - body.width();
378            body.pad(fill);
379            let mut line = Line::new();
380            line.push(&format!("{v} "), edge.clone());
381            line.append(body);
382            line.push(&format!(" {v}"), edge.clone());
383            out.push(line);
384        }
385        out.push(rule(bl, br));
386        out
387    }
388}
389
390impl crate::surface::Draw for KpiCard {
391    fn draw(
392        &self,
393        console: &Console,
394        options: &ConsoleOptions,
395        screen: &mut crate::surface::Screen,
396        rect: crate::surface::Rect,
397    ) {
398        super::draw_lines(
399            self.lines(console, options),
400            options.max_width,
401            options,
402            screen,
403            rect,
404        );
405    }
406}
407
408impl Renderable for KpiCard {
409    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
410        lines_to_segments(self.lines(console, options), options.max_width)
411    }
412
413    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
414        let (ascii, spark) = self.charsets(console, options);
415        let max = if self.expand {
416            options.max_width
417        } else {
418            self.width
419                .unwrap_or_else(|| self.natural_width(ascii, spark))
420        };
421        let min = self.width.unwrap_or(if self.border { 8 } else { 4 });
422        Measurement::new(min.min(max), max)
423            .with_maximum(options.max_width)
424            .normalize()
425    }
426}
427
428/// `KPI Errors: 42, down 25% vs yesterday, better, status warning`: the
429/// value, its change in words and whether that is for the better, and the
430/// status; then the sparkline's text, if it has one.
431impl crate::a11y::AccessibleText for KpiCard {
432    fn accessible_text(&self, width: usize) -> String {
433        let what = if self.label.is_empty() {
434            "KPI".to_string()
435        } else {
436            format!("KPI {}", self.label)
437        };
438        let mut parts = vec![self.value_text()];
439        if let Some(delta) = self.delta {
440            let v = delta.value();
441            let mut change = if !v.is_finite() {
442                "change unknown".to_string()
443            } else if v == 0.0 {
444                "unchanged".to_string()
445            } else {
446                // The written number without its sign, as the arrow says it.
447                let text = self
448                    .delta_text(true)
449                    .unwrap_or_default()
450                    .split_once(' ')
451                    .map(|(_, n)| n.trim_start_matches(['+', '-']).to_string())
452                    .unwrap_or_default();
453                format!("{} {text}", if v > 0.0 { "up" } else { "down" })
454            };
455            if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
456                change.push(' ');
457                change.push_str(caption);
458            }
459            parts.push(change);
460            if v.is_finite() && v != 0.0 {
461                let better = (v > 0.0) == self.higher_is_better;
462                parts.push(if better { "better" } else { "worse" }.to_string());
463            }
464        } else if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
465            parts.push(caption.to_string());
466        }
467        if let Some(status) = self.status {
468            parts.push(format!("status {}", status.word()));
469        }
470        let mut out = format!("{what}: {}", parts.join(", "));
471        if let Some(spark) = &self.sparkline {
472            out.push('\n');
473            out.push_str(&crate::a11y::AccessibleText::accessible_text(spark, width));
474        }
475        out
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::a11y::AccessibleText;
483
484    #[test]
485    fn accessible_text_says_the_change_in_words() {
486        let card = KpiCard::new("Errors", 42.0)
487            .previous(56.0)
488            .higher_is_better(false)
489            .caption("vs yesterday")
490            .status(Status::Warning);
491        assert_eq!(
492            card.accessible_text(80),
493            "KPI Errors: 42, down 25% vs yesterday, better, status warning"
494        );
495        let trend = KpiCard::new("Requests", 12_400.0)
496            .unit("/s")
497            .delta_percent(8.49)
498            .trend([1.0, 3.0, 2.0, 5.0]);
499        assert_eq!(
500            trend.accessible_text(80),
501            "KPI Requests: 12.4k/s, up 8.49%, better\n\
502             Sparkline, 4 values: min 1, max 5, last 5, trend up"
503        );
504        assert_eq!(
505            KpiCard::new("", 0.0).delta(0.0).accessible_text(80),
506            "KPI: 0, unchanged"
507        );
508    }
509}