Skip to main content

lean_ctx/core/
theme.rs

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    /// Export theme as CSS custom properties for the web dashboard.
316    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    /// Labeled section box top: `┌─ LABEL ──────────────────┐`
350    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    /// Labeled section box bottom: `└──────────────────────────┘`
369    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    /// Square box side: `│`
378    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    /// KPI underline using bold unicode lines, colored with the metric's color.
386    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    /// Export theme as a JS module for the web dashboard.
395    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
432/// Visual width of a string in terminal columns, ignoring ANSI escape sequences
433/// and accounting for wide characters (emoji, CJK = 2 columns).
434pub 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
452/// Pad a string to `target` visual width with spaces on the right.
453/// If the string exceeds `target`, it is visually truncated.
454pub 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
464/// Truncate a string to at most `max_cols` terminal columns,
465/// preserving ANSI escape sequences and respecting wide characters.
466pub 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
495// ---------------------------------------------------------------------------
496// Built-in presets
497// ---------------------------------------------------------------------------
498
499fn 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
685/// Count-up for percentage values (0.0 -> final_pct, displayed as "68.3%").
686pub 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
698/// Count-up for USD values (0.00 -> final_usd, displayed as "$1,289.35").
699pub 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
712/// Writes sections to stdout one by one with a delay between each, using cursor
713/// control to create a reveal effect. Skips animation when `NO_COLOR` or non-TTY.
714pub 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}