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