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 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
82pub 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
165fn 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
196fn 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#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}