use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack, PopupSize};
use ratatui::style::Color;
const MAX_BADGE_WIDTH: u16 = 40;
#[derive(clap::Args)]
pub struct BadgeArgs {
#[arg(long = "badge", value_name = "TEXT|COLOR|ANCHOR|WxL")]
pub badges: Vec<String>,
#[arg(long, default_value = "0")]
pub badge_width: u16,
#[arg(long, default_value = "1")]
pub badge_lines: u16,
#[arg(long, default_value = "tl")]
pub badge_anchor: String,
}
impl BadgeArgs {
pub fn prepare_specs(&self) -> Vec<BadgeSpec> {
let global_anchor = parse_anchor(&self.badge_anchor).unwrap_or(BadgeAnchor::TopLeft);
let global_width = if self.badge_width > 0 { Some(self.badge_width) } else { None };
self.badges.iter()
.map(|s| prepare_badge(parse_badge_arg(s), global_anchor, self.badge_lines, global_width))
.collect()
}
}
pub struct BadgeParts {
pub text: String,
pub color: Color,
pub anchor: Option<BadgeAnchor>,
pub width: Option<u16>,
pub lines: Option<u16>,
}
pub struct BadgeSpec {
pub display: String,
pub color: Color,
pub badge_w: u16,
pub lines: u16,
pub anchor: BadgeAnchor,
}
impl BadgeSpec {
pub fn into_badge(&self) -> Badge<'_> {
let mut badge = if self.lines > 1 && self.display.contains('\n') {
let lines: Vec<ratatui::text::Line<'_>> =
self.display.split('\n').map(ratatui::text::Line::from).collect();
Badge::with_content(lines, self.color)
.width(PopupSize::Fixed(self.badge_w))
.height(PopupSize::Fixed(self.lines + 2))
.closable(false)
} else {
Badge::new(&self.display, self.color)
.width(PopupSize::Fixed(self.badge_w))
.height(PopupSize::Fixed(self.lines + 2))
.closable(false)
};
badge = badge.anchor(self.anchor);
badge
}
}
pub fn parse_color(s: &str) -> Option<Color> {
match s.to_lowercase().as_str() {
"green" => Some(Color::Green),
"red" => Some(Color::Red),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"cyan" => Some(Color::Cyan),
"magenta" => Some(Color::Magenta),
"white" => Some(Color::White),
"black" => Some(Color::Black),
"gray" | "grey" => Some(Color::Gray),
"darkgray" | "darkgrey" => Some(Color::DarkGray),
"lightred" => Some(Color::LightRed),
"lightgreen" => Some(Color::LightGreen),
"lightyellow" => Some(Color::LightYellow),
"lightblue" => Some(Color::LightBlue),
"lightmagenta" => Some(Color::LightMagenta),
"lightcyan" => Some(Color::LightCyan),
_ => None,
}
}
pub fn parse_anchor(s: &str) -> Option<BadgeAnchor> {
match s.to_lowercase().as_str() {
"tl" | "top-left" => Some(BadgeAnchor::TopLeft),
"t" | "top" => Some(BadgeAnchor::Top),
"tr" | "top-right" => Some(BadgeAnchor::TopRight),
"bl" | "bottom-left" => Some(BadgeAnchor::BottomLeft),
"b" | "bottom" => Some(BadgeAnchor::Bottom),
"br" | "bottom-right" => Some(BadgeAnchor::BottomRight),
"l" | "left" => Some(BadgeAnchor::Left),
"r" | "right" => Some(BadgeAnchor::Right),
_ => None,
}
}
pub fn parse_dimensions(s: &str) -> (Option<u16>, Option<u16>) {
if let Some((w, l)) = s.split_once('x') {
(w.parse().ok(), l.parse().ok())
} else {
(s.parse().ok(), None)
}
}
pub fn parse_badge_arg(s: &str) -> BadgeParts {
let parts: Vec<&str> = s.split('|').collect();
BadgeParts {
text: parts[0].to_string(),
color: parts.get(1).and_then(|c| parse_color(c)).unwrap_or(Color::Gray),
anchor: parts.get(2).and_then(|a| parse_anchor(a)),
width: parts.get(3).map(|d| parse_dimensions(d).0).flatten(),
lines: parts.get(3).map(|d| parse_dimensions(d).1).flatten(),
}
}
fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
return text.to_string();
}
let mut t: String = text.chars().take(max_len.saturating_sub(1)).collect();
t.push('…');
t
}
fn wrap_text(text: &str, content_w: usize, max_lines: u16) -> String {
let mut result = String::new();
let mut remaining = text;
for i in 0..max_lines as usize {
if remaining.is_empty() { break; }
let chunk_len = remaining.len().min(content_w);
let (chunk, rest) = remaining.split_at(chunk_len);
if i > 0 { result.push('\n'); }
if i == max_lines as usize - 1 && !rest.is_empty() {
let mut t: String = chunk.chars().take(content_w.saturating_sub(1)).collect();
t.push('…');
result.push_str(&t);
} else {
result.push_str(chunk);
}
remaining = rest;
}
result
}
pub fn prepare_badge(parts: BadgeParts, global_anchor: BadgeAnchor, global_lines: u16, global_width: Option<u16>) -> BadgeSpec {
let anchor = parts.anchor.unwrap_or(global_anchor);
let lines = parts.lines.unwrap_or(global_lines);
let badge_w = parts.width.or(global_width).unwrap_or_else(|| {
(parts.text.len() as u16 + 4).max(10).min(MAX_BADGE_WIDTH)
});
let content_w = badge_w.saturating_sub(2) as usize;
let display = if parts.text.len() <= content_w {
parts.text.to_string()
} else if lines > 1 {
wrap_text(&parts.text, content_w, lines)
} else {
truncate_with_ellipsis(&parts.text, content_w)
};
BadgeSpec { display, color: parts.color, badge_w, lines, anchor }
}
pub fn build_badge_stack(specs: &[BadgeSpec]) -> BadgeStack<'_> {
let mut stack = BadgeStack::new();
for spec in specs {
stack = stack.push(spec.into_badge());
}
stack
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_color_green() {
assert_eq!(parse_color("green"), Some(Color::Green));
}
#[test]
fn parse_color_case_insensitive() {
assert_eq!(parse_color("RED"), Some(Color::Red));
assert_eq!(parse_color("Cyan"), Some(Color::Cyan));
}
#[test]
fn parse_color_unknown_returns_none() {
assert_eq!(parse_color("pink"), None);
assert_eq!(parse_color(""), None);
}
#[test]
fn parse_color_gray_aliases() {
assert_eq!(parse_color("gray"), Some(Color::Gray));
assert_eq!(parse_color("grey"), Some(Color::Gray));
}
#[test]
fn parse_anchor_tl() {
assert_eq!(parse_anchor("tl"), Some(BadgeAnchor::TopLeft));
assert_eq!(parse_anchor("top-left"), Some(BadgeAnchor::TopLeft));
}
#[test]
fn parse_anchor_tr() {
assert_eq!(parse_anchor("tr"), Some(BadgeAnchor::TopRight));
assert_eq!(parse_anchor("top-right"), Some(BadgeAnchor::TopRight));
}
#[test]
fn parse_anchor_bl() {
assert_eq!(parse_anchor("bl"), Some(BadgeAnchor::BottomLeft));
assert_eq!(parse_anchor("bottom-left"), Some(BadgeAnchor::BottomLeft));
}
#[test]
fn parse_anchor_b() {
assert_eq!(parse_anchor("b"), Some(BadgeAnchor::Bottom));
assert_eq!(parse_anchor("bottom"), Some(BadgeAnchor::Bottom));
}
#[test]
fn parse_anchor_l() {
assert_eq!(parse_anchor("l"), Some(BadgeAnchor::Left));
assert_eq!(parse_anchor("left"), Some(BadgeAnchor::Left));
}
#[test]
fn parse_anchor_r() {
assert_eq!(parse_anchor("r"), Some(BadgeAnchor::Right));
assert_eq!(parse_anchor("right"), Some(BadgeAnchor::Right));
}
#[test]
fn parse_anchor_t() {
assert_eq!(parse_anchor("t"), Some(BadgeAnchor::Top));
assert_eq!(parse_anchor("top"), Some(BadgeAnchor::Top));
}
#[test]
fn parse_anchor_br() {
assert_eq!(parse_anchor("br"), Some(BadgeAnchor::BottomRight));
assert_eq!(parse_anchor("bottom-right"), Some(BadgeAnchor::BottomRight));
}
#[test]
fn parse_anchor_invalid() {
assert_eq!(parse_anchor("xyz"), None);
assert_eq!(parse_anchor(""), None);
}
#[test]
fn parse_dimensions_width_only() {
assert_eq!(parse_dimensions("20"), (Some(20), None));
}
#[test]
fn parse_dimensions_width_and_lines() {
assert_eq!(parse_dimensions("20x2"), (Some(20), Some(2)));
}
#[test]
fn parse_dimensions_invalid() {
assert_eq!(parse_dimensions("abc"), (None, None));
}
#[test]
fn parse_badge_arg_text_only() {
let p = parse_badge_arg("main");
assert_eq!(p.text, "main");
assert_eq!(p.color, Color::Gray);
assert!(p.anchor.is_none());
assert!(p.width.is_none());
assert!(p.lines.is_none());
}
#[test]
fn parse_badge_arg_text_and_color() {
let p = parse_badge_arg("main|red");
assert_eq!(p.text, "main");
assert_eq!(p.color, Color::Red);
assert!(p.anchor.is_none());
}
#[test]
fn parse_badge_arg_all_parts() {
let p = parse_badge_arg("main|red|tr|20x2");
assert_eq!(p.text, "main");
assert_eq!(p.color, Color::Red);
assert_eq!(p.anchor, Some(BadgeAnchor::TopRight));
assert_eq!(p.width, Some(20));
assert_eq!(p.lines, Some(2));
}
#[test]
fn parse_badge_arg_text_with_colon() {
let p = parse_badge_arg("time:10:30|blue");
assert_eq!(p.text, "time:10:30");
assert_eq!(p.color, Color::Blue);
}
#[test]
fn parse_badge_arg_anchor_without_width() {
let p = parse_badge_arg("main|red|bl");
assert_eq!(p.text, "main");
assert_eq!(p.color, Color::Red);
assert_eq!(p.anchor, Some(BadgeAnchor::BottomLeft));
assert!(p.width.is_none());
}
#[test]
fn parse_badge_arg_width_without_anchor() {
let p = parse_badge_arg("main|red||20");
assert_eq!(p.text, "main");
assert_eq!(p.color, Color::Red);
assert!(p.anchor.is_none());
assert_eq!(p.width, Some(20));
assert!(p.lines.is_none());
}
#[test]
fn truncate_fits_no_change() {
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
}
#[test]
fn truncate_adds_ellipsis() {
let result = truncate_with_ellipsis("hello world", 6);
assert_eq!(result, "hello…");
}
#[test]
fn wrap_text_single_line() {
assert_eq!(wrap_text("hello", 10, 1), "hello");
}
#[test]
fn wrap_text_multi_chunks() {
let result = wrap_text("hello world", 5, 3);
assert_eq!(result, "hello\n worl\nd");
}
#[test]
fn wrap_text_last_line_ellipsis() {
let result = wrap_text("hello world foo", 5, 2);
assert_eq!(result, "hello\n wor…");
}
fn prepare_badge_simple(text: &str, color: Color, lines: u16, fixed_w: Option<u16>) -> BadgeSpec {
let parts = BadgeParts {
text: text.to_string(),
color,
anchor: None,
width: fixed_w,
lines: None,
};
prepare_badge(parts, BadgeAnchor::TopLeft, lines, fixed_w)
}
#[test]
fn prepare_badge_auto_width() {
let spec = prepare_badge_simple("prod", Color::Red, 1, None);
assert_eq!(spec.display, "prod");
assert_eq!(spec.color, Color::Red);
assert!(spec.badge_w >= 10);
}
#[test]
fn prepare_badge_fixed_width_truncates() {
let spec = prepare_badge_simple("hello world", Color::Red, 1, Some(10));
assert_eq!(spec.display, "hello w…");
assert_eq!(spec.badge_w, 10);
}
#[test]
fn prepare_badge_fixed_width_wraps() {
let spec = prepare_badge_simple("hello world", Color::Red, 2, Some(10));
assert_eq!(spec.badge_w, 10);
assert!(spec.display.contains('\n'));
}
#[test]
fn prepare_badge_respects_per_badge_anchor() {
let parts = BadgeParts {
text: "main".to_string(),
color: Color::Red,
anchor: Some(BadgeAnchor::TopRight),
width: None,
lines: None,
};
let spec = prepare_badge(parts, BadgeAnchor::TopLeft, 1, None);
assert_eq!(spec.anchor, BadgeAnchor::TopRight);
}
#[test]
fn prepare_badge_inherits_global_anchor() {
let parts = BadgeParts {
text: "main".to_string(),
color: Color::Red,
anchor: None,
width: None,
lines: None,
};
let spec = prepare_badge(parts, BadgeAnchor::BottomRight, 1, None);
assert_eq!(spec.anchor, BadgeAnchor::BottomRight);
}
#[test]
fn badge_args_prepare_specs_empty() {
let args = BadgeArgs {
badges: vec![],
badge_width: 0,
badge_lines: 1,
badge_anchor: "tl".to_string(),
};
let specs = args.prepare_specs();
assert!(specs.is_empty());
}
#[test]
fn badge_args_prepare_specs_uses_globals() {
let args = BadgeArgs {
badges: vec!["main".to_string()],
badge_width: 0,
badge_lines: 1,
badge_anchor: "tr".to_string(),
};
let specs = args.prepare_specs();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].anchor, BadgeAnchor::TopRight);
}
}