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
300pub 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
318pub 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
328fn 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}