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    pub muted: Color,
70    pub text: Color,
71    pub bar_start: Color,
72    pub bar_end: Color,
73    pub highlight: Color,
74    pub border: Color,
75}
76
77impl Default for Theme {
78    fn default() -> Self {
79        preset_default()
80    }
81}
82
83pub fn no_color() -> bool {
84    std::env::var("NO_COLOR").is_ok() || !std::io::stdout().is_terminal()
85}
86
87pub const RST: &str = "\x1b[0m";
88pub const BOLD: &str = "\x1b[1m";
89pub const DIM: &str = "\x1b[2m";
90
91pub fn rst() -> &'static str {
92    if no_color() {
93        ""
94    } else {
95        RST
96    }
97}
98
99pub fn bold() -> &'static str {
100    if no_color() {
101        ""
102    } else {
103        BOLD
104    }
105}
106
107pub fn dim() -> &'static str {
108    if no_color() {
109        ""
110    } else {
111        DIM
112    }
113}
114
115impl Theme {
116    pub fn pct_color(&self, pct: f64) -> String {
117        if no_color() {
118            return String::new();
119        }
120        if pct >= 90.0 {
121            self.success.fg()
122        } else if pct >= 70.0 {
123            self.secondary.fg()
124        } else if pct >= 50.0 {
125            self.warning.fg()
126        } else if pct >= 30.0 {
127            self.accent.fg()
128        } else {
129            self.muted.fg()
130        }
131    }
132
133    pub fn gradient_bar(&self, ratio: f64, width: usize) -> String {
134        let blocks = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
135        let full = (ratio * width as f64).max(0.0);
136        let whole = full as usize;
137        let frac = ((full - whole as f64) * 8.0) as usize;
138
139        if no_color() {
140            let mut s = "█".repeat(whole);
141            if whole < width && frac > 0 {
142                s.push(blocks[frac.min(7)]);
143            }
144            if s.is_empty() && ratio > 0.0 {
145                s.push('▏');
146            }
147            return s;
148        }
149
150        let mut buf = String::with_capacity(whole * 20 + 30);
151        let total_chars = if whole < width && frac > 0 {
152            whole + 1
153        } else if whole == 0 && ratio > 0.0 {
154            1
155        } else {
156            whole
157        };
158
159        for i in 0..whole {
160            let t = if total_chars > 1 {
161                i as f64 / (total_chars - 1) as f64
162            } else {
163                0.5
164            };
165            let c = self.bar_start.lerp(&self.bar_end, t);
166            buf.push_str(&c.fg());
167            buf.push('█');
168        }
169
170        if whole < width && frac > 0 {
171            let t = if total_chars > 1 {
172                whole as f64 / (total_chars - 1) as f64
173            } else {
174                1.0
175            };
176            let c = self.bar_start.lerp(&self.bar_end, t);
177            buf.push_str(&c.fg());
178            buf.push(blocks[frac.min(7)]);
179        } else if whole == 0 && ratio > 0.0 {
180            buf.push_str(&self.bar_start.fg());
181            buf.push('▏');
182        }
183
184        if !buf.is_empty() {
185            buf.push_str(RST);
186        }
187        buf
188    }
189
190    pub fn gradient_sparkline(&self, values: &[u64]) -> String {
191        let ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
192        let max = *values.iter().max().unwrap_or(&1) as f64;
193        if max == 0.0 {
194            return " ".repeat(values.len());
195        }
196
197        let nc = no_color();
198        let mut buf = String::with_capacity(values.len() * 20);
199        let len = values.len();
200
201        for (i, v) in values.iter().enumerate() {
202            let idx = ((*v as f64 / max) * 7.0).round() as usize;
203            let ch = ticks[idx.min(7)];
204            if nc {
205                buf.push(ch);
206            } else {
207                let t = if len > 1 {
208                    i as f64 / (len - 1) as f64
209                } else {
210                    0.5
211                };
212                let c = self.bar_start.lerp(&self.bar_end, t);
213                buf.push_str(&c.fg());
214                buf.push(ch);
215            }
216        }
217        if !nc && !buf.is_empty() {
218            buf.push_str(RST);
219        }
220        buf
221    }
222
223    pub fn badge(&self, _label: &str, value: &str, color: &Color) -> String {
224        if no_color() {
225            return format!(" {value:<12}");
226        }
227        format!("{bg}{BOLD} {value} {RST}", bg = color.bg(),)
228    }
229
230    pub fn border_line(&self, width: usize) -> String {
231        if no_color() {
232            return "─".repeat(width);
233        }
234        let line: String = std::iter::repeat_n('─', width).collect();
235        format!("{}{line}{RST}", self.border.fg())
236    }
237
238    pub fn box_top(&self, width: usize) -> String {
239        if no_color() {
240            let line: String = std::iter::repeat_n('─', width).collect();
241            return format!("╭{line}╮");
242        }
243        let line: String = std::iter::repeat_n('─', width).collect();
244        format!("{}╭{line}╮{RST}", self.border.fg())
245    }
246
247    pub fn box_bottom(&self, width: usize) -> String {
248        if no_color() {
249            let line: String = std::iter::repeat_n('─', width).collect();
250            return format!("╰{line}╯");
251        }
252        let line: String = std::iter::repeat_n('─', width).collect();
253        format!("{}╰{line}╯{RST}", self.border.fg())
254    }
255
256    pub fn box_mid(&self, width: usize) -> String {
257        if no_color() {
258            let line: String = std::iter::repeat_n('─', width).collect();
259            return format!("├{line}┤");
260        }
261        let line: String = std::iter::repeat_n('─', width).collect();
262        format!("{}├{line}┤{RST}", self.border.fg())
263    }
264
265    pub fn box_side(&self) -> String {
266        if no_color() {
267            return "│".to_string();
268        }
269        format!("{}│{RST}", self.border.fg())
270    }
271
272    pub fn header_icon(&self) -> String {
273        if no_color() {
274            return "◆".to_string();
275        }
276        format!("{}◆{RST}", self.accent.fg())
277    }
278
279    pub fn brand_title(&self) -> String {
280        if no_color() {
281            return "lean-ctx".to_string();
282        }
283        let p = self.primary.fg();
284        let s = self.secondary.fg();
285        format!("{p}{BOLD}lean{RST}{s}{BOLD}-ctx{RST}")
286    }
287
288    pub fn section_title(&self, title: &str) -> String {
289        if no_color() {
290            return title.to_string();
291        }
292        format!("{}{BOLD}{title}{RST}", self.text.fg())
293    }
294
295    pub fn to_toml(&self) -> String {
296        toml::to_string_pretty(self).unwrap_or_default()
297    }
298}
299
300/// Visual width of a string, ignoring ANSI escape sequences.
301pub fn visual_len(s: &str) -> usize {
302    let mut len = 0usize;
303    let mut in_escape = false;
304    for ch in s.chars() {
305        if in_escape {
306            if ch == 'm' {
307                in_escape = false;
308            }
309        } else if ch == '\x1b' {
310            in_escape = true;
311        } else {
312            len += 1;
313        }
314    }
315    len
316}
317
318/// Pad a string to `target` visual width with spaces on the right.
319pub fn pad_right(s: &str, target: usize) -> String {
320    let vlen = visual_len(s);
321    if vlen >= target {
322        s.to_string()
323    } else {
324        format!("{s}{pad}", pad = " ".repeat(target - vlen))
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Built-in presets
330// ---------------------------------------------------------------------------
331
332fn c(hex: &str) -> Color {
333    Color::Hex(hex.to_string())
334}
335
336pub fn preset_default() -> Theme {
337    Theme {
338        name: "default".into(),
339        primary: c("#36D399"),
340        secondary: c("#66CCFF"),
341        accent: c("#CC66FF"),
342        success: c("#36D399"),
343        warning: c("#FFCC33"),
344        muted: c("#888888"),
345        text: c("#F5F5F5"),
346        bar_start: c("#36D399"),
347        bar_end: c("#66CCFF"),
348        highlight: c("#FF6633"),
349        border: c("#555555"),
350    }
351}
352
353pub fn preset_neon() -> Theme {
354    Theme {
355        name: "neon".into(),
356        primary: c("#00FF88"),
357        secondary: c("#00FFFF"),
358        accent: c("#FF00FF"),
359        success: c("#00FF44"),
360        warning: c("#FFE100"),
361        muted: c("#666666"),
362        text: c("#FFFFFF"),
363        bar_start: c("#FF00FF"),
364        bar_end: c("#00FFFF"),
365        highlight: c("#FF3300"),
366        border: c("#333333"),
367    }
368}
369
370pub fn preset_ocean() -> Theme {
371    Theme {
372        name: "ocean".into(),
373        primary: c("#0EA5E9"),
374        secondary: c("#38BDF8"),
375        accent: c("#06B6D4"),
376        success: c("#22D3EE"),
377        warning: c("#F59E0B"),
378        muted: c("#64748B"),
379        text: c("#E2E8F0"),
380        bar_start: c("#0284C7"),
381        bar_end: c("#67E8F9"),
382        highlight: c("#F97316"),
383        border: c("#475569"),
384    }
385}
386
387pub fn preset_sunset() -> Theme {
388    Theme {
389        name: "sunset".into(),
390        primary: c("#F97316"),
391        secondary: c("#FB923C"),
392        accent: c("#EC4899"),
393        success: c("#F59E0B"),
394        warning: c("#EF4444"),
395        muted: c("#78716C"),
396        text: c("#FEF3C7"),
397        bar_start: c("#F97316"),
398        bar_end: c("#EC4899"),
399        highlight: c("#A855F7"),
400        border: c("#57534E"),
401    }
402}
403
404pub fn preset_monochrome() -> Theme {
405    Theme {
406        name: "monochrome".into(),
407        primary: c("#D4D4D4"),
408        secondary: c("#A3A3A3"),
409        accent: c("#E5E5E5"),
410        success: c("#D4D4D4"),
411        warning: c("#A3A3A3"),
412        muted: c("#737373"),
413        text: c("#F5F5F5"),
414        bar_start: c("#A3A3A3"),
415        bar_end: c("#E5E5E5"),
416        highlight: c("#FFFFFF"),
417        border: c("#525252"),
418    }
419}
420
421pub fn preset_cyberpunk() -> Theme {
422    Theme {
423        name: "cyberpunk".into(),
424        primary: c("#FF2D95"),
425        secondary: c("#00F0FF"),
426        accent: c("#FFE100"),
427        success: c("#00FF66"),
428        warning: c("#FF6B00"),
429        muted: c("#555577"),
430        text: c("#EEEEFF"),
431        bar_start: c("#FF2D95"),
432        bar_end: c("#FFE100"),
433        highlight: c("#00F0FF"),
434        border: c("#3D3D5C"),
435    }
436}
437
438pub const PRESET_NAMES: &[&str] = &[
439    "default",
440    "neon",
441    "ocean",
442    "sunset",
443    "monochrome",
444    "cyberpunk",
445];
446
447pub fn from_preset(name: &str) -> Option<Theme> {
448    match name {
449        "default" => Some(preset_default()),
450        "neon" => Some(preset_neon()),
451        "ocean" => Some(preset_ocean()),
452        "sunset" => Some(preset_sunset()),
453        "monochrome" => Some(preset_monochrome()),
454        "cyberpunk" => Some(preset_cyberpunk()),
455        _ => None,
456    }
457}
458
459pub fn theme_file_path() -> Option<PathBuf> {
460    dirs::home_dir().map(|h| h.join(".lean-ctx").join("theme.toml"))
461}
462
463pub fn load_theme(config_theme: &str) -> Theme {
464    if let Some(path) = theme_file_path() {
465        if path.exists() {
466            if let Ok(content) = std::fs::read_to_string(&path) {
467                if let Ok(theme) = toml::from_str::<Theme>(&content) {
468                    return theme;
469                }
470            }
471        }
472    }
473
474    from_preset(config_theme).unwrap_or_default()
475}
476
477pub fn save_theme(theme: &Theme) -> Result<(), String> {
478    let path = theme_file_path().ok_or("cannot determine home directory")?;
479    if let Some(parent) = path.parent() {
480        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
481    }
482    let content = toml::to_string_pretty(theme).map_err(|e| e.to_string())?;
483    std::fs::write(&path, content).map_err(|e| e.to_string())
484}
485
486pub fn animate_countup(final_value: u64, width: usize) -> Vec<String> {
487    let frames = 10;
488    (0..=frames)
489        .map(|f| {
490            let t = f as f64 / frames as f64;
491            let eased = t * t * (3.0 - 2.0 * t);
492            let v = (final_value as f64 * eased).round() as u64;
493            format!("{:>width$}", format_big_animated(v), width = width)
494        })
495        .collect()
496}
497
498fn format_big_animated(n: u64) -> String {
499    if n >= 1_000_000 {
500        format!("{:.1}M", n as f64 / 1_000_000.0)
501    } else if n >= 1_000 {
502        format!("{:.1}K", n as f64 / 1_000.0)
503    } else {
504        format!("{n}")
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn hex_to_rgb() {
514        let c = Color::Hex("#FF8800".into());
515        assert_eq!(c.rgb(), (255, 136, 0));
516    }
517
518    #[test]
519    fn lerp_colors() {
520        let a = Color::Hex("#000000".into());
521        let b = Color::Hex("#FF0000".into());
522        let mid = a.lerp(&b, 0.5);
523        let (r, g, bl) = mid.rgb();
524        assert!((r as i16 - 128).abs() <= 1);
525        assert_eq!(g, 0);
526        assert_eq!(bl, 0);
527    }
528
529    #[test]
530    fn gradient_bar_produces_output() {
531        let theme = preset_default();
532        let bar = theme.gradient_bar(0.5, 20);
533        assert!(!bar.is_empty());
534    }
535
536    #[test]
537    fn gradient_sparkline_produces_output() {
538        let theme = preset_default();
539        let spark = theme.gradient_sparkline(&[10, 50, 30, 80, 20]);
540        assert!(!spark.is_empty());
541        assert!(spark.chars().count() >= 5);
542    }
543
544    #[test]
545    fn all_presets_load() {
546        for name in PRESET_NAMES {
547            let t = from_preset(name);
548            assert!(t.is_some(), "preset {name} should exist");
549        }
550    }
551
552    #[test]
553    fn preset_serializes_to_toml() {
554        let t = preset_neon();
555        let toml_str = t.to_toml();
556        assert!(toml_str.contains("neon"));
557        assert!(toml_str.contains("#00FF88"));
558    }
559
560    #[test]
561    fn border_line_width() {
562        std::env::set_var("NO_COLOR", "1");
563        let theme = preset_default();
564        let line = theme.border_line(10);
565        assert_eq!(line.chars().count(), 10);
566        std::env::remove_var("NO_COLOR");
567    }
568
569    #[test]
570    fn box_top_bottom_symmetric() {
571        std::env::set_var("NO_COLOR", "1");
572        let theme = preset_default();
573        let top = theme.box_top(20);
574        let bot = theme.box_bottom(20);
575        assert_eq!(top.chars().count(), bot.chars().count());
576        std::env::remove_var("NO_COLOR");
577    }
578
579    #[test]
580    fn countup_frames() {
581        let frames = animate_countup(1000, 6);
582        assert_eq!(frames.len(), 11);
583        assert!(frames.last().unwrap().contains("1.0K"));
584    }
585
586    #[test]
587    fn visual_len_plain() {
588        assert_eq!(visual_len("hello"), 5);
589        assert_eq!(visual_len(""), 0);
590    }
591
592    #[test]
593    fn visual_len_with_ansi() {
594        assert_eq!(visual_len("\x1b[32mhello\x1b[0m"), 5);
595        assert_eq!(visual_len("\x1b[38;2;255;0;0mX\x1b[0m"), 1);
596    }
597
598    #[test]
599    fn pad_right_works() {
600        assert_eq!(pad_right("hi", 5), "hi   ");
601        assert_eq!(pad_right("hello", 3), "hello");
602        let ansi = "\x1b[32mhi\x1b[0m";
603        let padded = pad_right(ansi, 5);
604        assert_eq!(visual_len(&padded), 5);
605        assert!(padded.starts_with("\x1b[32m"));
606    }
607}