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
99/// Colour is off under `NO_COLOR`, `TERM=dumb`, or when stdout is not a TTY.
100pub 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    /// Export theme as CSS custom properties for the web dashboard.
307    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    /// Labeled section box top: `┌─ LABEL ──────────────────┐`
341    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    /// Labeled section box bottom: `└──────────────────────────┘`
360    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    /// Square box side: `│`
369    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    /// KPI underline using bold unicode lines, colored with the metric's color.
377    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    /// Export theme as a JS module for the web dashboard.
386    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
423/// Visual width of a string in terminal columns, ignoring ANSI escape sequences
424/// and accounting for wide characters (emoji, CJK = 2 columns).
425pub 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
443/// Pad a string to `target` visual width with spaces on the right.
444/// If the string exceeds `target`, it is visually truncated.
445pub 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
455/// Truncate a string to at most `max_cols` terminal columns,
456/// preserving ANSI escape sequences and respecting wide characters.
457pub 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
486// ---------------------------------------------------------------------------
487// Built-in presets
488// ---------------------------------------------------------------------------
489
490fn 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
674/// Count-up for percentage values (0.0 -> final_pct, displayed as "68.3%").
675pub 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
687/// Count-up for USD values (0.00 -> final_usd, displayed as "$1,289.35").
688pub 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
701/// Writes sections to stdout one by one with a delay between each, using cursor
702/// control to create a reveal effect. Skips animation when `NO_COLOR` or non-TTY.
703pub 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}