Skip to main content

pandora_kit/
badge.rs

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