Skip to main content

rich_ext/chart/
progress.rs

1//! Progress gauges laid out as ratatui's: [`FillGauge`], a bar that fills
2//! its area with the label on the middle row (ratatui's `Gauge`), and
3//! [`LineGauge`], a label and a line (ratatui's `LineGauge`).
4
5use rich::measure::Measurement;
6use rich::{Color, Console, ConsoleOptions, Renderable, Segment, Style};
7
8use super::{
9    Charset, Line, cell_units, cells, has_colour, lines_to_segments, theme_style, user_style,
10};
11use crate::fidelity::ascii_text;
12
13/// Left eighths of a cell, from one to seven.
14const EIGHTHS: [char; 7] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'];
15
16/// The ratio a gauge shows: clamped to `0..=1`, and 0 for NaN.
17fn clamp_ratio(ratio: f64) -> f64 {
18    if ratio.is_nan() {
19        0.0
20    } else {
21        ratio.clamp(0.0, 1.0)
22    }
23}
24
25/// Whether to draw in ASCII only on `console`.
26fn ascii(console: &Console, options: &ConsoleOptions) -> bool {
27    Charset::Auto.resolve(console, options, Charset::Blocks) == Charset::Ascii
28}
29
30/// A label's text, ASCII on an ASCII console.
31fn label_text(text: &str, ascii: bool) -> String {
32    if ascii && !text.is_ascii() {
33        ascii_text(text)
34    } else {
35        text.to_string()
36    }
37}
38
39/// `style` with its colours swapped, as a label over the filled part of a
40/// gauge is drawn: the bar's colour behind, its background (the terminal's
41/// default without one) in front. A style without a foreground colour is
42/// reversed instead.
43fn swapped(style: &Style) -> Style {
44    match style.color() {
45        Some(front) => {
46            let back = style
47                .bgcolor()
48                .cloned()
49                .unwrap_or_else(Color::default_color);
50            style
51                .without_color()
52                .combine(&Style::from_color(Some(back), Some(front.clone())))
53        }
54        None => style.combine(&Style::parse("reverse").unwrap_or_default()),
55    }
56}
57
58/// A style's foreground colour and attributes, without its background.
59fn front(style: &Style) -> Style {
60    style
61        .without_color()
62        .combine(&Style::from_color(style.color().cloned(), None))
63}
64
65/// A progress bar that fills its area, as ratatui's `Gauge`.
66///
67/// - The filled part runs from the left, in whole cells (`█`), or with an
68///   eighth of a cell at its edge (`▏` to `▉`) when
69///   [`unicode`](Self::unicode) is on. ASCII consoles get `#`, and `=` for
70///   a half with `unicode` on.
71/// - The label sits centred on the middle row; without one it is the
72///   percentage (`42%`). Over the filled part its colours are swapped, so
73///   it reads on the bar.
74/// - [`style`](Self::style) is ratatui's `gauge_style`: its foreground is
75///   the bar's colour and its background the rest's (`"magenta on
76///   black"`). Without it the bar is `chart.gauge.filled` and the rest
77///   `chart.gauge.unfilled`.
78/// - It fills the width it is given (or [`width`](Self::width)) and the
79///   height it is given (a `Layout` region, an intuiTUIve node), or is
80///   [`height`](Self::height) rows; given no height, one row.
81///
82/// ```
83/// use rich::Console;
84/// use rich_ext::chart::FillGauge;
85///
86/// let console = Console::builder().width(20).color_system(None).build();
87/// let gauge = FillGauge::new(0.42).height(3);
88/// assert_eq!(
89///     console.render_export(&gauge),
90///     concat!(
91///         "████████            \n",
92///         "████████42%         \n",
93///         "████████            \n",
94///     )
95/// );
96/// // With colour, the label's colours are swapped where it covers the bar.
97/// let fine = FillGauge::new(0.42).label("42.00%").unicode(true);
98/// assert_eq!(console.render_export(&fine), "███████42.00%       \n");
99/// ```
100#[derive(Clone, Debug, PartialEq)]
101#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
102pub struct FillGauge {
103    ratio: f64,
104    label: Option<String>,
105    label_style: Option<String>,
106    style: Option<String>,
107    unicode: bool,
108    width: Option<usize>,
109    height: Option<usize>,
110}
111
112impl FillGauge {
113    /// A gauge filled to `ratio`, from 0 to 1; values outside are clamped,
114    /// and NaN is 0.
115    pub fn new(ratio: f64) -> Self {
116        FillGauge {
117            ratio: clamp_ratio(ratio),
118            label: None,
119            label_style: None,
120            style: None,
121            unicode: false,
122            width: None,
123            height: None,
124        }
125    }
126
127    /// A gauge filled to `percent` percent (0 to 100).
128    pub fn percent(percent: f64) -> Self {
129        Self::new(percent / 100.0)
130    }
131
132    /// The ratio shown, from 0 to 1.
133    pub fn ratio(&self) -> f64 {
134        self.ratio
135    }
136
137    /// The label (default: the percentage, `42%`). An empty one shows
138    /// nothing.
139    pub fn label(mut self, label: impl Into<String>) -> Self {
140        self.label = Some(label.into());
141        self
142    }
143
144    /// A style over the label's own: a theme key or a definition, such as
145    /// `"bold italic"`.
146    pub fn label_style(mut self, style: impl Into<String>) -> Self {
147        self.label_style = Some(style.into());
148        self
149    }
150
151    /// The gauge's style, as ratatui's `gauge_style`: a theme key or a
152    /// definition whose foreground colours the bar and whose background
153    /// the rest. Its attributes go on the label.
154    pub fn style(mut self, style: impl Into<String>) -> Self {
155        self.style = Some(style.into());
156        self
157    }
158
159    /// Draw the bar's edge in eighths of a cell (default off: whole
160    /// cells, as ratatui's `use_unicode`).
161    pub fn unicode(mut self, unicode: bool) -> Self {
162        self.unicode = unicode;
163        self
164    }
165
166    /// Width in cells (default: the width it is given).
167    pub fn width(mut self, width: usize) -> Self {
168        self.width = Some(width);
169        self
170    }
171
172    /// Height in rows, whatever height it is given (at most 65535).
173    /// Without it, the gauge fills the height it is given, or takes one
174    /// row.
175    pub fn height(mut self, rows: usize) -> Self {
176        self.height = Some(rows.min(u16::MAX as usize));
177        self
178    }
179
180    fn label_or_percent(&self) -> String {
181        self.label
182            .clone()
183            .unwrap_or_else(|| format!("{}%", (self.ratio * 100.0).round()))
184    }
185
186    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
187        let width = self
188            .width
189            .map_or(options.max_width, |w| w.min(options.max_width));
190        let height = self
191            .height
192            .or(options.height)
193            .unwrap_or(1)
194            .min(u16::MAX as usize);
195        let ascii = ascii(console, options);
196        let colour = has_colour(console);
197        let (filled, unfilled) = match &self.style {
198            Some(spec) => {
199                let style = user_style(console, spec);
200                (style.clone(), style)
201            }
202            None => (
203                theme_style(console, "chart.gauge.filled"),
204                theme_style(console, "chart.gauge.unfilled"),
205            ),
206        };
207        let extra = self
208            .label_style
209            .as_deref()
210            .map(|spec| user_style(console, spec))
211            .unwrap_or_default();
212        let styles = colour.then(|| GaugeStyles {
213            label_on_bar: swapped(&filled).combine(&extra),
214            label_on_rest: unfilled.combine(&front(&filled)).combine(&extra),
215            filled,
216            unfilled,
217        });
218
219        // The filled cells, and the glyph of a part-filled one after them.
220        let exact = width as f64 * self.ratio;
221        let (end, edge) = if self.unicode {
222            let end = exact.floor() as usize;
223            let rest = exact - end as f64;
224            let edge = if end >= width {
225                None
226            } else if ascii {
227                match (rest * 2.0).round() as usize {
228                    0 => None,
229                    1 => Some('='),
230                    _ => Some('#'),
231                }
232            } else {
233                match (rest * 8.0).round() as usize {
234                    0 => None,
235                    8 => Some('█'),
236                    n => Some(EIGHTHS[n - 1]),
237                }
238            };
239            (end.min(width), edge)
240        } else {
241            ((exact.round() as usize).min(width), None)
242        };
243        let full = if ascii { '#' } else { '█' };
244
245        let text = label_text(&self.label_or_percent(), ascii);
246        let mut units = cell_units(&text);
247        units.truncate(width);
248        if units.last().is_some_and(|u| !u.is_empty()) && cells(units.last().unwrap()) > 1 {
249            // A wide character cut in two at the edge.
250            *units.last_mut().unwrap() = " ".to_string();
251        }
252        let label_col = (width - units.len()) / 2;
253        let label_row = height / 2;
254
255        (0..height)
256            .map(|row| {
257                let mut line = Line::new();
258                for col in 0..width {
259                    let on_bar = col < end;
260                    let pick = |s: &GaugeStyles, label: bool| -> Style {
261                        match (label, on_bar) {
262                            (true, true) => s.label_on_bar.clone(),
263                            (true, false) => s.label_on_rest.clone(),
264                            (false, true) => s.filled.clone(),
265                            (false, false) if col == end && edge.is_some() => s.filled.clone(),
266                            (false, false) => s.unfilled.clone(),
267                        }
268                    };
269                    let in_label =
270                        row == label_row && col >= label_col && col < label_col + units.len();
271                    if in_label {
272                        let unit = &units[col - label_col];
273                        // The second cell of a wide character is part of the
274                        // one before it.
275                        if !unit.is_empty() {
276                            line.push(unit, styles.as_ref().map(|s| pick(s, true)));
277                        }
278                        continue;
279                    }
280                    let glyph = if on_bar {
281                        full
282                    } else if col == end {
283                        edge.unwrap_or(' ')
284                    } else {
285                        ' '
286                    };
287                    line.push(&glyph.to_string(), styles.as_ref().map(|s| pick(s, false)));
288                }
289                line
290            })
291            .collect()
292    }
293}
294
295/// The four looks of a [`FillGauge`]'s cells.
296struct GaugeStyles {
297    filled: Style,
298    unfilled: Style,
299    label_on_bar: Style,
300    label_on_rest: Style,
301}
302
303impl crate::surface::Draw for FillGauge {
304    fn draw(
305        &self,
306        console: &Console,
307        options: &ConsoleOptions,
308        screen: &mut crate::surface::Screen,
309        rect: crate::surface::Rect,
310    ) {
311        super::draw_lines(
312            self.lines(console, options),
313            options.max_width,
314            options,
315            screen,
316            rect,
317        );
318    }
319}
320
321impl Renderable for FillGauge {
322    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
323        lines_to_segments(self.lines(console, options), options.max_width)
324    }
325
326    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
327        let max = self.width.unwrap_or(options.max_width);
328        Measurement::new(max.min(4), max)
329            .with_maximum(options.max_width)
330            .normalize()
331    }
332}
333
334/// The glyphs of a [`LineGauge`]'s line.
335#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
336pub enum LineSet {
337    /// `─` (ratatui's default).
338    #[default]
339    Thin,
340    /// `━`.
341    Thick,
342    /// `═`.
343    Double,
344}
345
346impl LineSet {
347    fn glyph(self, ascii: bool) -> char {
348        match self {
349            _ if ascii => '-',
350            LineSet::Thin => '─',
351            LineSet::Thick => '━',
352            LineSet::Double => '═',
353        }
354    }
355}
356
357/// A label and a line, the part up to the ratio in one style and the rest
358/// in another, as ratatui's `LineGauge`.
359///
360/// - The label comes first (default: the percentage, three wide: ` 42%`),
361///   then a space, then the line to the edge of the width it is given (or
362///   [`width`](Self::width)).
363/// - [`line`](Self::line) picks the glyphs (`─`, `━` or `═`), and
364///   [`symbols`](Self::symbols) any two of your own (`▰` and `▱`). An
365///   ASCII console gets `-`.
366/// - [`filled_style`](Self::filled_style) and
367///   [`unfilled_style`](Self::unfilled_style) default to
368///   `chart.gauge.filled` and `chart.gauge.unfilled`.
369///
370/// ```
371/// use rich::Console;
372/// use rich_ext::chart::{LineGauge, LineSet};
373///
374/// let console = Console::builder().width(30).color_system(None).build();
375/// let gauge = LineGauge::new(0.42).label("Downloading: 42%").line(LineSet::Thick);
376/// assert_eq!(console.render_export(&gauge), "Downloading: 42% ━━━━━━━━━━━━━\n");
377/// let plain = LineGauge::new(0.5).width(12);
378/// assert_eq!(console.render_export(&plain), " 50% ───────\n");
379/// ```
380#[derive(Clone, Debug, PartialEq)]
381#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
382pub struct LineGauge {
383    ratio: f64,
384    label: Option<String>,
385    label_style: Option<String>,
386    filled_style: Option<String>,
387    unfilled_style: Option<String>,
388    line: LineSet,
389    symbols: Option<(char, char)>,
390    width: Option<usize>,
391}
392
393impl LineGauge {
394    /// A gauge at `ratio`, from 0 to 1; values outside are clamped, and NaN
395    /// is 0.
396    pub fn new(ratio: f64) -> Self {
397        LineGauge {
398            ratio: clamp_ratio(ratio),
399            label: None,
400            label_style: None,
401            filled_style: None,
402            unfilled_style: None,
403            line: LineSet::Thin,
404            symbols: None,
405            width: None,
406        }
407    }
408
409    /// A gauge at `percent` percent (0 to 100).
410    pub fn percent(percent: f64) -> Self {
411        Self::new(percent / 100.0)
412    }
413
414    /// The ratio shown, from 0 to 1.
415    pub fn ratio(&self) -> f64 {
416        self.ratio
417    }
418
419    /// The label (default: the percentage, ` 42%`).
420    pub fn label(mut self, label: impl Into<String>) -> Self {
421        self.label = Some(label.into());
422        self
423    }
424
425    /// The label's style: a theme key or a definition (default
426    /// `chart.label`).
427    pub fn label_style(mut self, style: impl Into<String>) -> Self {
428        self.label_style = Some(style.into());
429        self
430    }
431
432    /// The style of the line up to the ratio (default
433    /// `chart.gauge.filled`).
434    pub fn filled_style(mut self, style: impl Into<String>) -> Self {
435        self.filled_style = Some(style.into());
436        self
437    }
438
439    /// The style of the rest of the line (default `chart.gauge.unfilled`).
440    pub fn unfilled_style(mut self, style: impl Into<String>) -> Self {
441        self.unfilled_style = Some(style.into());
442        self
443    }
444
445    /// The line's glyphs (default [`LineSet::Thin`]).
446    pub fn line(mut self, line: LineSet) -> Self {
447        self.line = line;
448        self
449    }
450
451    /// Glyphs of your own for the filled part and the rest, instead of a
452    /// [`LineSet`]; one that is not one cell wide, or not ASCII on an ASCII
453    /// console, falls back to the set's.
454    pub fn symbols(mut self, filled: char, unfilled: char) -> Self {
455        self.symbols = Some((filled, unfilled));
456        self
457    }
458
459    /// Width in cells (default: the width it is given).
460    pub fn width(mut self, width: usize) -> Self {
461        self.width = Some(width);
462        self
463    }
464
465    fn line_of(&self, console: &Console, options: &ConsoleOptions) -> Line {
466        let width = self
467            .width
468            .map_or(options.max_width, |w| w.min(options.max_width));
469        let ascii = ascii(console, options);
470        let colour = has_colour(console);
471        let style = |spec: &Option<String>, key: &str| {
472            colour.then(|| match spec {
473                Some(spec) => user_style(console, spec),
474                None => theme_style(console, key),
475            })
476        };
477        let set = self.line.glyph(ascii);
478        let fit = |c: char| {
479            (rich::cells::char_cell_width(c) == 1 && (!ascii || c.is_ascii_graphic())).then_some(c)
480        };
481        let (filled_glyph, unfilled_glyph) = match self.symbols {
482            Some((filled, unfilled)) => (fit(filled).unwrap_or(set), fit(unfilled).unwrap_or(set)),
483            None => (set, set),
484        };
485        let label = self
486            .label
487            .clone()
488            .unwrap_or_else(|| format!("{:3.0}%", self.ratio * 100.0));
489        let label = super::truncate(&label_text(&label, ascii), width, ascii);
490        let mut line = Line::new();
491        line.push(&label, style(&self.label_style, "chart.label"));
492        let start = line.width() + 1;
493        if start >= width {
494            return line;
495        }
496        line.pad(1);
497        let room = width - start;
498        let filled = ((room as f64) * self.ratio).floor() as usize;
499        line.push(
500            &filled_glyph.to_string().repeat(filled),
501            style(&self.filled_style, "chart.gauge.filled"),
502        );
503        line.push(
504            &unfilled_glyph.to_string().repeat(room - filled),
505            style(&self.unfilled_style, "chart.gauge.unfilled"),
506        );
507        line
508    }
509}
510
511impl crate::surface::Draw for LineGauge {
512    fn draw(
513        &self,
514        console: &Console,
515        options: &ConsoleOptions,
516        screen: &mut crate::surface::Screen,
517        rect: crate::surface::Rect,
518    ) {
519        super::draw_lines(
520            vec![self.line_of(console, options)],
521            options.max_width,
522            options,
523            screen,
524            rect,
525        );
526    }
527}
528
529impl Renderable for LineGauge {
530    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
531        lines_to_segments(vec![self.line_of(console, options)], options.max_width)
532    }
533
534    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
535        let max = self.width.unwrap_or(options.max_width);
536        Measurement::new(max.min(4), max)
537            .with_maximum(options.max_width)
538            .normalize()
539    }
540}
541
542/// `Gauge 42 of 100 GB: 42 percent`: the label, if it has one, and how full.
543fn gauge_text(label: Option<&str>, ratio: f64) -> String {
544    let percent = super::describe::percent(ratio);
545    match label.map(str::trim).filter(|l| !l.is_empty()) {
546        Some(label) => format!("Gauge {label}: {percent}"),
547        None => format!("Gauge: {percent}"),
548    }
549}
550
551/// `Gauge: 42 percent`, or `Gauge <label>: 42 percent` with a label.
552impl crate::a11y::AccessibleText for FillGauge {
553    fn accessible_text(&self, _width: usize) -> String {
554        gauge_text(self.label.as_deref(), self.ratio)
555    }
556}
557
558/// `Gauge: 42 percent`, or `Gauge <label>: 42 percent` with a label.
559impl crate::a11y::AccessibleText for LineGauge {
560    fn accessible_text(&self, _width: usize) -> String {
561        gauge_text(self.label.as_deref(), self.ratio)
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn accessible_text_says_how_full() {
571        use crate::a11y::AccessibleText;
572        assert_eq!(
573            FillGauge::new(0.424).accessible_text(80),
574            "Gauge: 42 percent"
575        );
576        assert_eq!(
577            FillGauge::percent(50.0)
578                .label("50/100 GB")
579                .accessible_text(80),
580            "Gauge 50/100 GB: 50 percent"
581        );
582        assert_eq!(
583            LineGauge::new(1.0).label("done").accessible_text(80),
584            "Gauge done: 100 percent"
585        );
586    }
587
588    fn plain(width: usize) -> Console {
589        Console::builder().width(width).color_system(None).build()
590    }
591
592    fn coloured(width: usize) -> Console {
593        Console::builder()
594            .width(width)
595            .force_terminal(true)
596            .color_system(Some(rich::ColorSystem::Truecolor))
597            .no_color(false)
598            .build()
599    }
600
601    fn rows(console: &Console, r: &dyn Renderable) -> Vec<String> {
602        console
603            .render_export(r)
604            .lines()
605            .map(str::to_string)
606            .collect()
607    }
608
609    #[test]
610    fn fill_gauges_fill_whole_cells_or_eighths() {
611        let gauge = |ratio: f64| FillGauge::new(ratio).label("");
612        // 0.42 of 10 cells is 4.2: four whole cells, rounded.
613        assert_eq!(rows(&plain(10), &gauge(0.42)), ["████      "]);
614        assert_eq!(rows(&plain(10), &gauge(0.46)), ["█████     "]);
615        // In eighths, 4.2 cells are four and a quarter.
616        assert_eq!(rows(&plain(10), &gauge(0.42).unicode(true)), ["████▎     "]);
617        assert_eq!(rows(&plain(10), &gauge(1.0).unicode(true)), ["██████████"]);
618        assert_eq!(rows(&plain(10), &gauge(0.0).unicode(true)), ["          "]);
619        // Out of range and NaN are clamped.
620        assert_eq!(gauge(7.0).ratio(), 1.0);
621        assert_eq!(gauge(-1.0).ratio(), 0.0);
622        assert_eq!(gauge(f64::NAN).ratio(), 0.0);
623    }
624
625    #[test]
626    fn fill_gauges_at_tiny_widths() {
627        for (width, want) in [(0, ""), (1, "4"), (2, "42"), (3, "42%")] {
628            let console = plain(width.max(1));
629            let gauge = FillGauge::new(0.42).width(width);
630            let out = console.render_export(&gauge);
631            assert_eq!(out.trim_end_matches('\n'), want, "width {width}");
632        }
633        // Wider: the label is centred, over the bar.
634        assert_eq!(rows(&plain(4), &FillGauge::new(0.5)), ["50% "]);
635        assert_eq!(rows(&plain(5), &FillGauge::new(0.5)), ["█50% "]);
636    }
637
638    #[test]
639    fn fill_gauges_take_the_height_given() {
640        let console = plain(10);
641        let options = console.options().update_height(5);
642        let lines = console.render_lines(&FillGauge::new(0.3), &options, false);
643        assert_eq!(lines.len(), 5);
644        // The label is on the middle row.
645        let text: Vec<String> = lines
646            .iter()
647            .map(|l| l.iter().map(|s| s.text.as_str()).collect())
648            .collect();
649        assert_eq!(text[2], "███30%    ");
650        assert_eq!(text[0], "███       ");
651        // An explicit height wins.
652        let lines = console.render_lines(&FillGauge::new(0.3).height(2), &options, false);
653        assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 2);
654    }
655
656    #[test]
657    fn the_label_swaps_its_colours_over_the_bar() {
658        let console = coloured(10);
659        let gauge = FillGauge::new(0.5).style("magenta on black italic bold");
660        let lines = console.render_lines(&gauge, &console.options(), false);
661        let at = |col: usize| -> (String, Option<Style>) {
662            let mut seen = 0;
663            for s in &lines[0] {
664                let n = cells(&s.text);
665                if col < seen + n {
666                    return (s.text.to_string(), s.style.clone());
667                }
668                seen += n;
669            }
670            panic!("no cell {col}")
671        };
672        // "█████" then "  " ... the label "50%" at columns 3 to 5.
673        assert_eq!(at(0).1, Style::parse("magenta on black italic bold").ok());
674        assert_eq!(at(3).1, Style::parse("black on magenta italic bold").ok());
675        assert_eq!(at(5).1, Style::parse("magenta on black italic bold").ok());
676        assert_eq!(at(9).1, Style::parse("magenta on black italic bold").ok());
677        // Without a background colour, the label is in the default colour.
678        let gauge = FillGauge::new(0.5).style("green");
679        let lines = console.render_lines(&gauge, &console.options(), false);
680        let style = lines[0]
681            .iter()
682            .find(|s| s.text.contains('5'))
683            .and_then(|s| s.style.clone())
684            .unwrap();
685        assert_eq!(style.bgcolor(), Style::parse("on green").unwrap().bgcolor());
686        assert!(style.color().is_some_and(|c| c.is_default()));
687    }
688
689    #[test]
690    fn ascii_consoles_draw_hashes_and_halves() {
691        let console = Console::builder()
692            .width(10)
693            .color_system(None)
694            .ascii_only(true)
695            .build();
696        let gauge = FillGauge::new(0.45).label("").unicode(true);
697        assert_eq!(console.render_export(&gauge), "####=     \n");
698        let gauge = FillGauge::new(0.45).label("é");
699        assert_eq!(console.render_export(&gauge), "####?     \n");
700        let line = LineGauge::new(0.5).line(LineSet::Double);
701        assert_eq!(console.render_export(&line), " 50% -----\n");
702    }
703
704    #[test]
705    fn line_gauges_fill_after_the_label() {
706        let console = plain(20);
707        assert_eq!(
708            rows(&console, &LineGauge::new(0.5).label("cpu")),
709            ["cpu ────────────────"]
710        );
711        // Floor, as ratatui: 16 cells at 0.99 is 15 filled.
712        let gauge = LineGauge::new(0.99).label("cpu").symbols('▰', '▱');
713        assert_eq!(rows(&console, &gauge), ["cpu ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱"]);
714        // No room for a line: the label alone, cut to the width.
715        assert_eq!(
716            rows(&plain(4), &LineGauge::new(0.5).label("download")),
717            ["dow…"]
718        );
719        assert_eq!(rows(&plain(5), &LineGauge::new(0.0)), ["  0%"]);
720        assert_eq!(rows(&plain(6), &LineGauge::new(1.0)), ["100% ─"]);
721        // A wide symbol falls back to the set's glyph.
722        let wide = LineGauge::new(0.5).label("").symbols('日', '▱').width(5);
723        assert_eq!(rows(&console, &wide), [" ──▱▱"]);
724    }
725
726    #[test]
727    fn line_gauges_style_the_two_parts() {
728        let console = coloured(9);
729        let gauge = LineGauge::new(0.5)
730            .label("ab")
731            .filled_style("red")
732            .unfilled_style("blue")
733            .label_style("bold");
734        let lines = console.render_lines(&gauge, &console.options(), false);
735        let runs: Vec<(String, Option<Style>)> = lines[0]
736            .iter()
737            .map(|s| (s.text.to_string(), s.style.clone()))
738            .collect();
739        assert_eq!(
740            runs,
741            vec![
742                ("ab".to_string(), Style::parse("bold").ok()),
743                (" ".to_string(), None),
744                ("───".to_string(), Style::parse("red").ok()),
745                ("───".to_string(), Style::parse("blue").ok()),
746            ]
747        );
748    }
749}