use std::fs;
use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack, PopupSize};
use ratatui::style::Color;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
#[derive(serde::Deserialize)]
struct BadgeInput {
text: String,
#[serde(default = "default_badge_color")]
color: String,
#[serde(default)]
anchor: Option<String>,
#[serde(default)]
layer: Option<u16>,
#[serde(default)]
width: Option<DimInput>,
#[serde(default)]
lines: Option<DimInput>,
}
fn default_badge_color() -> String {
"gray".to_string()
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum DimInput {
Number(u16),
String(String),
}
impl BadgeInput {
fn to_badge_parts(&self) -> BadgeParts {
BadgeParts {
text: self.text.to_string(),
color: parse_color(&self.color).unwrap_or(Color::Gray),
anchor: self.anchor.as_deref().and_then(parse_anchor),
layer: self.layer,
width: self.width.as_ref().map(dim_input_to_mode),
lines: self.lines.as_ref().map(dim_input_to_mode),
}
}
}
fn dim_input_to_mode(input: &DimInput) -> DimMode {
match input {
DimInput::Number(n) => DimMode::Fixed(*n),
DimInput::String(s) => parse_dim(s).unwrap_or(DimMode::Auto),
}
}
#[derive(clap::Args)]
pub struct BadgeArgs {
#[arg(long = "badge", value_name = "FILE")]
pub badge_file: Option<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 file_path = match &self.badge_file {
Some(p) => p,
None => return vec![],
};
let content = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{} badge: error al leer '{}': {}", crate::BIN_NAME, file_path, e);
std::process::exit(1);
}
};
let inputs: Vec<BadgeInput> = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("{} badge: error al parsear JSON en '{}': {}", crate::BIN_NAME, file_path, e);
std::process::exit(1);
}
};
let global_anchor = parse_anchor(&self.badge_anchor).unwrap_or(BadgeAnchor::TopLeft);
let global_width = if self.badge_width > 0 { Some(DimMode::Fixed(self.badge_width)) } else { None };
inputs.iter()
.map(|input| {
let parts = input.to_badge_parts();
prepare_badge(parts, global_anchor, self.badge_lines, global_width)
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DimMode {
Auto,
Max(u16),
Fixed(u16),
}
pub struct BadgeParts {
pub text: String,
pub color: Color,
pub anchor: Option<BadgeAnchor>,
pub layer: Option<u16>,
pub width: Option<DimMode>,
pub lines: Option<DimMode>,
}
pub struct BadgeSpec {
pub display: String,
pub color: Color,
pub badge_w: u16,
pub lines: u16,
pub anchor: BadgeAnchor,
pub layer: u16,
}
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).layer(self.layer);
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_anchor_and_layer(s: &str) -> (Option<BadgeAnchor>, Option<u16>) {
if let Some((anchor_part, layer_part)) = s.split_once('@') {
(parse_anchor(anchor_part), layer_part.parse::<u16>().ok())
} else {
(parse_anchor(s), None)
}
}
fn parse_dim(s: &str) -> Option<DimMode> {
if s.is_empty() || s.eq_ignore_ascii_case("a") {
Some(DimMode::Auto)
} else if let Some(rest) = s.strip_suffix(['a', 'A']) {
rest.parse::<u16>().ok().map(DimMode::Max)
} else {
s.parse::<u16>().ok().map(DimMode::Fixed)
}
}
pub fn parse_dimensions(s: &str) -> (Option<DimMode>, Option<DimMode>) {
if s.is_empty() {
return (None, None);
}
if let Some((w, l)) = s.split_once('x') {
let w = if w.is_empty() { Some(DimMode::Auto) } else { parse_dim(w) };
let l = if l.is_empty() { Some(DimMode::Auto) } else { parse_dim(l) };
(w, l)
} else {
(parse_dim(s), None)
}
}
fn truncate_with_ellipsis(text: &str, max_width: usize) -> String {
if text.width() <= max_width {
return text.to_string();
}
let target = max_width.saturating_sub(1);
let mut t = String::new();
let mut w = 0;
for c in text.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > target { break; }
t.push(c);
w += cw;
}
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 mut line_w = 0;
let mut split = 0;
for (j, c) in remaining.char_indices() {
let cw = c.width().unwrap_or(0);
if line_w + cw > content_w { break; }
line_w += cw;
split = j + c.len_utf8();
}
let chunk = &remaining[..split];
let rest = &remaining[split..];
if i > 0 { result.push('\n'); }
if i == max_lines as usize - 1 && !rest.is_empty() {
let mut t = String::new();
let mut w = 0;
let target = content_w.saturating_sub(1);
for c in chunk.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > target { break; }
t.push(c);
w += cw;
}
t.push('…');
result.push_str(&t);
} else {
result.push_str(chunk);
}
remaining = rest;
}
result
}
fn resolve_width(dim: Option<DimMode>, global: Option<DimMode>, text_len: u16) -> u16 {
match dim.or(global).unwrap_or(DimMode::Auto) {
DimMode::Fixed(w) => w,
DimMode::Max(w) => (text_len + 2).min(w),
DimMode::Auto => text_len + 2,
}
}
fn resolve_lines(dim: Option<DimMode>, global_lines: u16) -> u16 {
match dim {
Some(DimMode::Fixed(n)) => n,
Some(DimMode::Max(n)) => n,
Some(DimMode::Auto) => global_lines,
None => global_lines,
}
}
pub fn prepare_badge(
parts: BadgeParts,
global_anchor: BadgeAnchor,
global_lines: u16,
global_width: Option<DimMode>,
) -> BadgeSpec {
let text = parts.text;
let anchor = parts.anchor.unwrap_or(global_anchor);
let layer = parts.layer.unwrap_or(0);
let lines = resolve_lines(parts.lines, global_lines);
let text_width = text.width() as u16;
let badge_w = resolve_width(parts.width, global_width, text_width);
let content_w = badge_w.saturating_sub(2) as usize;
let display = if text.width() <= content_w {
text
} else if lines > 1 {
wrap_text(&text, content_w, lines)
} else {
truncate_with_ellipsis(&text, content_w)
};
BadgeSpec { display, color: parts.color, badge_w, lines, anchor, layer }
}
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));
}
#[test]
fn parse_anchor_tr() {
assert_eq!(parse_anchor("tr"), Some(BadgeAnchor::TopRight));
}
#[test]
fn parse_anchor_bl() {
assert_eq!(parse_anchor("bl"), Some(BadgeAnchor::BottomLeft));
}
#[test]
fn parse_anchor_br() {
assert_eq!(parse_anchor("br"), Some(BadgeAnchor::BottomRight));
}
#[test]
fn parse_anchor_invalid() {
assert_eq!(parse_anchor("xyz"), None);
}
#[test]
fn parse_anchor_only() {
let (a, l) = parse_anchor_and_layer("tl");
assert_eq!(a, Some(BadgeAnchor::TopLeft));
assert!(l.is_none());
}
#[test]
fn parse_anchor_with_layer() {
let (a, l) = parse_anchor_and_layer("tr@1");
assert_eq!(a, Some(BadgeAnchor::TopRight));
assert_eq!(l, Some(1));
}
#[test]
fn parse_anchor_with_layer_two() {
let (a, l) = parse_anchor_and_layer("bl@2");
assert_eq!(a, Some(BadgeAnchor::BottomLeft));
assert_eq!(l, Some(2));
}
#[test]
fn parse_anchor_invalid_layer_defaults() {
let (a, l) = parse_anchor_and_layer("tr@abc");
assert_eq!(a, Some(BadgeAnchor::TopRight));
assert!(l.is_none());
}
#[test]
fn parse_dim_width_only() {
let (w, l) = parse_dimensions("20");
assert_eq!(w, Some(DimMode::Fixed(20)));
assert!(l.is_none());
}
#[test]
fn parse_dim_width_and_lines() {
let (w, l) = parse_dimensions("20x2");
assert_eq!(w, Some(DimMode::Fixed(20)));
assert_eq!(l, Some(DimMode::Fixed(2)));
}
#[test]
fn parse_dim_auto_up_to() {
let (w, l) = parse_dimensions("20a");
assert_eq!(w, Some(DimMode::Max(20)));
assert!(l.is_none());
}
#[test]
fn parse_dim_full_auto() {
let (w, l) = parse_dimensions("a");
assert_eq!(w, Some(DimMode::Auto));
assert!(l.is_none());
}
#[test]
fn parse_dim_both_auto_up_to() {
let (w, l) = parse_dimensions("20ax3a");
assert_eq!(w, Some(DimMode::Max(20)));
assert_eq!(l, Some(DimMode::Max(3)));
}
#[test]
fn badge_args_prepare_specs_empty_when_no_file() {
let args = BadgeArgs {
badge_file: None,
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_from_json() {
let dir = std::env::temp_dir().join("pandora_test_badge");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("badges.json");
std::fs::write(&path, r#"[
{"text": "prod", "color": "red", "anchor": "tr"},
{"text": "v2.1.0", "color": "blue", "anchor": "bl"}
]"#).unwrap();
let args = BadgeArgs {
badge_file: Some(path.to_str().unwrap().to_string()),
badge_width: 0,
badge_lines: 1,
badge_anchor: "tr".to_string(),
};
let specs = args.prepare_specs();
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].anchor, BadgeAnchor::TopRight);
assert_eq!(specs[0].layer, 0);
assert_eq!(specs[0].display, "prod");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn badge_input_minimal() {
let input: BadgeInput = serde_json::from_str(r#"{"text":"main"}"#).unwrap();
let parts = input.to_badge_parts();
assert_eq!(parts.text, "main");
assert_eq!(parts.color, Color::Gray);
assert!(parts.anchor.is_none());
assert!(parts.layer.is_none());
assert!(parts.width.is_none());
assert!(parts.lines.is_none());
}
#[test]
fn badge_input_all_fields() {
let input: BadgeInput = serde_json::from_str(
r#"{"text":"prod","color":"red","anchor":"tr","layer":2,"width":20,"lines":3}"#,
)
.unwrap();
let parts = input.to_badge_parts();
assert_eq!(parts.text, "prod");
assert_eq!(parts.color, Color::Red);
assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
assert_eq!(parts.layer, Some(2));
assert_eq!(parts.width, Some(DimMode::Fixed(20)));
assert_eq!(parts.lines, Some(DimMode::Fixed(3)));
}
#[test]
fn badge_input_color_defaults_to_gray() {
let input: BadgeInput = serde_json::from_str(r#"{"text":"x"}"#).unwrap();
assert_eq!(input.color, "gray");
}
#[test]
fn badge_input_unknown_color_falls_back_to_gray() {
let input: BadgeInput = serde_json::from_str(r#"{"text":"x","color":"pink"}"#).unwrap();
assert_eq!(input.to_badge_parts().color, Color::Gray);
}
#[test]
fn badge_input_layers_separate_from_anchor() {
let input: BadgeInput =
serde_json::from_str(r#"{"text":"x","anchor":"tr","layer":1}"#).unwrap();
let parts = input.to_badge_parts();
assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
assert_eq!(parts.layer, Some(1));
}
#[test]
fn dim_input_number_fixed() {
let d: DimInput = serde_json::from_str("20").unwrap();
assert_eq!(dim_input_to_mode(&d), DimMode::Fixed(20));
}
#[test]
fn dim_input_string_max() {
let d: DimInput = serde_json::from_str(r#""20a""#).unwrap();
assert_eq!(dim_input_to_mode(&d), DimMode::Max(20));
}
#[test]
fn dim_input_string_auto() {
let d: DimInput = serde_json::from_str(r#""auto""#).unwrap();
assert_eq!(dim_input_to_mode(&d), DimMode::Auto);
}
#[test]
fn truncate_fits_no_change() {
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
}
#[test]
fn truncate_adds_ellipsis() {
assert_eq!(truncate_with_ellipsis("hello world", 6), "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 badge_parts(text: &str, color: Color, width: Option<DimMode>) -> BadgeParts {
BadgeParts { text: text.to_string(), color, anchor: None, layer: None, width, lines: None }
}
#[test]
fn prepare_badge_auto_width() {
let spec = prepare_badge(badge_parts("prod", Color::Red, None), BadgeAnchor::TopLeft, 1, None);
assert_eq!(spec.display, "prod");
assert_eq!(spec.badge_w, 6);
assert_eq!(spec.layer, 0);
}
#[test]
fn prepare_badge_fixed_width_truncates() {
let spec = prepare_badge(badge_parts("hello world", Color::Red, Some(DimMode::Fixed(10))), BadgeAnchor::TopLeft, 1, None);
assert_eq!(spec.display, "hello w…");
assert_eq!(spec.badge_w, 10);
}
#[test]
fn prepare_badge_auto_up_to() {
let spec = prepare_badge(badge_parts("prod", Color::Red, Some(DimMode::Max(20))), BadgeAnchor::TopLeft, 1, None);
assert_eq!(spec.badge_w, 6);
}
#[test]
fn prepare_badge_preserves_spaces() {
let spec = prepare_badge(
badge_parts(" spaced ", Color::Red, None),
BadgeAnchor::TopLeft, 1, None,
);
assert_eq!(spec.display, " spaced ");
}
#[test]
fn prepare_badge_respects_layer() {
let parts = BadgeParts {
text: "main".to_string(),
color: Color::Red,
anchor: Some(BadgeAnchor::TopRight),
layer: Some(2),
width: None,
lines: None,
};
let spec = prepare_badge(parts, BadgeAnchor::TopLeft, 1, None);
assert_eq!(spec.anchor, BadgeAnchor::TopRight);
assert_eq!(spec.layer, 2);
}
#[test]
fn prepare_badge_inherits_global_anchor() {
let parts = BadgeParts {
text: "main".to_string(),
color: Color::Red,
anchor: None,
layer: None,
width: None,
lines: None,
};
let spec = prepare_badge(parts, BadgeAnchor::BottomRight, 1, None);
assert_eq!(spec.anchor, BadgeAnchor::BottomRight);
assert_eq!(spec.layer, 0);
}
}