Skip to main content

rich_ext/chart/
sparkline.rs

1//! A chart of a series of values, one cell (or half a cell) a value, on one
2//! row or several.
3
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::{
8    Charset, Line, Scale, ValueFormat, cells, has_colour, lines_to_segments, theme_style,
9    user_style,
10};
11use crate::fidelity::ascii_text;
12
13/// Heights for [`Charset::Blocks`], lowest first.
14const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
15/// Heights for [`Charset::Ascii`], lowest first (the ramp `qa::bench` uses).
16const ASCII: [char; 8] = ['_', '.', '-', ':', '=', '+', '*', '#'];
17/// The tallest sparkline, in rows.
18const MAX_ROWS: usize = u16::MAX as usize;
19
20/// Which way a [`Sparkline`] runs.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum Direction {
23    /// The first value on the left (the default).
24    #[default]
25    LeftToRight,
26    /// The first value on the right, against the right edge of the width
27    /// given, as ratatui's `RenderDirection::RightToLeft`: a series that
28    /// grows at its front scrolls left.
29    RightToLeft,
30}
31
32/// How finely a [`Sparkline`] in [`Charset::Blocks`] draws the top of a
33/// bar.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
35pub enum Levels {
36    /// Eighths of a cell, `▁▂▃▄▅▆▇█` (the default; ratatui's
37    /// `NINE_LEVELS`).
38    #[default]
39    Nine,
40    /// Nothing, a half or a whole cell, ` ▄█` (ratatui's `THREE_LEVELS`),
41    /// for fonts without the eighths: the lowest eighth draws nothing,
42    /// two to six eighths a half, seven and eight a whole cell.
43    Three,
44}
45
46impl Levels {
47    /// The glyph for a cell `eighths` full (1 to 8).
48    fn glyph(self, eighths: usize) -> char {
49        match self {
50            Levels::Nine => BLOCKS[eighths.clamp(1, 8) - 1],
51            Levels::Three => match eighths {
52                0 | 1 => ' ',
53                2..=6 => '▄',
54                _ => '█',
55            },
56        }
57    }
58}
59
60/// A chart of a series of values: each value is a column of cells whose
61/// height shows where it falls between the smallest and largest value.
62///
63/// - [`Charset::Blocks`] (the default) draws one value per cell with
64///   `▁▂▃▄▅▆▇█` ([`levels`](Self::levels) picks ratatui's three-level set
65///   instead); [`Charset::Braille`] draws two values per cell, four
66///   heights each; [`Charset::Ascii`] uses the ramp `_.-:=+*#`.
67/// - It is one row tall, or [`rows`](Self::rows) rows, or the height it is
68///   given (a `Layout` region, an intuiTUIve node) when that is not set.
69///   Each row adds eight heights in blocks and four in Braille; the lowest
70///   value shows at least the lowest glyph.
71/// - NaN and infinite values, and the `None`s of
72///   [`optional`](Self::optional), are gaps: a space, or
73///   [`absent`](Self::absent)'s symbol down the whole column.
74/// - It measures to one cell per value (half that in Braille). Given fewer
75///   cells, it **resamples**: the values are split into as many equal
76///   buckets as there are cells (two per cell in Braille) and each bucket
77///   is drawn at the mean of its finite values.
78/// - [`min_max`](Self::min_max) styles the lowest and highest cell and
79///   writes `min 2 max 9` after the line; [`threshold`](Self::threshold)
80///   styles the cells above it and writes `3 > 80`. The words are what make
81///   them readable without colour, and they are the first thing left out
82///   when the width is short. On several rows they go on the bottom one.
83/// - [`direction`](Self::direction) runs it right to left, and
84///   [`styles`](Self::styles) gives each value its own style.
85///
86/// ```
87/// use rich::Console;
88/// use rich_ext::chart::{Charset, Sparkline};
89///
90/// let console = Console::builder().width(40).color_system(None).build();
91/// let values = [2.0, 4.0, 3.0, 9.0, 6.0, 5.0];
92/// let spark = Sparkline::new(values).min_max(true);
93/// assert_eq!(console.render_export(&spark), "▁▃▂█▅▄ min 2 max 9\n");
94///
95/// let ascii = Sparkline::new(values).charset(Charset::Ascii);
96/// assert_eq!(console.render_export(&ascii), "_-.#=:\n");
97///
98/// let braille = Sparkline::new(values).charset(Charset::Braille);
99/// assert_eq!(console.render_export(&braille), "⣠⣸⣦\n");
100///
101/// // Three rows: eight heights each.
102/// let tall = Sparkline::new([1.0, 4.0, 7.0, 10.0]).rows(3);
103/// assert_eq!(console.render_export(&tall), "   █\n ▁██\n▁███\n");
104/// ```
105#[derive(Clone, Debug, PartialEq)]
106#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
107pub struct Sparkline {
108    values: Vec<f64>,
109    min: Option<f64>,
110    max: Option<f64>,
111    charset: Charset,
112    style: Option<String>,
113    min_max: bool,
114    threshold: Option<f64>,
115    format: ValueFormat,
116    rows: Option<usize>,
117    direction: Direction,
118    absent: Option<(String, String)>,
119    styles: Vec<Option<String>>,
120    levels: Levels,
121}
122
123impl Sparkline {
124    /// A sparkline of `values`, in order.
125    pub fn new(values: impl IntoIterator<Item = f64>) -> Self {
126        Sparkline {
127            values: values.into_iter().collect(),
128            min: None,
129            max: None,
130            charset: Charset::Auto,
131            style: None,
132            min_max: false,
133            threshold: None,
134            format: ValueFormat::Compact,
135            rows: None,
136            direction: Direction::LeftToRight,
137            absent: None,
138            styles: Vec::new(),
139            levels: Levels::Nine,
140        }
141    }
142
143    /// A sparkline of values that may be absent: each `None` is a gap, as
144    /// ratatui's absent values are.
145    pub fn optional(values: impl IntoIterator<Item = Option<f64>>) -> Self {
146        Self::new(values.into_iter().map(|v| v.unwrap_or(f64::NAN)))
147    }
148
149    /// Fix the bottom and top of the scale instead of using the data's
150    /// range; values outside are clamped.
151    pub fn range(mut self, min: f64, max: f64) -> Self {
152        self.min = Some(min);
153        self.max = Some(max);
154        self
155    }
156
157    /// Fix only the bottom of the scale (`0.0` to start bars at zero).
158    pub fn min(mut self, min: f64) -> Self {
159        self.min = Some(min);
160        self
161    }
162
163    /// Fix only the top of the scale.
164    pub fn max(mut self, max: f64) -> Self {
165        self.max = Some(max);
166        self
167    }
168
169    /// Glyphs to draw with (default [`Charset::Auto`]: blocks).
170    pub fn charset(mut self, charset: Charset) -> Self {
171        self.charset = charset;
172        self
173    }
174
175    /// The line's style: a theme key or a definition (default
176    /// `chart.spark`).
177    pub fn style(mut self, style: impl Into<String>) -> Self {
178        self.style = Some(style.into());
179        self
180    }
181
182    /// Mark the lowest and highest values (`chart.min`, `chart.max`) and
183    /// write them after the line.
184    pub fn min_max(mut self, show: bool) -> Self {
185        self.min_max = show;
186        self
187    }
188
189    /// Style values above `threshold` with `chart.over`, and write how many
190    /// there are after the line.
191    pub fn threshold(mut self, threshold: f64) -> Self {
192        self.threshold = Some(threshold);
193        self
194    }
195
196    /// How the summary writes values (default [`ValueFormat::Compact`]).
197    pub fn format(mut self, format: ValueFormat) -> Self {
198        self.format = format;
199        self
200    }
201
202    /// Height in rows (at least 1, at most 65535), whatever height it is
203    /// given. Without it, the sparkline fills the height it is given, or
204    /// takes one row.
205    pub fn rows(mut self, rows: usize) -> Self {
206        self.rows = Some(rows.clamp(1, MAX_ROWS));
207        self
208    }
209
210    /// Which way it runs (default [`Direction::LeftToRight`]).
211    pub fn direction(mut self, direction: Direction) -> Self {
212        self.direction = direction;
213        self
214    }
215
216    /// Draw gaps with `symbol` down the whole column, in `style` (a theme
217    /// key or a definition; empty for none), as ratatui's
218    /// `absent_value_symbol` and `absent_value_style`. A symbol that is
219    /// not one cell wide draws a space; on an ASCII console a symbol past
220    /// ASCII is written as its look-alike, or `?`.
221    pub fn absent(mut self, symbol: impl Into<String>, style: impl Into<String>) -> Self {
222        self.absent = Some((symbol.into(), style.into()));
223        self
224    }
225
226    /// A style for each value, in order (theme keys or definitions); `None`,
227    /// and values past the end of the list, keep the sparkline's. A value's
228    /// own style wins over the marks of [`min_max`](Self::min_max) and
229    /// [`threshold`](Self::threshold). A resampled cell takes the style of
230    /// the last styled value in its bucket.
231    pub fn styles(mut self, styles: Vec<Option<String>>) -> Self {
232        self.styles = styles;
233        self
234    }
235
236    /// How finely blocks draw the top of a bar (default [`Levels::Nine`]).
237    pub fn levels(mut self, levels: Levels) -> Self {
238        self.levels = levels;
239        self
240    }
241
242    /// The values; a gap is NaN.
243    pub fn values(&self) -> &[f64] {
244        &self.values
245    }
246
247    /// The charset this sparkline draws with on `console`.
248    pub(crate) fn resolved_charset(&self, console: &Console, options: &ConsoleOptions) -> Charset {
249        self.charset.resolve(console, options, Charset::Blocks)
250    }
251
252    /// Cells for every value and the summary, at `charset`.
253    pub(crate) fn natural_width(&self, charset: Charset) -> usize {
254        self.natural_cells(charset) + self.summary_width()
255    }
256
257    fn scale(&self) -> Scale {
258        Scale::from_values(self.values.iter().copied()).bounds(self.min, self.max)
259    }
260
261    /// Values per cell for a charset.
262    fn per_cell(charset: Charset) -> usize {
263        if charset == Charset::Braille { 2 } else { 1 }
264    }
265
266    /// The text after the line, as (text, theme key) parts.
267    fn summary(&self) -> Vec<(String, &'static str)> {
268        let finite: Vec<f64> = self
269            .values
270            .iter()
271            .copied()
272            .filter(|v| v.is_finite())
273            .collect();
274        let mut parts = Vec::new();
275        if self.min_max && !finite.is_empty() {
276            let lo = finite.iter().copied().fold(f64::INFINITY, f64::min);
277            let hi = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
278            parts.push((format!("min {}", self.format.format(lo)), "chart.min"));
279            parts.push((format!("max {}", self.format.format(hi)), "chart.max"));
280        }
281        if let Some(t) = self.threshold {
282            let over = finite.iter().filter(|v| **v > t).count();
283            if over > 0 {
284                parts.push((format!("{over} > {}", self.format.format(t)), "chart.over"));
285            }
286        }
287        parts
288    }
289
290    fn summary_width(&self) -> usize {
291        self.summary().iter().map(|(t, _)| 1 + cells(t)).sum()
292    }
293
294    fn natural_cells(&self, charset: Charset) -> usize {
295        self.values.len().div_ceil(Self::per_cell(charset))
296    }
297
298    /// The values to draw in `slots` slots, each with the index of its
299    /// first value and its own style: as they are when they fit, otherwise
300    /// the mean of each of `slots` equal buckets.
301    fn resample(&self, slots: usize) -> Vec<Sample> {
302        let n = self.values.len();
303        let own = |i: usize| self.styles.get(i).cloned().flatten();
304        if n <= slots {
305            return self
306                .values
307                .iter()
308                .enumerate()
309                .map(|(i, &value)| Sample {
310                    value,
311                    start: i,
312                    style: own(i),
313                })
314                .collect();
315        }
316        (0..slots)
317            .map(|i| {
318                let start = i * n / slots;
319                let end = ((i + 1) * n / slots).max(start + 1);
320                let bucket: Vec<f64> = self.values[start..end]
321                    .iter()
322                    .copied()
323                    .filter(|v| v.is_finite())
324                    .collect();
325                let value = if bucket.is_empty() {
326                    f64::NAN
327                } else {
328                    bucket.iter().sum::<f64>() / bucket.len() as f64
329                };
330                Sample {
331                    value,
332                    start,
333                    style: (start..end).rev().find_map(own),
334                }
335            })
336            .collect()
337    }
338
339    /// One row, as [`KpiCard`](super::KpiCard) draws it.
340    pub(crate) fn line(&self, console: &Console, charset: Charset, width: usize) -> Line {
341        self.lines(console, charset, width, 1)
342            .pop()
343            .unwrap_or_default()
344    }
345
346    /// `rows` rows, at most `width` cells wide.
347    fn lines(&self, console: &Console, charset: Charset, width: usize, rows: usize) -> Vec<Line> {
348        let per_cell = Self::per_cell(charset);
349        let natural = self.natural_cells(charset);
350        let summary_w = self.summary_width();
351        let (cells_used, show_summary) = if natural + summary_w <= width {
352            (natural, true)
353        } else {
354            (natural.min(width), false)
355        };
356        let mut samples = self.resample(cells_used * per_cell);
357        let scale = self.scale();
358        let base = self
359            .style
360            .as_deref()
361            .map(|s| user_style(console, s))
362            .unwrap_or_else(|| theme_style(console, "chart.spark"));
363        let colour = has_colour(console);
364        let finite = || self.values.iter().copied().filter(|v| v.is_finite());
365        let lo = finite().fold(f64::INFINITY, f64::min);
366        let hi = finite().fold(f64::NEG_INFINITY, f64::max);
367        // Mark extremes on the drawn samples (the first of each).
368        let drawn = |pick: f64| {
369            samples.iter().position(|s| s.value == pick).or_else(|| {
370                // Resampled: the slot whose bucket holds the extreme.
371                let at = self.values.iter().position(|v| *v == pick)?;
372                samples.iter().rposition(|s| s.start <= at)
373            })
374        };
375        let (min_slot, max_slot) = if self.min_max && lo.is_finite() {
376            (drawn(lo), drawn(hi))
377        } else {
378            (None, None)
379        };
380        let slot_style = |i: usize, sample: &Sample| -> Option<Style> {
381            if !colour {
382                return None;
383            }
384            if let Some(own) = &sample.style {
385                return Some(user_style(console, own));
386            }
387            let key = if self.threshold.is_some_and(|t| sample.value > t) {
388                "chart.over"
389            } else if max_slot == Some(i) {
390                "chart.max"
391            } else if min_slot == Some(i) {
392                "chart.min"
393            } else {
394                return Some(base.clone());
395            };
396            Some(theme_style(console, key))
397        };
398        let absent = self.absent.as_ref().map(|(symbol, style)| {
399            let symbol = if charset == Charset::Ascii && !symbol.is_ascii() {
400                ascii_text(symbol)
401            } else {
402                symbol.clone()
403            };
404            let symbol = if cells(&symbol) == 1 {
405                symbol
406            } else {
407                " ".to_string()
408            };
409            let style = (colour && !style.trim().is_empty()).then(|| user_style(console, style));
410            (symbol, style)
411        });
412        let gap = |out: &mut Line| match &absent {
413            Some((symbol, style)) => out.push(symbol, style.clone()),
414            None => out.push(" ", None),
415        };
416        // How many steps of `steps` a finite value fills: at least one.
417        let height = |value: f64, steps: usize| {
418            scale
419                .normalize(value)
420                .map(|n| 1 + (n * (steps - 1) as f64).round() as usize)
421        };
422
423        // The cells of each row, bottom row last, left to right in drawing
424        // order; right to left reverses the samples first.
425        let mut indexed: Vec<(usize, Sample)> = samples.drain(..).enumerate().collect();
426        if self.direction == Direction::RightToLeft {
427            indexed.reverse();
428        }
429        let mut bars: Vec<Line> = (0..rows).map(|_| Line::new()).collect();
430        match charset {
431            Charset::Braille => {
432                // Two values a cell, paired from the first value's end: right
433                // to left, the first value is the right half of the last
434                // cell.
435                let mut slots: Vec<Option<&(usize, Sample)>> = indexed.iter().map(Some).collect();
436                if self.direction == Direction::RightToLeft && slots.len() % 2 == 1 {
437                    slots.insert(0, None);
438                }
439                for chunk in slots.chunks(2) {
440                    // A cell whose left half is empty has its value right.
441                    let offset = usize::from(chunk[0].is_none());
442                    let pair: Vec<&(usize, Sample)> = chunk.iter().flatten().copied().collect();
443                    let dots: Vec<Option<usize>> = pair
444                        .iter()
445                        .map(|(_, s)| height(s.value, rows * 4))
446                        .collect();
447                    if dots.iter().all(Option::is_none) {
448                        for row in &mut bars {
449                            gap(row);
450                        }
451                        continue;
452                    }
453                    let mut style = None;
454                    for (i, s) in &pair {
455                        if !s.value.is_finite() {
456                            continue;
457                        }
458                        let own = slot_style(*i, s);
459                        if style.is_none() || own.as_ref() != Some(&base) {
460                            style = own;
461                        }
462                    }
463                    for (r, row) in bars.iter_mut().enumerate() {
464                        let from_bottom = rows - 1 - r;
465                        let mut bits = 0u32;
466                        for (k, d) in dots.iter().enumerate() {
467                            let half = k + offset;
468                            let Some(d) = d else {
469                                continue;
470                            };
471                            let here = d.saturating_sub(from_bottom * 4).min(4);
472                            let column: [u32; 4] = if half == 0 {
473                                [0x40, 0x04, 0x02, 0x01]
474                            } else {
475                                [0x80, 0x20, 0x10, 0x08]
476                            };
477                            bits |= column[..here].iter().sum::<u32>();
478                        }
479                        if bits == 0 {
480                            row.push(" ", None);
481                        } else {
482                            let glyph = char::from_u32(0x2800 + bits).unwrap_or(' ');
483                            row.push(&glyph.to_string(), style.clone());
484                        }
485                    }
486                }
487            }
488            _ => {
489                let ascii = charset == Charset::Ascii;
490                for (i, sample) in &indexed {
491                    let Some(eighths) = height(sample.value, rows * 8) else {
492                        for row in &mut bars {
493                            gap(row);
494                        }
495                        continue;
496                    };
497                    let style = slot_style(*i, sample);
498                    for (r, row) in bars.iter_mut().enumerate() {
499                        let from_bottom = rows - 1 - r;
500                        let here = eighths.saturating_sub(from_bottom * 8).min(8);
501                        let glyph = match here {
502                            0 => ' ',
503                            n if ascii => ASCII[n - 1],
504                            n => self.levels.glyph(n),
505                        };
506                        if glyph == ' ' {
507                            row.push(" ", None);
508                        } else {
509                            row.push(&glyph.to_string(), style.clone());
510                        }
511                    }
512                }
513            }
514        }
515
516        // The summary goes on the bottom row: after the bars, or before
517        // them right to left, each part a space from the bars.
518        let parts = if show_summary {
519            self.summary()
520        } else {
521            Vec::new()
522        };
523        let words = |before: bool| {
524            let mut line = Line::new();
525            for (text, key) in &parts {
526                if before {
527                    line.push(text, colour.then(|| theme_style(console, key)));
528                    line.push(" ", None);
529                } else {
530                    line.push(" ", None);
531                    line.push(text, colour.then(|| theme_style(console, key)));
532                }
533            }
534            line
535        };
536        let last = rows - 1;
537        match self.direction {
538            Direction::LeftToRight => {
539                if let Some(bottom) = bars.get_mut(last) {
540                    bottom.append(words(false));
541                }
542                bars
543            }
544            Direction::RightToLeft => {
545                // Against the right edge of the width.
546                let words = words(true);
547                let words_w = words.width();
548                let lead = width.saturating_sub(cells_used + words_w);
549                let mut words = Some(words);
550                bars.into_iter()
551                    .enumerate()
552                    .map(|(r, row)| {
553                        let mut line = Line::new();
554                        line.pad(lead);
555                        match if r == last { words.take() } else { None } {
556                            Some(words) => line.append(words),
557                            None => line.pad(words_w),
558                        }
559                        line.append(row);
560                        line
561                    })
562                    .collect()
563            }
564        }
565    }
566}
567
568/// A value as drawn: itself or a bucket's mean, the index of its first
569/// value, and its own style.
570#[derive(Clone, Debug)]
571struct Sample {
572    value: f64,
573    start: usize,
574    style: Option<String>,
575}
576
577impl crate::surface::Draw for Sparkline {
578    fn draw(
579        &self,
580        console: &Console,
581        options: &ConsoleOptions,
582        screen: &mut crate::surface::Screen,
583        rect: crate::surface::Rect,
584    ) {
585        let charset = self.charset.resolve(console, options, Charset::Blocks);
586        let width = options.max_width;
587        let rows = self.rows.or(options.height).unwrap_or(1).clamp(1, MAX_ROWS);
588        super::draw_lines(
589            self.lines(console, charset, width, rows),
590            width,
591            options,
592            screen,
593            rect,
594        );
595    }
596}
597
598impl Renderable for Sparkline {
599    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
600        let charset = self.charset.resolve(console, options, Charset::Blocks);
601        let width = options.max_width;
602        let rows = self.rows.or(options.height).unwrap_or(1).clamp(1, MAX_ROWS);
603        lines_to_segments(self.lines(console, charset, width, rows), width)
604    }
605
606    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
607        let charset = self.charset.resolve(console, options, Charset::Blocks);
608        let natural = self.natural_cells(charset);
609        let max = natural + self.summary_width();
610        Measurement::new(natural.min(4), max)
611            .with_maximum(options.max_width)
612            .normalize()
613    }
614}
615
616/// `Sparkline, 24 values: min 12, max 52, last 14, trend up`, with the
617/// gaps counted and how many values are over the threshold.
618impl crate::a11y::AccessibleText for Sparkline {
619    fn accessible_text(&self, _width: usize) -> String {
620        self.facts("Sparkline")
621    }
622}
623
624impl Sparkline {
625    /// The finite values' range, with a fixed bound in place of its end:
626    /// what a shared scale unifies.
627    pub(crate) fn value_extent(&self) -> Option<(f64, f64)> {
628        let data = super::describe::extent(self.values.iter().copied());
629        let lo = self.min.or(data.map(|d| d.0))?;
630        let hi = self.max.or(data.map(|d| d.1))?;
631        Some((lo.min(hi), lo.max(hi)))
632    }
633
634    /// `what`, the count and the series' facts.
635    pub(crate) fn facts(&self, what: &str) -> String {
636        if self.values.is_empty() {
637            return format!("{what}, no values");
638        }
639        let points = super::describe::indexed(&self.values);
640        let facts = super::describe::series(&points, self.format, ("value", "values"));
641        // "24 values, 1 missing, min …": the count goes before the colon.
642        let (count, rest) = facts.split_once(", ").unwrap_or((facts.as_str(), ""));
643        let mut out = format!("{what}, {count}");
644        if !rest.is_empty() {
645            out.push_str(": ");
646            out.push_str(rest);
647        }
648        if let Some(t) = self.threshold {
649            let over = self
650                .values
651                .iter()
652                .filter(|v| v.is_finite() && **v > t)
653                .count();
654            out.push_str(&format!(
655                ", {} above {}",
656                super::describe::count(over),
657                self.format.format(t)
658            ));
659        }
660        out
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn accessible_text_names_the_extremes_and_the_trend() {
670        use crate::a11y::AccessibleText;
671        let spark = Sparkline::new([2.0, 4.0, 3.0, 9.0, 6.0, 5.0]);
672        assert_eq!(
673            spark.accessible_text(80),
674            "Sparkline, 6 values: min 2, max 9, last 5, trend up"
675        );
676        let gaps = Sparkline::optional([Some(9.0), None, Some(4.0), Some(1.0)]).threshold(3.0);
677        assert_eq!(
678            gaps.accessible_text(80),
679            "Sparkline, 4 values: 1 missing, min 1, max 9, last 1, trend down, 2 above 3"
680        );
681        assert_eq!(
682            Sparkline::new([]).accessible_text(80),
683            "Sparkline, no values"
684        );
685    }
686
687    fn rows(width: usize, spark: &Sparkline) -> Vec<String> {
688        let console = Console::builder().width(width).color_system(None).build();
689        console
690            .render_export(spark)
691            .lines()
692            .map(str::to_string)
693            .collect()
694    }
695
696    fn coloured(width: usize) -> Console {
697        Console::builder()
698            .width(width)
699            .force_terminal(true)
700            .color_system(Some(rich::ColorSystem::Standard))
701            .no_color(false)
702            .build()
703    }
704
705    #[test]
706    fn several_rows_stack_eighths() {
707        let spark = Sparkline::new((1..=9).map(f64::from)).rows(3);
708        assert_eq!(
709            rows(20, &spark),
710            vec!["      ▂▅█", "   ▂▅▇███", "▁▄▇██████"]
711        );
712        // One row is as before.
713        assert_eq!(rows(20, &spark.clone().rows(1)), vec!["▁▂▃▄▅▅▆▇█"]);
714        // The summary goes on the bottom row.
715        let summary = Sparkline::new([1.0, 2.0, 3.0]).min_max(true).rows(2);
716        assert_eq!(
717            rows(20, &summary),
718            vec![" ▁█            ", "▁██ min 1 max 3"]
719        );
720    }
721
722    #[test]
723    fn braille_stacks_four_dots_a_row() {
724        let spark = Sparkline::new([1.0, 5.0, 3.0, 8.0, 2.0])
725            .charset(Charset::Braille)
726            .rows(2);
727        assert_eq!(rows(20, &spark), vec!["⢀⢸ ", "⣸⣾⡄"]);
728    }
729
730    #[test]
731    fn right_to_left_puts_the_first_value_at_the_right_edge() {
732        let spark = Sparkline::new([1.0, 2.0, 3.0]).direction(Direction::RightToLeft);
733        assert_eq!(rows(6, &spark), vec!["   █▅▁"]);
734        // The summary goes before the bars.
735        let summary = spark.clone().min_max(true);
736        assert_eq!(rows(20, &summary), vec!["     min 1 max 3 █▅▁"]);
737        // In Braille the first value is the right half of the last cell.
738        let braille = spark.charset(Charset::Braille);
739        assert_eq!(rows(2, &braille), vec!["⢸⣆"]);
740        // Fewer cells than values: resampled, still from the right.
741        let long = Sparkline::new((0..8).map(f64::from)).direction(Direction::RightToLeft);
742        assert_eq!(rows(4, &long), vec!["█▆▄▂"]);
743    }
744
745    #[test]
746    fn absent_values_fill_their_column() {
747        let spark = Sparkline::optional([Some(1.0), None, Some(3.0)]);
748        assert_eq!(rows(10, &spark), vec!["▁ █"]);
749        let marked = spark.clone().absent("░", "red");
750        assert_eq!(rows(10, &marked), vec!["▁░█"]);
751        assert_eq!(rows(10, &marked.clone().rows(2)), vec![" ░█", "▁░█"]);
752        // A symbol that is not one cell wide is a space.
753        assert_eq!(rows(10, &spark.clone().absent("日", "")), vec!["▁ █"]);
754        // On an ASCII console the symbol is ASCII.
755        let ascii = Console::builder()
756            .width(10)
757            .color_system(None)
758            .ascii_only(true)
759            .build();
760        assert_eq!(ascii.render_export(&marked), "_.#\n");
761        // The absent style is used.
762        let console = coloured(10);
763        let lines = console.render_lines(&marked, &console.options(), false);
764        let gap = lines[0].iter().find(|s| s.text == "░").unwrap();
765        assert_eq!(gap.style, Style::parse("red").ok());
766    }
767
768    #[test]
769    fn each_value_can_have_its_own_style() {
770        let spark = Sparkline::new([1.0, 2.0, 3.0])
771            .style("cyan")
772            .styles(vec![None, Some("red".into())]);
773        let console = coloured(10);
774        assert_eq!(
775            console.render_export(&spark),
776            "\u{1b}[36m▁\u{1b}[0m\u{1b}[31m▅\u{1b}[0m\u{1b}[36m█\u{1b}[0m\n"
777        );
778        // A value's style wins over the marks.
779        let marked = spark.min_max(true).threshold(2.5).styles(vec![
780            Some("green".into()),
781            None,
782            Some("blue".into()),
783        ]);
784        let lines = console.render_lines(&marked, &console.options(), false);
785        let styles: Vec<Option<Style>> = lines[0].iter().take(3).map(|s| s.style.clone()).collect();
786        assert_eq!(styles[0], Style::parse("green").ok());
787        assert_eq!(styles[2], Style::parse("blue").ok());
788        // Resampled, a cell takes the last styled value of its bucket.
789        let long = Sparkline::new([1.0, 2.0, 3.0, 4.0]).styles(vec![
790            Some("red".into()),
791            Some("blue".into()),
792            None,
793            None,
794        ]);
795        let narrow = coloured(2);
796        let lines = narrow.render_lines(&long, &narrow.options(), false);
797        assert_eq!(lines[0][0].style, Style::parse("blue").ok());
798    }
799
800    #[test]
801    fn three_levels_draw_halves_and_wholes() {
802        let spark = Sparkline::new((0..8).map(f64::from)).levels(Levels::Three);
803        assert_eq!(rows(10, &spark), vec![" ▄▄▄▄▄██"]);
804        assert_eq!(rows(10, &spark.rows(2)), vec!["    ▄▄▄█", " ▄▄█████"]);
805    }
806
807    #[test]
808    fn it_fills_the_height_it_is_given() {
809        let console = Console::builder().width(10).color_system(None).build();
810        let spark = Sparkline::new([1.0, 2.0, 3.0]);
811        let lines = console.render_lines(&spark, &console.options().update_height(4), false);
812        let text: Vec<String> = lines
813            .iter()
814            .map(|l| l.iter().map(|s| s.text.as_str()).collect())
815            .collect();
816        assert_eq!(text, vec!["  █", " ▁█", " ██", "▁██"]);
817        // Its own rows win.
818        let lines =
819            console.render_lines(&spark.rows(1), &console.options().update_height(4), false);
820        assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 1);
821    }
822}