1use 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
13const EIGHTHS: [char; 7] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'];
15
16fn 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
25fn ascii(console: &Console, options: &ConsoleOptions) -> bool {
27 Charset::Auto.resolve(console, options, Charset::Blocks) == Charset::Ascii
28}
29
30fn 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
39fn 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
58fn front(style: &Style) -> Style {
60 style
61 .without_color()
62 .combine(&Style::from_color(style.color().cloned(), None))
63}
64
65#[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 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 pub fn percent(percent: f64) -> Self {
129 Self::new(percent / 100.0)
130 }
131
132 pub fn ratio(&self) -> f64 {
134 self.ratio
135 }
136
137 pub fn label(mut self, label: impl Into<String>) -> Self {
140 self.label = Some(label.into());
141 self
142 }
143
144 pub fn label_style(mut self, style: impl Into<String>) -> Self {
147 self.label_style = Some(style.into());
148 self
149 }
150
151 pub fn style(mut self, style: impl Into<String>) -> Self {
155 self.style = Some(style.into());
156 self
157 }
158
159 pub fn unicode(mut self, unicode: bool) -> Self {
162 self.unicode = unicode;
163 self
164 }
165
166 pub fn width(mut self, width: usize) -> Self {
168 self.width = Some(width);
169 self
170 }
171
172 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 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 *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 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
295struct 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
336pub enum LineSet {
337 #[default]
339 Thin,
340 Thick,
342 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#[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 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 pub fn percent(percent: f64) -> Self {
411 Self::new(percent / 100.0)
412 }
413
414 pub fn ratio(&self) -> f64 {
416 self.ratio
417 }
418
419 pub fn label(mut self, label: impl Into<String>) -> Self {
421 self.label = Some(label.into());
422 self
423 }
424
425 pub fn label_style(mut self, style: impl Into<String>) -> Self {
428 self.label_style = Some(style.into());
429 self
430 }
431
432 pub fn filled_style(mut self, style: impl Into<String>) -> Self {
435 self.filled_style = Some(style.into());
436 self
437 }
438
439 pub fn unfilled_style(mut self, style: impl Into<String>) -> Self {
441 self.unfilled_style = Some(style.into());
442 self
443 }
444
445 pub fn line(mut self, line: LineSet) -> Self {
447 self.line = line;
448 self
449 }
450
451 pub fn symbols(mut self, filled: char, unfilled: char) -> Self {
455 self.symbols = Some((filled, unfilled));
456 self
457 }
458
459 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
542fn 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
551impl 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
558impl 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 assert_eq!(rows(&plain(10), &gauge(0.42)), ["████ "]);
614 assert_eq!(rows(&plain(10), &gauge(0.46)), ["█████ "]);
615 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 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 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 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 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 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 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 let gauge = LineGauge::new(0.99).label("cpu").symbols('▰', '▱');
713 assert_eq!(rows(&console, &gauge), ["cpu ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱"]);
714 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 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}