use rich::measure::Measurement;
use rich::{Color, Console, ConsoleOptions, Renderable, Segment, Style};
use super::{
Charset, Line, cell_units, cells, has_colour, lines_to_segments, theme_style, user_style,
};
use crate::fidelity::ascii_text;
const EIGHTHS: [char; 7] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'];
fn clamp_ratio(ratio: f64) -> f64 {
if ratio.is_nan() {
0.0
} else {
ratio.clamp(0.0, 1.0)
}
}
fn ascii(console: &Console, options: &ConsoleOptions) -> bool {
Charset::Auto.resolve(console, options, Charset::Blocks) == Charset::Ascii
}
fn label_text(text: &str, ascii: bool) -> String {
if ascii && !text.is_ascii() {
ascii_text(text)
} else {
text.to_string()
}
}
fn swapped(style: &Style) -> Style {
match style.color() {
Some(front) => {
let back = style
.bgcolor()
.cloned()
.unwrap_or_else(Color::default_color);
style
.without_color()
.combine(&Style::from_color(Some(back), Some(front.clone())))
}
None => style.combine(&Style::parse("reverse").unwrap_or_default()),
}
}
fn front(style: &Style) -> Style {
style
.without_color()
.combine(&Style::from_color(style.color().cloned(), None))
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct FillGauge {
ratio: f64,
label: Option<String>,
label_style: Option<String>,
style: Option<String>,
unicode: bool,
width: Option<usize>,
height: Option<usize>,
}
impl FillGauge {
pub fn new(ratio: f64) -> Self {
FillGauge {
ratio: clamp_ratio(ratio),
label: None,
label_style: None,
style: None,
unicode: false,
width: None,
height: None,
}
}
pub fn percent(percent: f64) -> Self {
Self::new(percent / 100.0)
}
pub fn ratio(&self) -> f64 {
self.ratio
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn label_style(mut self, style: impl Into<String>) -> Self {
self.label_style = Some(style.into());
self
}
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn unicode(mut self, unicode: bool) -> Self {
self.unicode = unicode;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, rows: usize) -> Self {
self.height = Some(rows.min(u16::MAX as usize));
self
}
fn label_or_percent(&self) -> String {
self.label
.clone()
.unwrap_or_else(|| format!("{}%", (self.ratio * 100.0).round()))
}
fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
let width = self
.width
.map_or(options.max_width, |w| w.min(options.max_width));
let height = self
.height
.or(options.height)
.unwrap_or(1)
.min(u16::MAX as usize);
let ascii = ascii(console, options);
let colour = has_colour(console);
let (filled, unfilled) = match &self.style {
Some(spec) => {
let style = user_style(console, spec);
(style.clone(), style)
}
None => (
theme_style(console, "chart.gauge.filled"),
theme_style(console, "chart.gauge.unfilled"),
),
};
let extra = self
.label_style
.as_deref()
.map(|spec| user_style(console, spec))
.unwrap_or_default();
let styles = colour.then(|| GaugeStyles {
label_on_bar: swapped(&filled).combine(&extra),
label_on_rest: unfilled.combine(&front(&filled)).combine(&extra),
filled,
unfilled,
});
let exact = width as f64 * self.ratio;
let (end, edge) = if self.unicode {
let end = exact.floor() as usize;
let rest = exact - end as f64;
let edge = if end >= width {
None
} else if ascii {
match (rest * 2.0).round() as usize {
0 => None,
1 => Some('='),
_ => Some('#'),
}
} else {
match (rest * 8.0).round() as usize {
0 => None,
8 => Some('█'),
n => Some(EIGHTHS[n - 1]),
}
};
(end.min(width), edge)
} else {
((exact.round() as usize).min(width), None)
};
let full = if ascii { '#' } else { '█' };
let text = label_text(&self.label_or_percent(), ascii);
let mut units = cell_units(&text);
units.truncate(width);
if units.last().is_some_and(|u| !u.is_empty()) && cells(units.last().unwrap()) > 1 {
*units.last_mut().unwrap() = " ".to_string();
}
let label_col = (width - units.len()) / 2;
let label_row = height / 2;
(0..height)
.map(|row| {
let mut line = Line::new();
for col in 0..width {
let on_bar = col < end;
let pick = |s: &GaugeStyles, label: bool| -> Style {
match (label, on_bar) {
(true, true) => s.label_on_bar.clone(),
(true, false) => s.label_on_rest.clone(),
(false, true) => s.filled.clone(),
(false, false) if col == end && edge.is_some() => s.filled.clone(),
(false, false) => s.unfilled.clone(),
}
};
let in_label =
row == label_row && col >= label_col && col < label_col + units.len();
if in_label {
let unit = &units[col - label_col];
if !unit.is_empty() {
line.push(unit, styles.as_ref().map(|s| pick(s, true)));
}
continue;
}
let glyph = if on_bar {
full
} else if col == end {
edge.unwrap_or(' ')
} else {
' '
};
line.push(&glyph.to_string(), styles.as_ref().map(|s| pick(s, false)));
}
line
})
.collect()
}
}
struct GaugeStyles {
filled: Style,
unfilled: Style,
label_on_bar: Style,
label_on_rest: Style,
}
impl crate::surface::Draw for FillGauge {
fn draw(
&self,
console: &Console,
options: &ConsoleOptions,
screen: &mut crate::surface::Screen,
rect: crate::surface::Rect,
) {
super::draw_lines(
self.lines(console, options),
options.max_width,
options,
screen,
rect,
);
}
}
impl Renderable for FillGauge {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
lines_to_segments(self.lines(console, options), options.max_width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let max = self.width.unwrap_or(options.max_width);
Measurement::new(max.min(4), max)
.with_maximum(options.max_width)
.normalize()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum LineSet {
#[default]
Thin,
Thick,
Double,
}
impl LineSet {
fn glyph(self, ascii: bool) -> char {
match self {
_ if ascii => '-',
LineSet::Thin => '─',
LineSet::Thick => '━',
LineSet::Double => '═',
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct LineGauge {
ratio: f64,
label: Option<String>,
label_style: Option<String>,
filled_style: Option<String>,
unfilled_style: Option<String>,
line: LineSet,
symbols: Option<(char, char)>,
width: Option<usize>,
}
impl LineGauge {
pub fn new(ratio: f64) -> Self {
LineGauge {
ratio: clamp_ratio(ratio),
label: None,
label_style: None,
filled_style: None,
unfilled_style: None,
line: LineSet::Thin,
symbols: None,
width: None,
}
}
pub fn percent(percent: f64) -> Self {
Self::new(percent / 100.0)
}
pub fn ratio(&self) -> f64 {
self.ratio
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn label_style(mut self, style: impl Into<String>) -> Self {
self.label_style = Some(style.into());
self
}
pub fn filled_style(mut self, style: impl Into<String>) -> Self {
self.filled_style = Some(style.into());
self
}
pub fn unfilled_style(mut self, style: impl Into<String>) -> Self {
self.unfilled_style = Some(style.into());
self
}
pub fn line(mut self, line: LineSet) -> Self {
self.line = line;
self
}
pub fn symbols(mut self, filled: char, unfilled: char) -> Self {
self.symbols = Some((filled, unfilled));
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
fn line_of(&self, console: &Console, options: &ConsoleOptions) -> Line {
let width = self
.width
.map_or(options.max_width, |w| w.min(options.max_width));
let ascii = ascii(console, options);
let colour = has_colour(console);
let style = |spec: &Option<String>, key: &str| {
colour.then(|| match spec {
Some(spec) => user_style(console, spec),
None => theme_style(console, key),
})
};
let set = self.line.glyph(ascii);
let fit = |c: char| {
(rich::cells::char_cell_width(c) == 1 && (!ascii || c.is_ascii_graphic())).then_some(c)
};
let (filled_glyph, unfilled_glyph) = match self.symbols {
Some((filled, unfilled)) => (fit(filled).unwrap_or(set), fit(unfilled).unwrap_or(set)),
None => (set, set),
};
let label = self
.label
.clone()
.unwrap_or_else(|| format!("{:3.0}%", self.ratio * 100.0));
let label = super::truncate(&label_text(&label, ascii), width, ascii);
let mut line = Line::new();
line.push(&label, style(&self.label_style, "chart.label"));
let start = line.width() + 1;
if start >= width {
return line;
}
line.pad(1);
let room = width - start;
let filled = ((room as f64) * self.ratio).floor() as usize;
line.push(
&filled_glyph.to_string().repeat(filled),
style(&self.filled_style, "chart.gauge.filled"),
);
line.push(
&unfilled_glyph.to_string().repeat(room - filled),
style(&self.unfilled_style, "chart.gauge.unfilled"),
);
line
}
}
impl crate::surface::Draw for LineGauge {
fn draw(
&self,
console: &Console,
options: &ConsoleOptions,
screen: &mut crate::surface::Screen,
rect: crate::surface::Rect,
) {
super::draw_lines(
vec![self.line_of(console, options)],
options.max_width,
options,
screen,
rect,
);
}
}
impl Renderable for LineGauge {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
lines_to_segments(vec![self.line_of(console, options)], options.max_width)
}
fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
let max = self.width.unwrap_or(options.max_width);
Measurement::new(max.min(4), max)
.with_maximum(options.max_width)
.normalize()
}
}
fn gauge_text(label: Option<&str>, ratio: f64) -> String {
let percent = super::describe::percent(ratio);
match label.map(str::trim).filter(|l| !l.is_empty()) {
Some(label) => format!("Gauge {label}: {percent}"),
None => format!("Gauge: {percent}"),
}
}
impl crate::a11y::AccessibleText for FillGauge {
fn accessible_text(&self, _width: usize) -> String {
gauge_text(self.label.as_deref(), self.ratio)
}
}
impl crate::a11y::AccessibleText for LineGauge {
fn accessible_text(&self, _width: usize) -> String {
gauge_text(self.label.as_deref(), self.ratio)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accessible_text_says_how_full() {
use crate::a11y::AccessibleText;
assert_eq!(
FillGauge::new(0.424).accessible_text(80),
"Gauge: 42 percent"
);
assert_eq!(
FillGauge::percent(50.0)
.label("50/100 GB")
.accessible_text(80),
"Gauge 50/100 GB: 50 percent"
);
assert_eq!(
LineGauge::new(1.0).label("done").accessible_text(80),
"Gauge done: 100 percent"
);
}
fn plain(width: usize) -> Console {
Console::builder().width(width).color_system(None).build()
}
fn coloured(width: usize) -> Console {
Console::builder()
.width(width)
.force_terminal(true)
.color_system(Some(rich::ColorSystem::Truecolor))
.no_color(false)
.build()
}
fn rows(console: &Console, r: &dyn Renderable) -> Vec<String> {
console
.render_export(r)
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn fill_gauges_fill_whole_cells_or_eighths() {
let gauge = |ratio: f64| FillGauge::new(ratio).label("");
assert_eq!(rows(&plain(10), &gauge(0.42)), ["████ "]);
assert_eq!(rows(&plain(10), &gauge(0.46)), ["█████ "]);
assert_eq!(rows(&plain(10), &gauge(0.42).unicode(true)), ["████▎ "]);
assert_eq!(rows(&plain(10), &gauge(1.0).unicode(true)), ["██████████"]);
assert_eq!(rows(&plain(10), &gauge(0.0).unicode(true)), [" "]);
assert_eq!(gauge(7.0).ratio(), 1.0);
assert_eq!(gauge(-1.0).ratio(), 0.0);
assert_eq!(gauge(f64::NAN).ratio(), 0.0);
}
#[test]
fn fill_gauges_at_tiny_widths() {
for (width, want) in [(0, ""), (1, "4"), (2, "42"), (3, "42%")] {
let console = plain(width.max(1));
let gauge = FillGauge::new(0.42).width(width);
let out = console.render_export(&gauge);
assert_eq!(out.trim_end_matches('\n'), want, "width {width}");
}
assert_eq!(rows(&plain(4), &FillGauge::new(0.5)), ["50% "]);
assert_eq!(rows(&plain(5), &FillGauge::new(0.5)), ["█50% "]);
}
#[test]
fn fill_gauges_take_the_height_given() {
let console = plain(10);
let options = console.options().update_height(5);
let lines = console.render_lines(&FillGauge::new(0.3), &options, false);
assert_eq!(lines.len(), 5);
let text: Vec<String> = lines
.iter()
.map(|l| l.iter().map(|s| s.text.as_str()).collect())
.collect();
assert_eq!(text[2], "███30% ");
assert_eq!(text[0], "███ ");
let lines = console.render_lines(&FillGauge::new(0.3).height(2), &options, false);
assert_eq!(lines.iter().filter(|l| !l.is_empty()).count(), 2);
}
#[test]
fn the_label_swaps_its_colours_over_the_bar() {
let console = coloured(10);
let gauge = FillGauge::new(0.5).style("magenta on black italic bold");
let lines = console.render_lines(&gauge, &console.options(), false);
let at = |col: usize| -> (String, Option<Style>) {
let mut seen = 0;
for s in &lines[0] {
let n = cells(&s.text);
if col < seen + n {
return (s.text.to_string(), s.style.clone());
}
seen += n;
}
panic!("no cell {col}")
};
assert_eq!(at(0).1, Style::parse("magenta on black italic bold").ok());
assert_eq!(at(3).1, Style::parse("black on magenta italic bold").ok());
assert_eq!(at(5).1, Style::parse("magenta on black italic bold").ok());
assert_eq!(at(9).1, Style::parse("magenta on black italic bold").ok());
let gauge = FillGauge::new(0.5).style("green");
let lines = console.render_lines(&gauge, &console.options(), false);
let style = lines[0]
.iter()
.find(|s| s.text.contains('5'))
.and_then(|s| s.style.clone())
.unwrap();
assert_eq!(style.bgcolor(), Style::parse("on green").unwrap().bgcolor());
assert!(style.color().is_some_and(|c| c.is_default()));
}
#[test]
fn ascii_consoles_draw_hashes_and_halves() {
let console = Console::builder()
.width(10)
.color_system(None)
.ascii_only(true)
.build();
let gauge = FillGauge::new(0.45).label("").unicode(true);
assert_eq!(console.render_export(&gauge), "####= \n");
let gauge = FillGauge::new(0.45).label("é");
assert_eq!(console.render_export(&gauge), "####? \n");
let line = LineGauge::new(0.5).line(LineSet::Double);
assert_eq!(console.render_export(&line), " 50% -----\n");
}
#[test]
fn line_gauges_fill_after_the_label() {
let console = plain(20);
assert_eq!(
rows(&console, &LineGauge::new(0.5).label("cpu")),
["cpu ────────────────"]
);
let gauge = LineGauge::new(0.99).label("cpu").symbols('▰', '▱');
assert_eq!(rows(&console, &gauge), ["cpu ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▱"]);
assert_eq!(
rows(&plain(4), &LineGauge::new(0.5).label("download")),
["dow…"]
);
assert_eq!(rows(&plain(5), &LineGauge::new(0.0)), [" 0%"]);
assert_eq!(rows(&plain(6), &LineGauge::new(1.0)), ["100% ─"]);
let wide = LineGauge::new(0.5).label("").symbols('日', '▱').width(5);
assert_eq!(rows(&console, &wide), [" ──▱▱"]);
}
#[test]
fn line_gauges_style_the_two_parts() {
let console = coloured(9);
let gauge = LineGauge::new(0.5)
.label("ab")
.filled_style("red")
.unfilled_style("blue")
.label_style("bold");
let lines = console.render_lines(&gauge, &console.options(), false);
let runs: Vec<(String, Option<Style>)> = lines[0]
.iter()
.map(|s| (s.text.to_string(), s.style.clone()))
.collect();
assert_eq!(
runs,
vec![
("ab".to_string(), Style::parse("bold").ok()),
(" ".to_string(), None),
("───".to_string(), Style::parse("red").ok()),
("───".to_string(), Style::parse("blue").ok()),
]
);
}
}