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