mod axis;
mod bar;
mod canvas;
mod gauge;
mod heatmap;
mod kpi;
mod line;
mod scale;
mod sparkline;
mod status;
mod timeline;
pub use bar::{Bar, BarChart, Histogram, Orientation};
pub use canvas::DotCanvas;
pub use gauge::{Band, BulletChart, Gauge};
pub use heatmap::Heatmap;
pub use kpi::KpiCard;
pub use line::{LineChart, Series, SeriesKind};
pub use scale::{Scale, ValueFormat};
pub use sparkline::Sparkline;
pub use status::{State, Status, StatusMatrix};
pub use timeline::{Milestone, Span, Timeline};
use rich::cells::char_cell_width;
use rich::{Console, ConsoleOptions, Segment, Style};
use crate::fidelity::{Fidelity, Policy};
pub const STYLES: &[(&str, &str)] = &[
("chart.axis", "bright_black"),
("chart.label", "none"),
("chart.value", "none"),
("chart.bar", "cyan"),
("chart.negative", "magenta"),
("chart.spark", "cyan"),
("chart.over", "bold red"),
("chart.min", "blue"),
("chart.max", "bold green"),
("chart.series.1", "cyan"),
("chart.series.2", "magenta"),
("chart.series.3", "yellow"),
("chart.series.4", "green"),
("chart.series.5", "blue"),
("chart.track", "bright_black"),
("chart.target", "bold"),
("chart.ok", "green"),
("chart.warning", "yellow"),
("chart.critical", "bold red"),
("chart.unknown", "bright_black"),
("chart.delta.good", "green"),
("chart.delta.bad", "red"),
("chart.delta.flat", "bright_black"),
("chart.kpi.label", "none"),
("chart.kpi.value", "bold"),
("chart.kpi.border", "bright_black"),
("chart.heat.1", "blue"),
("chart.heat.2", "cyan"),
("chart.heat.3", "green"),
("chart.heat.4", "yellow"),
("chart.heat.5", "red"),
("chart.state.pass", "green"),
("chart.state.fail", "bold red"),
("chart.state.skip", "bright_black"),
("chart.state.flaky", "yellow"),
("chart.state.unknown", "magenta"),
("chart.milestone", "bold yellow"),
];
const SERIES_STYLES: usize = 5;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Charset {
#[default]
Auto,
Blocks,
Braille,
Ascii,
}
impl Charset {
pub fn resolve(self, console: &Console, options: &ConsoleOptions, natural: Charset) -> Charset {
let ascii = options.ascii_only()
|| console.ascii_only()
|| Fidelity::for_console(console, &Policy::default()) == Fidelity::Ascii;
match self {
_ if ascii => Charset::Ascii,
Charset::Auto => natural,
set => set,
}
}
}
pub(crate) fn has_colour(console: &Console) -> bool {
Fidelity::for_console(console, &Policy::default()) >= Fidelity::Rich
}
pub(crate) fn theme_style(console: &Console, key: &str) -> Style {
if let Some(style) = console.theme().get(key) {
return style.clone();
}
STYLES
.iter()
.find(|(name, _)| *name == key)
.and_then(|(_, spec)| Style::parse(spec).ok())
.unwrap_or_default()
}
pub(crate) fn user_style(console: &Console, spec: &str) -> Style {
console
.theme()
.get(spec)
.cloned()
.or_else(|| Style::parse(spec).ok())
.unwrap_or_default()
}
pub(crate) fn series_key(index: usize) -> String {
format!("chart.series.{}", index % SERIES_STYLES + 1)
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Line {
spans: Vec<(String, Option<Style>)>,
width: usize,
}
impl Line {
pub(crate) fn new() -> Self {
Line::default()
}
pub(crate) fn width(&self) -> usize {
self.width
}
pub(crate) fn push(&mut self, text: &str, style: Option<Style>) {
if text.is_empty() {
return;
}
self.width += text.chars().map(char_cell_width).sum::<usize>();
if let Some((last, last_style)) = self.spans.last_mut() {
if *last_style == style {
last.push_str(text);
return;
}
}
self.spans.push((text.to_string(), style));
}
pub(crate) fn append(&mut self, other: Line) {
for (text, style) in other.spans {
self.push(&text, style);
}
}
pub(crate) fn pad(&mut self, n: usize) {
if n > 0 {
self.push(&" ".repeat(n), None);
}
}
pub(crate) fn crop(&mut self, width: usize) {
if self.width <= width {
return;
}
let mut used = 0;
let mut kept = Vec::new();
for (text, style) in self.spans.drain(..) {
if used >= width {
break;
}
let mut out = String::new();
for c in text.chars() {
let w = char_cell_width(c);
if used + w > width {
if used < width {
out.push(' ');
used += 1;
}
break;
}
out.push(c);
used += w;
}
if !out.is_empty() {
kept.push((out, style));
}
}
self.spans = kept;
self.width = used;
}
fn into_segments(self, out: &mut Vec<Segment>) {
for (text, style) in self.spans {
out.push(Segment::new(text, style));
}
}
}
pub(crate) fn lines_to_segments(lines: Vec<Line>, width: usize) -> Vec<Segment> {
let block = lines
.iter()
.map(|l| l.width().min(width))
.max()
.unwrap_or(0);
let mut out = Vec::new();
for (index, mut line) in lines.into_iter().enumerate() {
if index > 0 {
out.push(Segment::line());
}
line.crop(width);
let fill = block - line.width();
line.pad(fill);
line.into_segments(&mut out);
}
out
}
pub(crate) fn cell_units(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut wide = false;
for c in text.chars() {
match char_cell_width(c) {
0 => {
let at = out.len().checked_sub(if wide { 2 } else { 1 });
if let Some(cell) = at.and_then(|i| out.get_mut(i)) {
cell.push(c);
}
}
w => {
out.push(c.to_string());
wide = w > 1;
if wide {
out.push(String::new());
}
}
}
}
out
}
pub(crate) fn truncate(text: &str, width: usize, ascii: bool) -> String {
let owned;
let text = if ascii && !text.is_ascii() {
owned = crate::fidelity::ascii_text(text);
owned.as_str()
} else {
text
};
let len: usize = text.chars().map(char_cell_width).sum();
if len <= width {
return text.to_string();
}
if width == 0 {
return String::new();
}
let mut out = String::new();
let mut used = 0;
for c in text.chars() {
let w = char_cell_width(c);
if used + w > width - 1 {
break;
}
out.push(c);
used += w;
}
out.push_str(&" ".repeat(width - 1 - used));
out.push(if ascii { '.' } else { '…' });
out
}
pub(crate) fn wrap_entries(entries: Vec<Vec<(String, Option<Style>)>>, width: usize) -> Vec<Line> {
let mut out = Vec::new();
let mut line = Line::new();
for entry in entries {
let entry_w: usize = entry.iter().map(|(t, _)| cells(t)).sum();
if line.width() > 0 && line.width() + 2 + entry_w > width {
out.push(std::mem::take(&mut line));
}
if line.width() > 0 {
line.pad(2);
}
for (text, style) in entry {
line.push(&text, style);
}
}
if line.width() > 0 {
out.push(line);
}
out
}
pub(crate) fn entries_width(entries: &[Vec<(String, Option<Style>)>]) -> usize {
let total: usize = entries
.iter()
.map(|e| e.iter().map(|(t, _)| cells(t)).sum::<usize>())
.sum();
total + 2 * entries.len().saturating_sub(1)
}
pub(crate) fn centre(line: &mut Line, text: &str, width: usize, style: Option<Style>, ascii: bool) {
let text = truncate(text, width, ascii);
let pad = width - cells(&text);
line.pad(pad / 2);
line.push(&text, style);
line.pad(pad - pad / 2);
}
pub(crate) fn cells(text: &str) -> usize {
rich::cells::cell_len(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crop_counts_cells_not_bytes() {
let mut line = Line::new();
line.push("ab", None);
line.push("日本", Some(Style::parse("red").unwrap()));
assert_eq!(line.width(), 6);
line.crop(5);
assert_eq!(line.width(), 5);
let mut segs = Vec::new();
line.into_segments(&mut segs);
let text: String = segs.iter().map(|s| s.text.as_str()).collect();
assert_eq!(text, "ab日 ");
}
#[test]
fn truncation_marks_the_cut() {
assert_eq!(truncate("latency", 4, false), "lat…");
assert_eq!(truncate("latency", 4, true), "lat.");
assert_eq!(truncate("latency", 1, true), ".");
assert_eq!(truncate("latency", 0, true), "");
assert_eq!(truncate("ok", 4, true), "ok");
assert_eq!(truncate("日本語", 4, false), "日 …");
assert_eq!(truncate("café", 4, true), "caf?");
}
}