1use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack, PopupSize};
2use ratatui::style::Color;
3
4#[derive(clap::Args)]
7pub struct BadgeArgs {
8 #[arg(long = "badge", value_name = "TEXT|COLOR|ANCHOR|WxL")]
10 pub badges: Vec<String>,
11
12 #[arg(long, default_value = "0")]
14 pub badge_width: u16,
15
16 #[arg(long, default_value = "1")]
18 pub badge_lines: u16,
19
20 #[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#[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
80pub 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
152fn 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
183fn 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#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}