1use serde::{Deserialize, Serialize};
2use std::io::IsTerminal;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(untagged)]
7pub enum Color {
8 Hex(String),
9}
10
11impl Color {
12 pub fn rgb(&self) -> (u8, u8, u8) {
13 let Color::Hex(hex) = self;
14 let hex = hex.trim_start_matches('#');
15 if hex.len() < 6 {
16 return (255, 255, 255);
17 }
18 let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255);
19 let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255);
20 let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255);
21 (r, g, b)
22 }
23
24 pub fn fg(&self) -> String {
25 if no_color() {
26 return String::new();
27 }
28 let (r, g, b) = self.rgb();
29 format!("\x1b[38;2;{r};{g};{b}m")
30 }
31
32 pub fn bg(&self) -> String {
33 if no_color() {
34 return String::new();
35 }
36 let (r, g, b) = self.rgb();
37 format!("\x1b[48;2;{r};{g};{b}m")
38 }
39
40 fn lerp_channel(a: u8, b: u8, t: f64) -> u8 {
41 (a as f64 + (b as f64 - a as f64) * t).round() as u8
42 }
43
44 pub fn lerp(&self, other: &Color, t: f64) -> Color {
45 let (r1, g1, b1) = self.rgb();
46 let (r2, g2, b2) = other.rgb();
47 let r = Self::lerp_channel(r1, r2, t);
48 let g = Self::lerp_channel(g1, g2, t);
49 let b = Self::lerp_channel(b1, b2, t);
50 Color::Hex(format!("#{r:02X}{g:02X}{b:02X}"))
51 }
52}
53
54impl Default for Color {
55 fn default() -> Self {
56 Color::Hex("#FFFFFF".to_string())
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct Theme {
63 pub name: String,
64 pub primary: Color,
65 pub secondary: Color,
66 pub accent: Color,
67 pub success: Color,
68 pub warning: Color,
69 #[serde(default = "default_danger")]
70 pub danger: Color,
71 pub muted: Color,
72 pub text: Color,
73 #[serde(default = "default_surface")]
74 pub surface: Color,
75 #[serde(default = "default_background")]
76 pub background: Color,
77 pub bar_start: Color,
78 pub bar_end: Color,
79 pub highlight: Color,
80 pub border: Color,
81}
82
83fn default_danger() -> Color {
84 Color::Hex("#EF4444".to_string())
85}
86fn default_surface() -> Color {
87 Color::Hex("#0A0A12".to_string())
88}
89fn default_background() -> Color {
90 Color::Hex("#06060A".to_string())
91}
92
93impl Default for Theme {
94 fn default() -> Self {
95 preset_default()
96 }
97}
98
99pub fn no_color() -> bool {
100 std::env::var("NO_COLOR").is_ok() || !std::io::stdout().is_terminal()
101}
102
103pub const RST: &str = "\x1b[0m";
104pub const BOLD: &str = "\x1b[1m";
105pub const DIM: &str = "\x1b[2m";
106
107pub fn rst() -> &'static str {
108 if no_color() { "" } else { RST }
109}
110
111pub fn bold() -> &'static str {
112 if no_color() { "" } else { BOLD }
113}
114
115pub fn dim() -> &'static str {
116 if no_color() { "" } else { DIM }
117}
118
119impl Theme {
120 pub fn pct_color(&self, pct: f64) -> String {
121 if no_color() {
122 return String::new();
123 }
124 if pct >= 90.0 {
125 self.success.fg()
126 } else if pct >= 70.0 {
127 self.secondary.fg()
128 } else if pct >= 50.0 {
129 self.warning.fg()
130 } else if pct >= 30.0 {
131 self.accent.fg()
132 } else {
133 self.muted.fg()
134 }
135 }
136
137 pub fn gradient_bar(&self, ratio: f64, width: usize) -> String {
138 let blocks = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
139 let full = (ratio * width as f64).max(0.0);
140 let whole = full as usize;
141 let frac = ((full - whole as f64) * 8.0) as usize;
142
143 if no_color() {
144 let mut s = "█".repeat(whole);
145 if whole < width && frac > 0 {
146 s.push(blocks[frac.min(7)]);
147 }
148 if s.is_empty() && ratio > 0.0 {
149 s.push('▏');
150 }
151 return s;
152 }
153
154 let mut buf = String::with_capacity(whole * 20 + 30);
155 let total_chars = if whole < width && frac > 0 {
156 whole + 1
157 } else if whole == 0 && ratio > 0.0 {
158 1
159 } else {
160 whole
161 };
162
163 for i in 0..whole {
164 let t = if total_chars > 1 {
165 i as f64 / (total_chars - 1) as f64
166 } else {
167 0.5
168 };
169 let c = self.bar_start.lerp(&self.bar_end, t);
170 buf.push_str(&c.fg());
171 buf.push('█');
172 }
173
174 if whole < width && frac > 0 {
175 let t = if total_chars > 1 {
176 whole as f64 / (total_chars - 1) as f64
177 } else {
178 1.0
179 };
180 let c = self.bar_start.lerp(&self.bar_end, t);
181 buf.push_str(&c.fg());
182 buf.push(blocks[frac.min(7)]);
183 } else if whole == 0 && ratio > 0.0 {
184 buf.push_str(&self.bar_start.fg());
185 buf.push('▏');
186 }
187
188 if !buf.is_empty() {
189 buf.push_str(RST);
190 }
191 buf
192 }
193
194 pub fn gradient_sparkline(&self, values: &[u64]) -> String {
195 let ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
196 let max = *values.iter().max().unwrap_or(&1) as f64;
197 if max == 0.0 {
198 return " ".repeat(values.len());
199 }
200
201 let nc = no_color();
202 let mut buf = String::with_capacity(values.len() * 20);
203 let len = values.len();
204
205 for (i, v) in values.iter().enumerate() {
206 let idx = ((*v as f64 / max) * 7.0).round() as usize;
207 let ch = ticks[idx.min(7)];
208 if nc {
209 buf.push(ch);
210 } else {
211 let t = if len > 1 {
212 i as f64 / (len - 1) as f64
213 } else {
214 0.5
215 };
216 let c = self.bar_start.lerp(&self.bar_end, t);
217 buf.push_str(&c.fg());
218 buf.push(ch);
219 }
220 }
221 if !nc && !buf.is_empty() {
222 buf.push_str(RST);
223 }
224 buf
225 }
226
227 pub fn badge(&self, _label: &str, value: &str, color: &Color) -> String {
228 if no_color() {
229 return format!(" {value:<12}");
230 }
231 format!("{bg}{BOLD} {value} {RST}", bg = color.bg())
232 }
233
234 pub fn border_line(&self, width: usize) -> String {
235 if no_color() {
236 return "─".repeat(width);
237 }
238 let line: String = std::iter::repeat_n('─', width).collect();
239 format!("{}{line}{RST}", self.border.fg())
240 }
241
242 pub fn box_top(&self, width: usize) -> String {
243 if no_color() {
244 let line: String = std::iter::repeat_n('─', width).collect();
245 return format!("╭{line}╮");
246 }
247 let line: String = std::iter::repeat_n('─', width).collect();
248 format!("{}╭{line}╮{RST}", self.border.fg())
249 }
250
251 pub fn box_bottom(&self, width: usize) -> String {
252 if no_color() {
253 let line: String = std::iter::repeat_n('─', width).collect();
254 return format!("╰{line}╯");
255 }
256 let line: String = std::iter::repeat_n('─', width).collect();
257 format!("{}╰{line}╯{RST}", self.border.fg())
258 }
259
260 pub fn box_mid(&self, width: usize) -> String {
261 if no_color() {
262 let line: String = std::iter::repeat_n('─', width).collect();
263 return format!("├{line}┤");
264 }
265 let line: String = std::iter::repeat_n('─', width).collect();
266 format!("{}├{line}┤{RST}", self.border.fg())
267 }
268
269 pub fn box_side(&self) -> String {
270 if no_color() {
271 return "│".to_string();
272 }
273 format!("{}│{RST}", self.border.fg())
274 }
275
276 pub fn header_icon(&self) -> String {
277 if no_color() {
278 return "◆".to_string();
279 }
280 format!("{}◆{RST}", self.accent.fg())
281 }
282
283 pub fn brand_title(&self) -> String {
284 if no_color() {
285 return "lean-ctx".to_string();
286 }
287 let p = self.primary.fg();
288 let s = self.secondary.fg();
289 format!("{p}{BOLD}lean{RST}{s}{BOLD}-ctx{RST}")
290 }
291
292 pub fn section_title(&self, title: &str) -> String {
293 if no_color() {
294 return title.to_string();
295 }
296 format!("{}{BOLD}{title}{RST}", self.text.fg())
297 }
298
299 pub fn to_toml(&self) -> String {
300 toml::to_string_pretty(self).unwrap_or_default()
301 }
302
303 pub fn to_css_vars(&self) -> String {
305 let Color::Hex(ref primary) = self.primary;
306 let Color::Hex(ref secondary) = self.secondary;
307 let Color::Hex(ref accent) = self.accent;
308 let Color::Hex(ref success) = self.success;
309 let Color::Hex(ref warning) = self.warning;
310 let Color::Hex(ref danger) = self.danger;
311 let Color::Hex(ref muted) = self.muted;
312 let Color::Hex(ref text) = self.text;
313 let Color::Hex(ref surface) = self.surface;
314 let Color::Hex(ref background) = self.background;
315 let Color::Hex(ref bar_start) = self.bar_start;
316 let Color::Hex(ref bar_end) = self.bar_end;
317 let Color::Hex(ref border) = self.border;
318 format!(
319 ":root {{\n\
320 \x20 --lctx-primary: {primary};\n\
321 \x20 --lctx-secondary: {secondary};\n\
322 \x20 --lctx-accent: {accent};\n\
323 \x20 --lctx-success: {success};\n\
324 \x20 --lctx-warning: {warning};\n\
325 \x20 --lctx-danger: {danger};\n\
326 \x20 --lctx-muted: {muted};\n\
327 \x20 --lctx-text: {text};\n\
328 \x20 --lctx-surface: {surface};\n\
329 \x20 --lctx-background: {background};\n\
330 \x20 --lctx-bar-start: {bar_start};\n\
331 \x20 --lctx-bar-end: {bar_end};\n\
332 \x20 --lctx-border: {border};\n\
333 }}"
334 )
335 }
336
337 pub fn box_top_labeled(&self, width: usize, label: &str) -> String {
339 let max_label = width.saturating_sub(4);
340 let label_display = if visual_len(label) > max_label {
341 truncate_visual(label, max_label)
342 } else {
343 label.to_string()
344 };
345 let label_part = format!("─ {label_display} ");
346 let remaining = width.saturating_sub(visual_len(&label_part));
347 let fill: String = std::iter::repeat_n('─', remaining).collect();
348 if no_color() {
349 return format!("┌{label_part}{fill}┐");
350 }
351 let a = self.accent.fg();
352 let b = self.border.fg();
353 format!("{b}┌─ {a}{BOLD}{label_display}{RST}{b} {fill}┐{RST}")
354 }
355
356 pub fn box_bottom_square(&self, width: usize) -> String {
358 let line: String = std::iter::repeat_n('─', width).collect();
359 if no_color() {
360 return format!("└{line}┘");
361 }
362 format!("{}└{line}┘{RST}", self.border.fg())
363 }
364
365 pub fn box_side_square(&self) -> String {
367 if no_color() {
368 return "│".to_string();
369 }
370 format!("{}│{RST}", self.border.fg())
371 }
372
373 pub fn kpi_underline(&self, width: usize, color: &Color) -> String {
375 let line: String = std::iter::repeat_n('━', width).collect();
376 if no_color() {
377 return line;
378 }
379 format!("{}{line}{RST}", color.fg())
380 }
381
382 pub fn to_js_tokens(&self) -> String {
384 let Color::Hex(ref primary) = self.primary;
385 let Color::Hex(ref secondary) = self.secondary;
386 let Color::Hex(ref accent) = self.accent;
387 let Color::Hex(ref success) = self.success;
388 let Color::Hex(ref warning) = self.warning;
389 let Color::Hex(ref danger) = self.danger;
390 let Color::Hex(ref muted) = self.muted;
391 let Color::Hex(ref text) = self.text;
392 let Color::Hex(ref surface) = self.surface;
393 let Color::Hex(ref background) = self.background;
394 let Color::Hex(ref bar_start) = self.bar_start;
395 let Color::Hex(ref bar_end) = self.bar_end;
396 let Color::Hex(ref border) = self.border;
397 format!(
398 "// Auto-generated by lean-ctx — do not edit manually\n\
399 export const tokens = {{\n\
400 \x20 name: \"{name}\",\n\
401 \x20 primary: \"{primary}\",\n\
402 \x20 secondary: \"{secondary}\",\n\
403 \x20 accent: \"{accent}\",\n\
404 \x20 success: \"{success}\",\n\
405 \x20 warning: \"{warning}\",\n\
406 \x20 danger: \"{danger}\",\n\
407 \x20 muted: \"{muted}\",\n\
408 \x20 text: \"{text}\",\n\
409 \x20 surface: \"{surface}\",\n\
410 \x20 background: \"{background}\",\n\
411 \x20 barStart: \"{bar_start}\",\n\
412 \x20 barEnd: \"{bar_end}\",\n\
413 \x20 border: \"{border}\",\n\
414 }};\n",
415 name = self.name,
416 )
417 }
418}
419
420pub fn visual_len(s: &str) -> usize {
423 use unicode_width::UnicodeWidthChar;
424 let mut len = 0usize;
425 let mut in_escape = false;
426 for ch in s.chars() {
427 if in_escape {
428 if ch == 'm' {
429 in_escape = false;
430 }
431 } else if ch == '\x1b' {
432 in_escape = true;
433 } else {
434 len += UnicodeWidthChar::width(ch).unwrap_or(0);
435 }
436 }
437 len
438}
439
440pub fn pad_right(s: &str, target: usize) -> String {
443 use std::cmp::Ordering;
444 let vlen = visual_len(s);
445 match vlen.cmp(&target) {
446 Ordering::Equal => s.to_string(),
447 Ordering::Less => format!("{s}{pad}", pad = " ".repeat(target - vlen)),
448 Ordering::Greater => truncate_visual(s, target),
449 }
450}
451
452pub fn truncate_visual(s: &str, max_cols: usize) -> String {
455 use unicode_width::UnicodeWidthChar;
456 let mut out = String::with_capacity(s.len());
457 let mut cols = 0usize;
458 let mut in_escape = false;
459 for ch in s.chars() {
460 if in_escape {
461 out.push(ch);
462 if ch == 'm' {
463 in_escape = false;
464 }
465 } else if ch == '\x1b' {
466 in_escape = true;
467 out.push(ch);
468 } else {
469 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
470 if cols + w > max_cols {
471 break;
472 }
473 cols += w;
474 out.push(ch);
475 }
476 }
477 if cols < max_cols {
478 out.push_str(&" ".repeat(max_cols - cols));
479 }
480 out
481}
482
483fn c(hex: &str) -> Color {
488 Color::Hex(hex.to_string())
489}
490
491pub fn preset_default() -> Theme {
492 Theme {
493 name: "default".into(),
494 primary: c("#36D399"),
495 secondary: c("#66CCFF"),
496 accent: c("#CC66FF"),
497 success: c("#36D399"),
498 warning: c("#FFCC33"),
499 danger: c("#EF4444"),
500 muted: c("#888888"),
501 text: c("#F5F5F5"),
502 surface: c("#0A0A12"),
503 background: c("#06060A"),
504 bar_start: c("#36D399"),
505 bar_end: c("#66CCFF"),
506 highlight: c("#FF6633"),
507 border: c("#555555"),
508 }
509}
510
511pub fn preset_neon() -> Theme {
512 Theme {
513 name: "neon".into(),
514 primary: c("#00FF88"),
515 secondary: c("#00FFFF"),
516 accent: c("#FF00FF"),
517 success: c("#00FF44"),
518 warning: c("#FFE100"),
519 danger: c("#FF3300"),
520 muted: c("#666666"),
521 text: c("#FFFFFF"),
522 surface: c("#0D0D1A"),
523 background: c("#050510"),
524 bar_start: c("#FF00FF"),
525 bar_end: c("#00FFFF"),
526 highlight: c("#FF3300"),
527 border: c("#333333"),
528 }
529}
530
531pub fn preset_ocean() -> Theme {
532 Theme {
533 name: "ocean".into(),
534 primary: c("#0EA5E9"),
535 secondary: c("#38BDF8"),
536 accent: c("#06B6D4"),
537 success: c("#22D3EE"),
538 warning: c("#F59E0B"),
539 danger: c("#EF4444"),
540 muted: c("#64748B"),
541 text: c("#E2E8F0"),
542 surface: c("#0C1524"),
543 background: c("#060D18"),
544 bar_start: c("#0284C7"),
545 bar_end: c("#67E8F9"),
546 highlight: c("#F97316"),
547 border: c("#475569"),
548 }
549}
550
551pub fn preset_sunset() -> Theme {
552 Theme {
553 name: "sunset".into(),
554 primary: c("#F97316"),
555 secondary: c("#FB923C"),
556 accent: c("#EC4899"),
557 success: c("#F59E0B"),
558 warning: c("#EF4444"),
559 danger: c("#DC2626"),
560 muted: c("#78716C"),
561 text: c("#FEF3C7"),
562 surface: c("#1C1410"),
563 background: c("#0F0A08"),
564 bar_start: c("#F97316"),
565 bar_end: c("#EC4899"),
566 highlight: c("#A855F7"),
567 border: c("#57534E"),
568 }
569}
570
571pub fn preset_monochrome() -> Theme {
572 Theme {
573 name: "monochrome".into(),
574 primary: c("#D4D4D4"),
575 secondary: c("#A3A3A3"),
576 accent: c("#E5E5E5"),
577 success: c("#D4D4D4"),
578 warning: c("#A3A3A3"),
579 danger: c("#737373"),
580 muted: c("#737373"),
581 text: c("#F5F5F5"),
582 surface: c("#141414"),
583 background: c("#0A0A0A"),
584 bar_start: c("#A3A3A3"),
585 bar_end: c("#E5E5E5"),
586 highlight: c("#FFFFFF"),
587 border: c("#525252"),
588 }
589}
590
591pub fn preset_cyberpunk() -> Theme {
592 Theme {
593 name: "cyberpunk".into(),
594 primary: c("#FF2D95"),
595 secondary: c("#00F0FF"),
596 accent: c("#FFE100"),
597 success: c("#00FF66"),
598 warning: c("#FF6B00"),
599 danger: c("#FF0033"),
600 muted: c("#555577"),
601 text: c("#EEEEFF"),
602 surface: c("#12122A"),
603 background: c("#080816"),
604 bar_start: c("#FF2D95"),
605 bar_end: c("#FFE100"),
606 highlight: c("#00F0FF"),
607 border: c("#3D3D5C"),
608 }
609}
610
611pub const PRESET_NAMES: &[&str] = &[
612 "default",
613 "neon",
614 "ocean",
615 "sunset",
616 "monochrome",
617 "cyberpunk",
618];
619
620pub fn from_preset(name: &str) -> Option<Theme> {
621 match name {
622 "default" => Some(preset_default()),
623 "neon" => Some(preset_neon()),
624 "ocean" => Some(preset_ocean()),
625 "sunset" => Some(preset_sunset()),
626 "monochrome" => Some(preset_monochrome()),
627 "cyberpunk" => Some(preset_cyberpunk()),
628 _ => None,
629 }
630}
631
632pub fn theme_file_path() -> Option<PathBuf> {
633 crate::core::data_dir::lean_ctx_data_dir()
634 .ok()
635 .map(|d| d.join("theme.toml"))
636}
637
638pub fn load_theme(config_theme: &str) -> Theme {
639 if let Some(path) = theme_file_path()
640 && path.exists()
641 && let Ok(content) = std::fs::read_to_string(&path)
642 && let Ok(theme) = toml::from_str::<Theme>(&content)
643 {
644 return theme;
645 }
646
647 from_preset(config_theme).unwrap_or_default()
648}
649
650pub fn save_theme(theme: &Theme) -> Result<(), String> {
651 let path = theme_file_path().ok_or("cannot determine home directory")?;
652 if let Some(parent) = path.parent() {
653 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
654 }
655 let content = toml::to_string_pretty(theme).map_err(|e| e.to_string())?;
656 std::fs::write(&path, content).map_err(|e| e.to_string())
657}
658
659pub fn animate_countup(final_value: u64, width: usize) -> Vec<String> {
660 let frames = 10;
661 (0..=frames)
662 .map(|f| {
663 let t = f as f64 / frames as f64;
664 let eased = t * t * (3.0 - 2.0 * t);
665 let v = (final_value as f64 * eased).round() as u64;
666 format!("{:>width$}", format_big_animated(v), width = width)
667 })
668 .collect()
669}
670
671pub fn animate_countup_pct(final_pct: f64, width: usize) -> Vec<String> {
673 let frames = 10;
674 (0..=frames)
675 .map(|f| {
676 let t = f as f64 / frames as f64;
677 let eased = t * t * (3.0 - 2.0 * t);
678 let v = final_pct * eased;
679 format!("{:>width$}", format!("{v:.1}%"), width = width)
680 })
681 .collect()
682}
683
684pub fn animate_countup_usd(final_usd: f64, width: usize) -> Vec<String> {
686 let frames = 10;
687 (0..=frames)
688 .map(|f| {
689 let t = f as f64 / frames as f64;
690 let eased = t * t * (3.0 - 2.0 * t);
691 let v = final_usd * eased;
692 let formatted = format!("${v:.2}");
693 format!("{formatted:>width$}")
694 })
695 .collect()
696}
697
698pub fn animate_section_reveal(sections: &[String], delay_ms: u64) {
701 use std::io::Write;
702 let is_tty = std::io::stdout().is_terminal();
703 if no_color() || !is_tty || delay_ms == 0 {
704 for s in sections {
705 println!("{s}");
706 }
707 return;
708 }
709 let mut stdout = std::io::stdout();
710 for s in sections {
711 let _ = writeln!(stdout, "{s}");
712 let _ = stdout.flush();
713 std::thread::sleep(std::time::Duration::from_millis(delay_ms));
714 }
715}
716
717fn format_big_animated(n: u64) -> String {
718 if n >= 1_000_000 {
719 format!("{:.1}M", n as f64 / 1_000_000.0)
720 } else if n >= 1_000 {
721 format!("{:.1}K", n as f64 / 1_000.0)
722 } else {
723 format!("{n}")
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn hex_to_rgb() {
733 let c = Color::Hex("#FF8800".into());
734 assert_eq!(c.rgb(), (255, 136, 0));
735 }
736
737 #[test]
738 fn lerp_colors() {
739 let a = Color::Hex("#000000".into());
740 let b = Color::Hex("#FF0000".into());
741 let mid = a.lerp(&b, 0.5);
742 let (r, g, bl) = mid.rgb();
743 assert!((r as i16 - 128).abs() <= 1);
744 assert_eq!(g, 0);
745 assert_eq!(bl, 0);
746 }
747
748 #[test]
749 fn gradient_bar_produces_output() {
750 let theme = preset_default();
751 let bar = theme.gradient_bar(0.5, 20);
752 assert!(!bar.is_empty());
753 }
754
755 #[test]
756 fn gradient_sparkline_produces_output() {
757 let theme = preset_default();
758 let spark = theme.gradient_sparkline(&[10, 50, 30, 80, 20]);
759 assert!(!spark.is_empty());
760 assert!(spark.chars().count() >= 5);
761 }
762
763 #[test]
764 fn all_presets_load() {
765 for name in PRESET_NAMES {
766 let t = from_preset(name);
767 assert!(t.is_some(), "preset {name} should exist");
768 }
769 }
770
771 #[test]
772 fn preset_serializes_to_toml() {
773 let t = preset_neon();
774 let toml_str = t.to_toml();
775 assert!(toml_str.contains("neon"));
776 assert!(toml_str.contains("#00FF88"));
777 }
778
779 #[test]
780 fn border_line_width() {
781 crate::test_env::set_var("NO_COLOR", "1");
782 let theme = preset_default();
783 let line = theme.border_line(10);
784 assert_eq!(line.chars().count(), 10);
785 crate::test_env::remove_var("NO_COLOR");
786 }
787
788 #[test]
789 fn box_top_bottom_symmetric() {
790 crate::test_env::set_var("NO_COLOR", "1");
791 let theme = preset_default();
792 let top = theme.box_top(20);
793 let bot = theme.box_bottom(20);
794 assert_eq!(top.chars().count(), bot.chars().count());
795 crate::test_env::remove_var("NO_COLOR");
796 }
797
798 #[test]
799 fn countup_frames() {
800 let frames = animate_countup(1000, 6);
801 assert_eq!(frames.len(), 11);
802 assert!(frames.last().unwrap().contains("1.0K"));
803 }
804
805 #[test]
806 fn visual_len_plain() {
807 assert_eq!(visual_len("hello"), 5);
808 assert_eq!(visual_len(""), 0);
809 }
810
811 #[test]
812 fn visual_len_with_ansi() {
813 assert_eq!(visual_len("\x1b[32mhello\x1b[0m"), 5);
814 assert_eq!(visual_len("\x1b[38;2;255;0;0mX\x1b[0m"), 1);
815 }
816
817 #[test]
818 fn pad_right_works() {
819 assert_eq!(pad_right("hi", 5), "hi ");
820 assert_eq!(pad_right("hello", 3), "hel");
821 assert_eq!(visual_len(&pad_right("hello", 3)), 3);
822 let ansi = "\x1b[32mhi\x1b[0m";
823 let padded = pad_right(ansi, 5);
824 assert_eq!(visual_len(&padded), 5);
825 assert!(padded.starts_with("\x1b[32m"));
826 }
827}