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