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