use rich::measure::Measurement;
use rich::{Console, ConsoleOptions, Renderable, Segment};
use super::{
Charset, Line, Sparkline, Status, ValueFormat, cells, has_colour, lines_to_segments,
theme_style, truncate,
};
const MIN_BORDERED: usize = 5;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Delta {
Absolute(f64),
Percent(f64),
}
impl Delta {
fn value(self) -> f64 {
match self {
Delta::Absolute(v) | Delta::Percent(v) => v,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct KpiCard {
label: String,
value: f64,
format: ValueFormat,
unit: String,
delta: Option<Delta>,
caption: Option<String>,
higher_is_better: bool,
sparkline: Option<Sparkline>,
status: Option<Status>,
width: Option<usize>,
expand: bool,
border: bool,
charset: Charset,
}
impl KpiCard {
pub fn new(label: impl Into<String>, value: f64) -> Self {
KpiCard {
label: label.into(),
value,
format: ValueFormat::Compact,
unit: String::new(),
delta: None,
caption: None,
higher_is_better: true,
sparkline: None,
status: None,
width: None,
expand: false,
border: true,
charset: Charset::Auto,
}
}
pub fn format(mut self, format: ValueFormat) -> Self {
self.format = format;
self
}
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = unit.into();
self
}
pub fn delta(mut self, delta: f64) -> Self {
self.delta = Some(Delta::Absolute(delta));
self
}
pub fn delta_percent(mut self, percent: f64) -> Self {
self.delta = Some(Delta::Percent(percent));
self
}
pub fn previous(mut self, previous: f64) -> Self {
let change = self.value - previous;
self.delta = Some(if previous != 0.0 && previous.is_finite() {
Delta::Percent(change / previous.abs() * 100.0)
} else {
Delta::Absolute(change)
});
self
}
pub fn caption(mut self, caption: impl Into<String>) -> Self {
self.caption = Some(caption.into());
self
}
pub fn higher_is_better(mut self, higher: bool) -> Self {
self.higher_is_better = higher;
self
}
pub fn sparkline(mut self, sparkline: Sparkline) -> Self {
self.sparkline = Some(sparkline);
self
}
pub fn trend(self, values: impl IntoIterator<Item = f64>) -> Self {
self.sparkline(Sparkline::new(values))
}
pub fn status(mut self, status: Status) -> Self {
self.status = Some(status);
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width.max(1));
self
}
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
pub fn border(mut self, border: bool) -> Self {
self.border = border;
self
}
pub fn charset(mut self, charset: Charset) -> Self {
self.charset = charset;
self
}
fn value_text(&self) -> String {
let value = self.format.format(self.value);
if self.value.is_finite() {
format!("{value}{}", self.unit)
} else {
value
}
}
fn delta_text(&self, ascii: bool) -> Option<String> {
let delta = self.delta?;
let v = delta.value();
let direction = v.is_finite().then(|| v.partial_cmp(&0.0)).flatten();
let arrow = match (direction, ascii) {
(Some(std::cmp::Ordering::Greater), false) => "▲",
(Some(std::cmp::Ordering::Greater), true) => "^",
(Some(std::cmp::Ordering::Less), false) => "▼",
(Some(std::cmp::Ordering::Less), true) => "v",
_ => "=",
};
let number = match delta {
Delta::Absolute(v) => self.format.format(v),
Delta::Percent(v) => {
let text = ValueFormat::Compact.format(v);
if v.is_finite() {
format!("{text}%")
} else {
text
}
}
};
let sign = if v > 0.0 && v.is_finite() && number != "0" && number != "0%" {
"+"
} else {
""
};
Some(format!("{arrow} {sign}{number}"))
}
fn delta_key(&self) -> &'static str {
let v = self.delta.map_or(0.0, Delta::value);
if !v.is_finite() || v == 0.0 {
"chart.delta.flat"
} else if (v > 0.0) == self.higher_is_better {
"chart.delta.good"
} else {
"chart.delta.bad"
}
}
fn charsets(&self, console: &Console, options: &ConsoleOptions) -> (bool, Option<Charset>) {
let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
let spark = self.sparkline.as_ref().map(|s| {
if ascii {
Charset::Ascii
} else {
s.resolved_charset(console, options)
}
});
(ascii, spark)
}
fn inner_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
let header = cells(&self.label)
+ self.status.map_or(0, |s| {
cells(&s.text(ascii)) + usize::from(!self.label.is_empty())
});
let delta = match (self.delta_text(ascii), &self.caption) {
(Some(d), Some(c)) => cells(&d) + 1 + cells(c),
(Some(d), None) => cells(&d),
(None, Some(c)) => cells(c),
(None, None) => 0,
};
let spark = match (&self.sparkline, spark) {
(Some(s), Some(charset)) => s.natural_width(charset),
_ => 0,
};
header
.max(cells(&self.value_text()))
.max(delta)
.max(spark)
.max(1)
}
fn natural_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
self.inner_width(ascii, spark) + if self.border { 4 } else { 0 }
}
fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
let given = options.max_width;
let (ascii, spark) = self.charsets(console, options);
let colour = has_colour(console);
let total = if self.expand {
given
} else {
self.width
.unwrap_or_else(|| self.natural_width(ascii, spark))
.min(given)
};
let border = self.border && total >= MIN_BORDERED;
let inner = if border { total - 4 } else { total };
let style = |key: &str| colour.then(|| theme_style(console, key));
let mut content = Vec::new();
let mut header = Line::new();
let status = self.status.map(|s| truncate(&s.text(ascii), inner, ascii));
let status_w = status.as_deref().map_or(0, cells);
let label_room = inner.saturating_sub(status_w + usize::from(status_w > 0));
let label = truncate(&self.label, label_room, ascii);
header.push(&label, style("chart.kpi.label"));
if let (Some(text), Some(s)) = (&status, self.status) {
header.pad(inner - cells(&label) - status_w);
header.push(text, style(s.key()));
}
content.push(header);
let mut value = Line::new();
value.push(
&truncate(&self.value_text(), inner, ascii),
style("chart.kpi.value"),
);
content.push(value);
let delta = self.delta_text(ascii);
if delta.is_some() || self.caption.is_some() {
let mut line = Line::new();
if let Some(d) = &delta {
line.push(&truncate(d, inner, ascii), style(self.delta_key()));
}
if let Some(caption) = &self.caption {
let room = inner.saturating_sub(line.width() + usize::from(line.width() > 0));
if room > 0 {
if line.width() > 0 {
line.pad(1);
}
line.push(&truncate(caption, room, ascii), style("chart.label"));
}
}
content.push(line);
}
if let (Some(s), Some(charset)) = (&self.sparkline, spark) {
content.push(s.line(console, charset, inner));
}
if !border {
for line in &mut content {
line.crop(inner);
let fill = inner - line.width();
line.pad(fill);
}
return content;
}
let edge = style("chart.kpi.border");
let (tl, tr, bl, br, h, v) = if ascii {
('+', '+', '+', '+', '-', '|')
} else {
('╭', '╮', '╰', '╯', '─', '│')
};
let rule = |l: char, r: char| {
let mut line = Line::new();
line.push(
&format!("{l}{}{r}", h.to_string().repeat(total - 2)),
edge.clone(),
);
line
};
let mut out = vec![rule(tl, tr)];
for mut body in content {
body.crop(inner);
let fill = inner - body.width();
body.pad(fill);
let mut line = Line::new();
line.push(&format!("{v} "), edge.clone());
line.append(body);
line.push(&format!(" {v}"), edge.clone());
out.push(line);
}
out.push(rule(bl, br));
out
}
}
impl crate::surface::Draw for KpiCard {
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 KpiCard {
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 (ascii, spark) = self.charsets(console, options);
let max = if self.expand {
options.max_width
} else {
self.width
.unwrap_or_else(|| self.natural_width(ascii, spark))
};
let min = self.width.unwrap_or(if self.border { 8 } else { 4 });
Measurement::new(min.min(max), max)
.with_maximum(options.max_width)
.normalize()
}
}
impl crate::a11y::AccessibleText for KpiCard {
fn accessible_text(&self, width: usize) -> String {
let what = if self.label.is_empty() {
"KPI".to_string()
} else {
format!("KPI {}", self.label)
};
let mut parts = vec![self.value_text()];
if let Some(delta) = self.delta {
let v = delta.value();
let mut change = if !v.is_finite() {
"change unknown".to_string()
} else if v == 0.0 {
"unchanged".to_string()
} else {
let text = self
.delta_text(true)
.unwrap_or_default()
.split_once(' ')
.map(|(_, n)| n.trim_start_matches(['+', '-']).to_string())
.unwrap_or_default();
format!("{} {text}", if v > 0.0 { "up" } else { "down" })
};
if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
change.push(' ');
change.push_str(caption);
}
parts.push(change);
if v.is_finite() && v != 0.0 {
let better = (v > 0.0) == self.higher_is_better;
parts.push(if better { "better" } else { "worse" }.to_string());
}
} else if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
parts.push(caption.to_string());
}
if let Some(status) = self.status {
parts.push(format!("status {}", status.word()));
}
let mut out = format!("{what}: {}", parts.join(", "));
if let Some(spark) = &self.sparkline {
out.push('\n');
out.push_str(&crate::a11y::AccessibleText::accessible_text(spark, width));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::AccessibleText;
#[test]
fn accessible_text_says_the_change_in_words() {
let card = KpiCard::new("Errors", 42.0)
.previous(56.0)
.higher_is_better(false)
.caption("vs yesterday")
.status(Status::Warning);
assert_eq!(
card.accessible_text(80),
"KPI Errors: 42, down 25% vs yesterday, better, status warning"
);
let trend = KpiCard::new("Requests", 12_400.0)
.unit("/s")
.delta_percent(8.49)
.trend([1.0, 3.0, 2.0, 5.0]);
assert_eq!(
trend.accessible_text(80),
"KPI Requests: 12.4k/s, up 8.49%, better\n\
Sparkline, 4 values: min 1, max 5, last 5, trend up"
);
assert_eq!(
KpiCard::new("", 0.0).delta(0.0).accessible_text(80),
"KPI: 0, unchanged"
);
}
}