1use rich::measure::Measurement;
4use rich::{Console, ConsoleOptions, Renderable, Segment};
5
6use super::{
7 Charset, Line, Sparkline, Status, ValueFormat, cells, has_colour, lines_to_segments,
8 theme_style, truncate,
9};
10
11const MIN_BORDERED: usize = 5;
13
14#[derive(Clone, Copy, Debug, PartialEq)]
16enum Delta {
17 Absolute(f64),
18 Percent(f64),
19}
20
21impl Delta {
22 fn value(self) -> f64 {
23 match self {
24 Delta::Absolute(v) | Delta::Percent(v) => v,
25 }
26 }
27}
28
29#[derive(Clone, Debug, PartialEq)]
77#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
78pub struct KpiCard {
79 label: String,
80 value: f64,
81 format: ValueFormat,
82 unit: String,
83 delta: Option<Delta>,
84 caption: Option<String>,
85 higher_is_better: bool,
86 sparkline: Option<Sparkline>,
87 status: Option<Status>,
88 width: Option<usize>,
89 expand: bool,
90 border: bool,
91 charset: Charset,
92}
93
94impl KpiCard {
95 pub fn new(label: impl Into<String>, value: f64) -> Self {
97 KpiCard {
98 label: label.into(),
99 value,
100 format: ValueFormat::Compact,
101 unit: String::new(),
102 delta: None,
103 caption: None,
104 higher_is_better: true,
105 sparkline: None,
106 status: None,
107 width: None,
108 expand: false,
109 border: true,
110 charset: Charset::Auto,
111 }
112 }
113
114 pub fn format(mut self, format: ValueFormat) -> Self {
117 self.format = format;
118 self
119 }
120
121 pub fn unit(mut self, unit: impl Into<String>) -> Self {
123 self.unit = unit.into();
124 self
125 }
126
127 pub fn delta(mut self, delta: f64) -> Self {
129 self.delta = Some(Delta::Absolute(delta));
130 self
131 }
132
133 pub fn delta_percent(mut self, percent: f64) -> Self {
135 self.delta = Some(Delta::Percent(percent));
136 self
137 }
138
139 pub fn previous(mut self, previous: f64) -> Self {
142 let change = self.value - previous;
143 self.delta = Some(if previous != 0.0 && previous.is_finite() {
144 Delta::Percent(change / previous.abs() * 100.0)
145 } else {
146 Delta::Absolute(change)
147 });
148 self
149 }
150
151 pub fn caption(mut self, caption: impl Into<String>) -> Self {
153 self.caption = Some(caption.into());
154 self
155 }
156
157 pub fn higher_is_better(mut self, higher: bool) -> Self {
160 self.higher_is_better = higher;
161 self
162 }
163
164 pub fn sparkline(mut self, sparkline: Sparkline) -> Self {
166 self.sparkline = Some(sparkline);
167 self
168 }
169
170 pub fn trend(self, values: impl IntoIterator<Item = f64>) -> Self {
172 self.sparkline(Sparkline::new(values))
173 }
174
175 pub fn status(mut self, status: Status) -> Self {
177 self.status = Some(status);
178 self
179 }
180
181 pub fn width(mut self, width: usize) -> Self {
183 self.width = Some(width.max(1));
184 self
185 }
186
187 pub fn expand(mut self, expand: bool) -> Self {
189 self.expand = expand;
190 self
191 }
192
193 pub fn border(mut self, border: bool) -> Self {
195 self.border = border;
196 self
197 }
198
199 pub fn charset(mut self, charset: Charset) -> Self {
203 self.charset = charset;
204 self
205 }
206
207 fn value_text(&self) -> String {
208 let value = self.format.format(self.value);
209 if self.value.is_finite() {
210 format!("{value}{}", self.unit)
211 } else {
212 value
213 }
214 }
215
216 fn delta_text(&self, ascii: bool) -> Option<String> {
217 let delta = self.delta?;
218 let v = delta.value();
219 let direction = v.is_finite().then(|| v.partial_cmp(&0.0)).flatten();
221 let arrow = match (direction, ascii) {
222 (Some(std::cmp::Ordering::Greater), false) => "▲",
223 (Some(std::cmp::Ordering::Greater), true) => "^",
224 (Some(std::cmp::Ordering::Less), false) => "▼",
225 (Some(std::cmp::Ordering::Less), true) => "v",
226 _ => "=",
227 };
228 let number = match delta {
229 Delta::Absolute(v) => self.format.format(v),
230 Delta::Percent(v) => {
231 let text = ValueFormat::Compact.format(v);
232 if v.is_finite() {
233 format!("{text}%")
234 } else {
235 text
236 }
237 }
238 };
239 let sign = if v > 0.0 && v.is_finite() && number != "0" && number != "0%" {
240 "+"
241 } else {
242 ""
243 };
244 Some(format!("{arrow} {sign}{number}"))
245 }
246
247 fn delta_key(&self) -> &'static str {
248 let v = self.delta.map_or(0.0, Delta::value);
249 if !v.is_finite() || v == 0.0 {
250 "chart.delta.flat"
251 } else if (v > 0.0) == self.higher_is_better {
252 "chart.delta.good"
253 } else {
254 "chart.delta.bad"
255 }
256 }
257
258 fn charsets(&self, console: &Console, options: &ConsoleOptions) -> (bool, Option<Charset>) {
259 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
260 let spark = self.sparkline.as_ref().map(|s| {
261 if ascii {
262 Charset::Ascii
263 } else {
264 s.resolved_charset(console, options)
265 }
266 });
267 (ascii, spark)
268 }
269
270 fn inner_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
272 let header = cells(&self.label)
273 + self.status.map_or(0, |s| {
274 cells(&s.text(ascii)) + usize::from(!self.label.is_empty())
275 });
276 let delta = match (self.delta_text(ascii), &self.caption) {
277 (Some(d), Some(c)) => cells(&d) + 1 + cells(c),
278 (Some(d), None) => cells(&d),
279 (None, Some(c)) => cells(c),
280 (None, None) => 0,
281 };
282 let spark = match (&self.sparkline, spark) {
283 (Some(s), Some(charset)) => s.natural_width(charset),
284 _ => 0,
285 };
286 header
287 .max(cells(&self.value_text()))
288 .max(delta)
289 .max(spark)
290 .max(1)
291 }
292
293 fn natural_width(&self, ascii: bool, spark: Option<Charset>) -> usize {
294 self.inner_width(ascii, spark) + if self.border { 4 } else { 0 }
295 }
296
297 fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
298 let given = options.max_width;
299 let (ascii, spark) = self.charsets(console, options);
300 let colour = has_colour(console);
301 let total = if self.expand {
302 given
303 } else {
304 self.width
305 .unwrap_or_else(|| self.natural_width(ascii, spark))
306 .min(given)
307 };
308 let border = self.border && total >= MIN_BORDERED;
309 let inner = if border { total - 4 } else { total };
310 let style = |key: &str| colour.then(|| theme_style(console, key));
311
312 let mut content = Vec::new();
313 let mut header = Line::new();
315 let status = self.status.map(|s| truncate(&s.text(ascii), inner, ascii));
316 let status_w = status.as_deref().map_or(0, cells);
317 let label_room = inner.saturating_sub(status_w + usize::from(status_w > 0));
318 let label = truncate(&self.label, label_room, ascii);
319 header.push(&label, style("chart.kpi.label"));
320 if let (Some(text), Some(s)) = (&status, self.status) {
321 header.pad(inner - cells(&label) - status_w);
322 header.push(text, style(s.key()));
323 }
324 content.push(header);
325 let mut value = Line::new();
326 value.push(
327 &truncate(&self.value_text(), inner, ascii),
328 style("chart.kpi.value"),
329 );
330 content.push(value);
331 let delta = self.delta_text(ascii);
332 if delta.is_some() || self.caption.is_some() {
333 let mut line = Line::new();
334 if let Some(d) = &delta {
335 line.push(&truncate(d, inner, ascii), style(self.delta_key()));
336 }
337 if let Some(caption) = &self.caption {
338 let room = inner.saturating_sub(line.width() + usize::from(line.width() > 0));
339 if room > 0 {
340 if line.width() > 0 {
341 line.pad(1);
342 }
343 line.push(&truncate(caption, room, ascii), style("chart.label"));
344 }
345 }
346 content.push(line);
347 }
348 if let (Some(s), Some(charset)) = (&self.sparkline, spark) {
349 content.push(s.line(console, charset, inner));
350 }
351
352 if !border {
353 for line in &mut content {
354 line.crop(inner);
355 let fill = inner - line.width();
356 line.pad(fill);
357 }
358 return content;
359 }
360 let edge = style("chart.kpi.border");
361 let (tl, tr, bl, br, h, v) = if ascii {
362 ('+', '+', '+', '+', '-', '|')
363 } else {
364 ('╭', '╮', '╰', '╯', '─', '│')
365 };
366 let rule = |l: char, r: char| {
367 let mut line = Line::new();
368 line.push(
369 &format!("{l}{}{r}", h.to_string().repeat(total - 2)),
370 edge.clone(),
371 );
372 line
373 };
374 let mut out = vec![rule(tl, tr)];
375 for mut body in content {
376 body.crop(inner);
377 let fill = inner - body.width();
378 body.pad(fill);
379 let mut line = Line::new();
380 line.push(&format!("{v} "), edge.clone());
381 line.append(body);
382 line.push(&format!(" {v}"), edge.clone());
383 out.push(line);
384 }
385 out.push(rule(bl, br));
386 out
387 }
388}
389
390impl crate::surface::Draw for KpiCard {
391 fn draw(
392 &self,
393 console: &Console,
394 options: &ConsoleOptions,
395 screen: &mut crate::surface::Screen,
396 rect: crate::surface::Rect,
397 ) {
398 super::draw_lines(
399 self.lines(console, options),
400 options.max_width,
401 options,
402 screen,
403 rect,
404 );
405 }
406}
407
408impl Renderable for KpiCard {
409 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
410 lines_to_segments(self.lines(console, options), options.max_width)
411 }
412
413 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
414 let (ascii, spark) = self.charsets(console, options);
415 let max = if self.expand {
416 options.max_width
417 } else {
418 self.width
419 .unwrap_or_else(|| self.natural_width(ascii, spark))
420 };
421 let min = self.width.unwrap_or(if self.border { 8 } else { 4 });
422 Measurement::new(min.min(max), max)
423 .with_maximum(options.max_width)
424 .normalize()
425 }
426}
427
428impl crate::a11y::AccessibleText for KpiCard {
432 fn accessible_text(&self, width: usize) -> String {
433 let what = if self.label.is_empty() {
434 "KPI".to_string()
435 } else {
436 format!("KPI {}", self.label)
437 };
438 let mut parts = vec![self.value_text()];
439 if let Some(delta) = self.delta {
440 let v = delta.value();
441 let mut change = if !v.is_finite() {
442 "change unknown".to_string()
443 } else if v == 0.0 {
444 "unchanged".to_string()
445 } else {
446 let text = self
448 .delta_text(true)
449 .unwrap_or_default()
450 .split_once(' ')
451 .map(|(_, n)| n.trim_start_matches(['+', '-']).to_string())
452 .unwrap_or_default();
453 format!("{} {text}", if v > 0.0 { "up" } else { "down" })
454 };
455 if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
456 change.push(' ');
457 change.push_str(caption);
458 }
459 parts.push(change);
460 if v.is_finite() && v != 0.0 {
461 let better = (v > 0.0) == self.higher_is_better;
462 parts.push(if better { "better" } else { "worse" }.to_string());
463 }
464 } else if let Some(caption) = self.caption.as_deref().filter(|c| !c.is_empty()) {
465 parts.push(caption.to_string());
466 }
467 if let Some(status) = self.status {
468 parts.push(format!("status {}", status.word()));
469 }
470 let mut out = format!("{what}: {}", parts.join(", "));
471 if let Some(spark) = &self.sparkline {
472 out.push('\n');
473 out.push_str(&crate::a11y::AccessibleText::accessible_text(spark, width));
474 }
475 out
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::a11y::AccessibleText;
483
484 #[test]
485 fn accessible_text_says_the_change_in_words() {
486 let card = KpiCard::new("Errors", 42.0)
487 .previous(56.0)
488 .higher_is_better(false)
489 .caption("vs yesterday")
490 .status(Status::Warning);
491 assert_eq!(
492 card.accessible_text(80),
493 "KPI Errors: 42, down 25% vs yesterday, better, status warning"
494 );
495 let trend = KpiCard::new("Requests", 12_400.0)
496 .unit("/s")
497 .delta_percent(8.49)
498 .trend([1.0, 3.0, 2.0, 5.0]);
499 assert_eq!(
500 trend.accessible_text(80),
501 "KPI Requests: 12.4k/s, up 8.49%, better\n\
502 Sparkline, 4 values: min 1, max 5, last 5, trend up"
503 );
504 assert_eq!(
505 KpiCard::new("", 0.0).delta(0.0).accessible_text(80),
506 "KPI: 0, unchanged"
507 );
508 }
509}