Skip to main content

pandora_kit/
badge.rs

1use std::fs;
2
3use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack, PopupSize};
4use ratatui::style::Color;
5
6// ─── JSON input types ─────────────────────────────────────
7
8#[derive(serde::Deserialize)]
9struct BadgeInput {
10    text: String,
11    #[serde(default = "default_badge_color")]
12    color: String,
13    #[serde(default)]
14    anchor: Option<String>,
15    #[serde(default)]
16    layer: Option<u16>,
17    #[serde(default)]
18    width: Option<DimInput>,
19    #[serde(default)]
20    lines: Option<DimInput>,
21}
22
23fn default_badge_color() -> String {
24    "gray".to_string()
25}
26
27#[derive(serde::Deserialize)]
28#[serde(untagged)]
29enum DimInput {
30    Number(u16),
31    String(String),
32}
33
34impl BadgeInput {
35    fn to_badge_parts(&self) -> BadgeParts {
36        BadgeParts {
37            text: self.text.trim().to_string(),
38            color: parse_color(&self.color).unwrap_or(Color::Gray),
39            anchor: self.anchor.as_deref().and_then(parse_anchor),
40            layer: self.layer,
41            width: self.width.as_ref().map(dim_input_to_mode),
42            lines: self.lines.as_ref().map(dim_input_to_mode),
43        }
44    }
45}
46
47fn dim_input_to_mode(input: &DimInput) -> DimMode {
48    match input {
49        DimInput::Number(n) => DimMode::Fixed(*n),
50        DimInput::String(s) => parse_dim(s).unwrap_or(DimMode::Auto),
51    }
52}
53
54// ─── CLI args ─────────────────────────────────────────────
55
56#[derive(clap::Args)]
57pub struct BadgeArgs {
58    /// Path to JSON file with badge definitions
59    #[arg(long = "badge", value_name = "FILE")]
60    pub badge_file: Option<String>,
61
62    /// Default badge width (0 = auto per-badge)
63    #[arg(long, default_value = "0")]
64    pub badge_width: u16,
65    /// Default content lines for each badge
66    #[arg(long, default_value = "1")]
67    pub badge_lines: u16,
68
69    /// Default anchor: tl, t, tr, bl, b, br, l, r
70    #[arg(long, default_value = "tl")]
71    pub badge_anchor: String,
72}
73
74impl BadgeArgs {
75    pub fn prepare_specs(&self) -> Vec<BadgeSpec> {
76        let file_path = match &self.badge_file {
77            Some(p) => p,
78            None => return vec![],
79        };
80
81        let content = match fs::read_to_string(file_path) {
82            Ok(c) => c,
83            Err(e) => {
84                eprintln!("{} badge: error al leer '{}': {}", crate::BIN_NAME, file_path, e);
85                std::process::exit(1);
86            }
87        };
88
89        let inputs: Vec<BadgeInput> = match serde_json::from_str(&content) {
90            Ok(v) => v,
91            Err(e) => {
92                eprintln!("{} badge: error al parsear JSON en '{}': {}", crate::BIN_NAME, file_path, e);
93                std::process::exit(1);
94            }
95        };
96
97        let global_anchor = parse_anchor(&self.badge_anchor).unwrap_or(BadgeAnchor::TopLeft);
98        let global_width = if self.badge_width > 0 { Some(DimMode::Fixed(self.badge_width)) } else { None };
99
100        inputs.iter()
101            .map(|input| {
102                let parts = input.to_badge_parts();
103                prepare_badge(parts, global_anchor, self.badge_lines, global_width)
104            })
105            .collect()
106    }
107}
108
109// ─── Types ────────────────────────────────────────────────
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DimMode {
113    Auto,
114    Max(u16),
115    Fixed(u16),
116}
117
118pub struct BadgeParts {
119    pub text: String,
120    pub color: Color,
121    pub anchor: Option<BadgeAnchor>,
122    pub layer: Option<u16>,
123    pub width: Option<DimMode>,
124    pub lines: Option<DimMode>,
125}
126
127pub struct BadgeSpec {
128    pub display: String,
129    pub color: Color,
130    pub badge_w: u16,
131    pub lines: u16,
132    pub anchor: BadgeAnchor,
133    pub layer: u16,
134}
135
136impl BadgeSpec {
137    pub fn into_badge(&self) -> Badge<'_> {
138        let mut badge = if self.lines > 1 && self.display.contains('\n') {
139            let lines: Vec<ratatui::text::Line<'_>> =
140                self.display.split('\n').map(ratatui::text::Line::from).collect();
141            Badge::with_content(lines, self.color)
142                .width(PopupSize::Fixed(self.badge_w))
143                .height(PopupSize::Fixed(self.lines + 2))
144                .closable(false)
145        } else {
146            Badge::new(&self.display, self.color)
147                .width(PopupSize::Fixed(self.badge_w))
148                .height(PopupSize::Fixed(self.lines + 2))
149                .closable(false)
150        };
151        badge = badge.anchor(self.anchor).layer(self.layer);
152        badge
153    }
154}
155
156// ─── Parsing ─────────────────────────────────────────────
157
158pub fn parse_color(s: &str) -> Option<Color> {
159    match s.to_lowercase().as_str() {
160        "green" => Some(Color::Green),
161        "red" => Some(Color::Red),
162        "yellow" => Some(Color::Yellow),
163        "blue" => Some(Color::Blue),
164        "cyan" => Some(Color::Cyan),
165        "magenta" => Some(Color::Magenta),
166        "white" => Some(Color::White),
167        "black" => Some(Color::Black),
168        "gray" | "grey" => Some(Color::Gray),
169        "darkgray" | "darkgrey" => Some(Color::DarkGray),
170        "lightred" => Some(Color::LightRed),
171        "lightgreen" => Some(Color::LightGreen),
172        "lightyellow" => Some(Color::LightYellow),
173        "lightblue" => Some(Color::LightBlue),
174        "lightmagenta" => Some(Color::LightMagenta),
175        "lightcyan" => Some(Color::LightCyan),
176        _ => None,
177    }
178}
179
180pub fn parse_anchor(s: &str) -> Option<BadgeAnchor> {
181    match s.to_lowercase().as_str() {
182        "tl" | "top-left" => Some(BadgeAnchor::TopLeft),
183        "t" | "top" => Some(BadgeAnchor::Top),
184        "tr" | "top-right" => Some(BadgeAnchor::TopRight),
185        "bl" | "bottom-left" => Some(BadgeAnchor::BottomLeft),
186        "b" | "bottom" => Some(BadgeAnchor::Bottom),
187        "br" | "bottom-right" => Some(BadgeAnchor::BottomRight),
188        "l" | "left" => Some(BadgeAnchor::Left),
189        "r" | "right" => Some(BadgeAnchor::Right),
190        _ => None,
191    }
192}
193
194pub fn parse_anchor_and_layer(s: &str) -> (Option<BadgeAnchor>, Option<u16>) {
195    if let Some((anchor_part, layer_part)) = s.split_once('@') {
196        (parse_anchor(anchor_part), layer_part.parse::<u16>().ok())
197    } else {
198        (parse_anchor(s), None)
199    }
200}
201
202fn parse_dim(s: &str) -> Option<DimMode> {
203    if s.is_empty() || s.eq_ignore_ascii_case("a") {
204        Some(DimMode::Auto)
205    } else if let Some(rest) = s.strip_suffix(['a', 'A']) {
206        rest.parse::<u16>().ok().map(DimMode::Max)
207    } else {
208        s.parse::<u16>().ok().map(DimMode::Fixed)
209    }
210}
211
212pub fn parse_dimensions(s: &str) -> (Option<DimMode>, Option<DimMode>) {
213    if s.is_empty() {
214        return (None, None);
215    }
216    if let Some((w, l)) = s.split_once('x') {
217        let w = if w.is_empty() { Some(DimMode::Auto) } else { parse_dim(w) };
218        let l = if l.is_empty() { Some(DimMode::Auto) } else { parse_dim(l) };
219        (w, l)
220    } else {
221        (parse_dim(s), None)
222    }
223}
224
225
226// ─── Text fitting ────────────────────────────────────────
227
228fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
229    if text.len() <= max_len {
230        return text.to_string();
231    }
232    let mut t: String = text.chars().take(max_len.saturating_sub(1)).collect();
233    t.push('…');
234    t
235}
236
237fn wrap_text(text: &str, content_w: usize, max_lines: u16) -> String {
238    let mut result = String::new();
239    let mut remaining = text;
240    for i in 0..max_lines as usize {
241        if remaining.is_empty() { break; }
242        let chunk_len = remaining.len().min(content_w);
243        let (chunk, rest) = remaining.split_at(chunk_len);
244        if i > 0 { result.push('\n'); }
245        if i == max_lines as usize - 1 && !rest.is_empty() {
246            let mut t: String = chunk.chars().take(content_w.saturating_sub(1)).collect();
247            t.push('…');
248            result.push_str(&t);
249        } else {
250            result.push_str(chunk);
251        }
252        remaining = rest;
253    }
254    result
255}
256
257// ─── Building ────────────────────────────────────────────
258
259fn resolve_width(dim: Option<DimMode>, global: Option<DimMode>, text_len: u16) -> u16 {
260    match dim.or(global).unwrap_or(DimMode::Auto) {
261        DimMode::Fixed(w) => w,
262        DimMode::Max(w) => (text_len + 2).min(w),
263        DimMode::Auto => text_len + 2,
264    }
265}
266
267fn resolve_lines(dim: Option<DimMode>, global_lines: u16) -> u16 {
268    match dim {
269        Some(DimMode::Fixed(n)) => n,
270        Some(DimMode::Max(n)) => n,
271        Some(DimMode::Auto) => global_lines,
272        None => global_lines,
273    }
274}
275
276pub fn prepare_badge(
277    parts: BadgeParts,
278    global_anchor: BadgeAnchor,
279    global_lines: u16,
280    global_width: Option<DimMode>,
281) -> BadgeSpec {
282    let text = parts.text.trim().to_string();
283    let anchor = parts.anchor.unwrap_or(global_anchor);
284    let layer = parts.layer.unwrap_or(0);
285    let lines = resolve_lines(parts.lines, global_lines);
286    let badge_w = resolve_width(parts.width, global_width, text.len() as u16);
287    let content_w = badge_w.saturating_sub(2) as usize;
288
289    let display = if text.len() <= content_w {
290        text
291    } else if lines > 1 {
292        wrap_text(&text, content_w, lines)
293    } else {
294        truncate_with_ellipsis(&text, content_w)
295    };
296
297    BadgeSpec { display, color: parts.color, badge_w, lines, anchor, layer }
298}
299
300pub fn build_badge_stack(specs: &[BadgeSpec]) -> BadgeStack<'_> {
301    let mut stack = BadgeStack::new();
302    for spec in specs {
303        stack = stack.push(spec.into_badge());
304    }
305    stack
306}
307
308// ─── Tests ───────────────────────────────────────────────
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    // ── parse_color ──
315
316    #[test]
317    fn parse_color_green() {
318        assert_eq!(parse_color("green"), Some(Color::Green));
319    }
320
321    #[test]
322    fn parse_color_case_insensitive() {
323        assert_eq!(parse_color("RED"), Some(Color::Red));
324        assert_eq!(parse_color("Cyan"), Some(Color::Cyan));
325    }
326
327    #[test]
328    fn parse_color_unknown_returns_none() {
329        assert_eq!(parse_color("pink"), None);
330        assert_eq!(parse_color(""), None);
331    }
332
333    #[test]
334    fn parse_color_gray_aliases() {
335        assert_eq!(parse_color("gray"), Some(Color::Gray));
336        assert_eq!(parse_color("grey"), Some(Color::Gray));
337    }
338
339    // ── parse_anchor ──
340
341    #[test]
342    fn parse_anchor_tl() {
343        assert_eq!(parse_anchor("tl"), Some(BadgeAnchor::TopLeft));
344    }
345
346    #[test]
347    fn parse_anchor_tr() {
348        assert_eq!(parse_anchor("tr"), Some(BadgeAnchor::TopRight));
349    }
350
351    #[test]
352    fn parse_anchor_bl() {
353        assert_eq!(parse_anchor("bl"), Some(BadgeAnchor::BottomLeft));
354    }
355
356    #[test]
357    fn parse_anchor_br() {
358        assert_eq!(parse_anchor("br"), Some(BadgeAnchor::BottomRight));
359    }
360
361    #[test]
362    fn parse_anchor_invalid() {
363        assert_eq!(parse_anchor("xyz"), None);
364    }
365
366    // ── parse_anchor_and_layer ──
367
368    #[test]
369    fn parse_anchor_only() {
370        let (a, l) = parse_anchor_and_layer("tl");
371        assert_eq!(a, Some(BadgeAnchor::TopLeft));
372        assert!(l.is_none());
373    }
374
375    #[test]
376    fn parse_anchor_with_layer() {
377        let (a, l) = parse_anchor_and_layer("tr@1");
378        assert_eq!(a, Some(BadgeAnchor::TopRight));
379        assert_eq!(l, Some(1));
380    }
381
382    #[test]
383    fn parse_anchor_with_layer_two() {
384        let (a, l) = parse_anchor_and_layer("bl@2");
385        assert_eq!(a, Some(BadgeAnchor::BottomLeft));
386        assert_eq!(l, Some(2));
387    }
388
389    #[test]
390    fn parse_anchor_invalid_layer_defaults() {
391        let (a, l) = parse_anchor_and_layer("tr@abc");
392        assert_eq!(a, Some(BadgeAnchor::TopRight));
393        assert!(l.is_none());
394    }
395
396    // ── parse_dimensions ──
397
398    #[test]
399    fn parse_dim_width_only() {
400        let (w, l) = parse_dimensions("20");
401        assert_eq!(w, Some(DimMode::Fixed(20)));
402        assert!(l.is_none());
403    }
404
405    #[test]
406    fn parse_dim_width_and_lines() {
407        let (w, l) = parse_dimensions("20x2");
408        assert_eq!(w, Some(DimMode::Fixed(20)));
409        assert_eq!(l, Some(DimMode::Fixed(2)));
410    }
411
412    #[test]
413    fn parse_dim_auto_up_to() {
414        let (w, l) = parse_dimensions("20a");
415        assert_eq!(w, Some(DimMode::Max(20)));
416        assert!(l.is_none());
417    }
418
419    #[test]
420    fn parse_dim_full_auto() {
421        let (w, l) = parse_dimensions("a");
422        assert_eq!(w, Some(DimMode::Auto));
423        assert!(l.is_none());
424    }
425
426    #[test]
427    fn parse_dim_both_auto_up_to() {
428        let (w, l) = parse_dimensions("20ax3a");
429        assert_eq!(w, Some(DimMode::Max(20)));
430        assert_eq!(l, Some(DimMode::Max(3)));
431    }
432
433    // ── BadgeArgs ──
434
435    #[test]
436    fn badge_args_prepare_specs_empty_when_no_file() {
437        let args = BadgeArgs {
438            badge_file: None,
439            badge_width: 0,
440            badge_lines: 1,
441            badge_anchor: "tl".to_string(),
442        };
443        let specs = args.prepare_specs();
444        assert!(specs.is_empty());
445    }
446
447    #[test]
448    fn badge_args_prepare_specs_from_json() {
449        let dir = std::env::temp_dir().join("pandora_test_badge");
450        let _ = std::fs::create_dir_all(&dir);
451        let path = dir.join("badges.json");
452        std::fs::write(&path, r#"[
453            {"text": "prod", "color": "red", "anchor": "tr"},
454            {"text": "v2.1.0", "color": "blue", "anchor": "bl"}
455        ]"#).unwrap();
456
457        let args = BadgeArgs {
458            badge_file: Some(path.to_str().unwrap().to_string()),
459            badge_width: 0,
460            badge_lines: 1,
461            badge_anchor: "tr".to_string(),
462        };
463        let specs = args.prepare_specs();
464        assert_eq!(specs.len(), 2);
465        assert_eq!(specs[0].anchor, BadgeAnchor::TopRight);
466        assert_eq!(specs[0].layer, 0);
467        assert_eq!(specs[0].display, "prod");
468
469        let _ = std::fs::remove_dir_all(&dir);
470    }
471
472    // ── BadgeInput → BadgeParts ──
473
474    #[test]
475    fn badge_input_minimal() {
476        let input: BadgeInput = serde_json::from_str(r#"{"text":"main"}"#).unwrap();
477        let parts = input.to_badge_parts();
478        assert_eq!(parts.text, "main");
479        assert_eq!(parts.color, Color::Gray);
480        assert!(parts.anchor.is_none());
481        assert!(parts.layer.is_none());
482        assert!(parts.width.is_none());
483        assert!(parts.lines.is_none());
484    }
485
486    #[test]
487    fn badge_input_all_fields() {
488        let input: BadgeInput = serde_json::from_str(
489            r#"{"text":"prod","color":"red","anchor":"tr","layer":2,"width":20,"lines":3}"#,
490        )
491        .unwrap();
492        let parts = input.to_badge_parts();
493        assert_eq!(parts.text, "prod");
494        assert_eq!(parts.color, Color::Red);
495        assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
496        assert_eq!(parts.layer, Some(2));
497        assert_eq!(parts.width, Some(DimMode::Fixed(20)));
498        assert_eq!(parts.lines, Some(DimMode::Fixed(3)));
499    }
500
501    #[test]
502    fn badge_input_color_defaults_to_gray() {
503        let input: BadgeInput = serde_json::from_str(r#"{"text":"x"}"#).unwrap();
504        assert_eq!(input.color, "gray");
505    }
506
507    #[test]
508    fn badge_input_unknown_color_falls_back_to_gray() {
509        let input: BadgeInput = serde_json::from_str(r#"{"text":"x","color":"pink"}"#).unwrap();
510        assert_eq!(input.to_badge_parts().color, Color::Gray);
511    }
512
513    #[test]
514    fn badge_input_layers_separate_from_anchor() {
515        let input: BadgeInput =
516            serde_json::from_str(r#"{"text":"x","anchor":"tr","layer":1}"#).unwrap();
517        let parts = input.to_badge_parts();
518        assert_eq!(parts.anchor, Some(BadgeAnchor::TopRight));
519        assert_eq!(parts.layer, Some(1));
520    }
521
522    // ── DimInput ──
523
524    #[test]
525    fn dim_input_number_fixed() {
526        let d: DimInput = serde_json::from_str("20").unwrap();
527        assert_eq!(dim_input_to_mode(&d), DimMode::Fixed(20));
528    }
529
530    #[test]
531    fn dim_input_string_max() {
532        let d: DimInput = serde_json::from_str(r#""20a""#).unwrap();
533        assert_eq!(dim_input_to_mode(&d), DimMode::Max(20));
534    }
535
536    #[test]
537    fn dim_input_string_auto() {
538        let d: DimInput = serde_json::from_str(r#""auto""#).unwrap();
539        assert_eq!(dim_input_to_mode(&d), DimMode::Auto);
540    }
541
542    #[test]
543    fn truncate_fits_no_change() {
544        assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
545    }
546
547    #[test]
548    fn truncate_adds_ellipsis() {
549        assert_eq!(truncate_with_ellipsis("hello world", 6), "hello…");
550    }
551
552    // ── wrap_text ──
553
554    #[test]
555    fn wrap_text_single_line() {
556        assert_eq!(wrap_text("hello", 10, 1), "hello");
557    }
558
559    #[test]
560    fn wrap_text_multi_chunks() {
561        let result = wrap_text("hello world", 5, 3);
562        assert_eq!(result, "hello\n worl\nd");
563    }
564
565    #[test]
566    fn wrap_text_last_line_ellipsis() {
567        let result = wrap_text("hello world foo", 5, 2);
568        assert_eq!(result, "hello\n wor…");
569    }
570
571    // ── prepare_badge ──
572
573    fn badge_parts(text: &str, color: Color, width: Option<DimMode>) -> BadgeParts {
574        BadgeParts { text: text.to_string(), color, anchor: None, layer: None, width, lines: None }
575    }
576
577    #[test]
578    fn prepare_badge_auto_width() {
579        let spec = prepare_badge(badge_parts("prod", Color::Red, None), BadgeAnchor::TopLeft, 1, None);
580        assert_eq!(spec.display, "prod");
581        assert_eq!(spec.badge_w, 6);
582        assert_eq!(spec.layer, 0);
583    }
584
585    #[test]
586    fn prepare_badge_fixed_width_truncates() {
587        let spec = prepare_badge(badge_parts("hello world", Color::Red, Some(DimMode::Fixed(10))), BadgeAnchor::TopLeft, 1, None);
588        assert_eq!(spec.display, "hello w…");
589        assert_eq!(spec.badge_w, 10);
590    }
591
592    #[test]
593    fn prepare_badge_auto_up_to() {
594        let spec = prepare_badge(badge_parts("prod", Color::Red, Some(DimMode::Max(20))), BadgeAnchor::TopLeft, 1, None);
595        assert_eq!(spec.badge_w, 6);
596    }
597
598    #[test]
599    fn prepare_badge_trims_input() {
600        let trimmed = prepare_badge(
601            badge_parts("  spaced  ", Color::Red, None),
602            BadgeAnchor::TopLeft, 1, None,
603        );
604        assert_eq!(trimmed.display, "spaced");
605    }
606
607    #[test]
608    fn prepare_badge_respects_layer() {
609        let parts = BadgeParts {
610            text: "main".to_string(),
611            color: Color::Red,
612            anchor: Some(BadgeAnchor::TopRight),
613            layer: Some(2),
614            width: None,
615            lines: None,
616        };
617        let spec = prepare_badge(parts, BadgeAnchor::TopLeft, 1, None);
618        assert_eq!(spec.anchor, BadgeAnchor::TopRight);
619        assert_eq!(spec.layer, 2);
620    }
621
622    #[test]
623    fn prepare_badge_inherits_global_anchor() {
624        let parts = BadgeParts {
625            text: "main".to_string(),
626            color: Color::Red,
627            anchor: None,
628            layer: None,
629            width: None,
630            lines: None,
631        };
632        let spec = prepare_badge(parts, BadgeAnchor::BottomRight, 1, None);
633        assert_eq!(spec.anchor, BadgeAnchor::BottomRight);
634        assert_eq!(spec.layer, 0);
635    }
636
637}
638
639