Skip to main content

pandora_kit/
badge.rs

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