use std::io::IsTerminal;
use std::time::Duration;
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const MAGENTA: &str = "\x1b[35m";
const CYAN: &str = "\x1b[36m";
const DIM_YELLOW: &str = "\x1b[2;33m";
const BOLD_CYAN: &str = "\x1b[1;36m";
const RULE_WIDTH: usize = 46;
const LABEL_PAD: usize = 11;
const ART_GRADIENT: [&str; 5] = ["\x1b[96m", "\x1b[36m", "\x1b[94m", "\x1b[95m", "\x1b[35m"];
pub struct Palette {
on: bool,
}
impl Palette {
pub fn detect() -> Self {
let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
Self {
on: !no_color && std::io::stdout().is_terminal(),
}
}
fn paint(&self, code: &str, s: &str) -> String {
if self.on {
format!("{code}{s}{RESET}")
} else {
s.to_owned()
}
}
pub fn bold(&self, s: &str) -> String {
self.paint(BOLD, s)
}
pub fn dim(&self, s: &str) -> String {
self.paint(DIM, s)
}
pub fn cyan(&self, s: &str) -> String {
self.paint(CYAN, s)
}
pub fn magenta(&self, s: &str) -> String {
self.paint(MAGENTA, s)
}
pub fn green(&self, s: &str) -> String {
self.paint(GREEN, s)
}
pub fn yellow(&self, s: &str) -> String {
self.paint(YELLOW, s)
}
pub fn red(&self, s: &str) -> String {
self.paint(RED, s)
}
pub fn dim_yellow(&self, s: &str) -> String {
self.paint(DIM_YELLOW, s)
}
}
pub enum Tone {
Ok,
Warn,
Err,
Plain,
}
pub struct Banner {
name: String,
version: String,
art: Vec<String>,
rows: Vec<(String, String, Tone)>,
}
impl Banner {
pub fn new(name: &str, version: &str) -> Self {
Self {
name: name.to_owned(),
version: version.to_owned(),
art: Vec::new(),
rows: Vec::new(),
}
}
pub fn art(mut self, lines: &[&str]) -> Self {
self.art = lines.iter().map(|l| (*l).to_owned()).collect();
self
}
pub fn row(mut self, label: &str, value: &str, tone: Tone) -> Self {
self.rows.push((label.to_owned(), value.to_owned(), tone));
self
}
pub fn print(&self) {
println!("{}", self.render(&Palette::detect()));
}
fn rule_width(&self, pad: usize) -> usize {
let art = self
.art
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0);
let rows = self
.rows
.iter()
.map(|(_, value, _)| 1 + pad + 1 + value.chars().count())
.max()
.unwrap_or(0);
RULE_WIDTH.max(art).max(rows)
}
fn label_pad(&self) -> usize {
self.rows
.iter()
.map(|(label, _, _)| label.chars().count())
.max()
.unwrap_or(0)
.max(LABEL_PAD)
}
fn render(&self, p: &Palette) -> String {
let pad = self.label_pad();
let rule = p.dim(&"\u{2500}".repeat(self.rule_width(pad)));
let mut out = String::new();
out.push('\n');
if !self.art.is_empty() {
let last = self.art.len() - 1;
for (i, line) in self.art.iter().enumerate() {
let step = (i * (ART_GRADIENT.len() - 1))
.checked_div(last)
.unwrap_or(0);
out.push_str(" ");
out.push_str(&p.paint(ART_GRADIENT[step], line));
out.push('\n');
}
out.push('\n');
}
out.push_str(&format!(
" {} {}\n",
p.paint(BOLD_CYAN, &self.name),
p.cyan(&format!("v{}", self.version))
));
out.push_str(&format!(" {rule}\n"));
for (label, value, tone) in &self.rows {
let value = match tone {
Tone::Ok => p.green(value),
Tone::Warn => p.dim_yellow(value),
Tone::Err => p.red(value),
Tone::Plain => p.bold(value),
};
out.push_str(&format!(
" {} {value}\n",
p.dim(&format!("{label:<pad$}"))
));
}
out.push_str(&format!(" {rule}"));
out
}
}
pub fn print_ready(statuses: &[(&str, bool)], elapsed: Duration) {
println!("{}", render_ready(statuses, elapsed, &Palette::detect()));
}
fn render_ready(statuses: &[(&str, bool)], elapsed: Duration, p: &Palette) -> String {
let parts: Vec<String> = statuses
.iter()
.map(|(name, ok)| {
if *ok {
format!("{name} {}", p.green("ok"))
} else {
format!("{name} {}", p.red("down"))
}
})
.collect();
format!(
" {} {} {} {}",
p.green("\u{25CF}"),
p.bold(&p.green("online")),
parts.join(" - "),
p.dim(&format!("(startup {:.1}s)", elapsed.as_secs_f32()))
)
}
pub fn print_check(label: &str, value: &str) {
let p = Palette::detect();
println!(
" {} {} {}",
p.green("\u{2714}"),
p.dim(label),
p.bold(value)
);
}
pub fn print_warn(label: &str, value: &str) {
let p = Palette::detect();
println!(" {} {} {}", p.yellow("!"), p.dim(label), p.bold(value));
}
#[cfg(test)]
mod tests {
use super::*;
fn plain() -> Palette {
Palette { on: false }
}
#[test]
fn render_without_colors_pads_labels() {
let s = Banner::new("MyBot", "1.2.3")
.row("platforms", "telegram", Tone::Ok)
.row("ai", "disabled", Tone::Warn)
.render(&plain());
assert!(!s.contains('\x1b'));
assert!(s.contains(" MyBot v1.2.3\n"));
assert!(s.contains(" platforms telegram\n"));
assert!(s.contains(" ai disabled\n"));
}
#[test]
fn rule_defaults_to_minimum_width() {
let s = Banner::new("Tiny", "0.1").render(&plain());
assert!(s.contains(&"\u{2500}".repeat(RULE_WIDTH)));
}
#[test]
fn rule_grows_with_a_long_art_line() {
let long = "#".repeat(60);
let s = Banner::new("Big", "0.1")
.art(&[&long, "short"])
.render(&plain());
assert!(s.contains(&long));
assert!(s.contains(&"\u{2500}".repeat(60)));
assert!(!s.contains(&"\u{2500}".repeat(61)));
}
#[test]
fn long_labels_stretch_the_column() {
let s = Banner::new("MyBot", "0.1")
.row("a-very-long-label", "x", Tone::Plain)
.row("short", "y", Tone::Plain)
.render(&plain());
assert!(s.contains(" a-very-long-label x\n"));
assert!(s.contains(" short y\n"));
}
#[test]
fn ready_line_format() {
let s = render_ready(
&[("telegram", true), ("discord", false)],
Duration::from_millis(1400),
&plain(),
);
assert_eq!(
s,
" \u{25CF} online telegram ok - discord down (startup 1.4s)"
);
}
}