1use crate::tui::Theme;
2use crate::tui::components::markdown::{MarkdownTheme, StyleFn, create_highlight_fn};
3use serde::Deserialize;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::AtomicU16;
8
9#[derive(Debug, Clone, Deserialize)]
14#[serde(untagged)]
15pub enum ColorValue {
16 HexOrVar(String),
17 Index(u8),
18}
19
20#[derive(Debug, Clone, Deserialize)]
22pub struct ThemeConfig {
23 pub name: String,
24 #[serde(default)]
25 pub vars: HashMap<String, String>,
26 pub colors: HashMap<String, ColorValue>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum ColorMode {
32 TrueColor,
33 Ansi256,
34}
35
36#[derive(Debug, Clone)]
41pub struct RabTheme {
42 pub name: String,
43 mode: ColorMode,
44 fg_ansi: HashMap<String, String>,
45 bg_ansi: HashMap<String, String>,
46}
47
48impl RabTheme {
49 fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
51 let hex = hex.trim_start_matches('#');
52 if hex.len() != 6 {
53 return None;
54 }
55 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
56 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
57 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
58 Some((r, g, b))
59 }
60
61 fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
63 const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
64 const GRAY_VALUES: [u8; 24] = [
65 8, 18, 28, 38, 48, 58, 68, 78, 88, 98, 108, 118, 128, 138, 148, 158, 168, 178, 188,
66 198, 208, 218, 228, 238,
67 ];
68
69 let find_closest = |value: u8, table: &[u8]| -> usize {
70 let mut min_dist = u16::MAX;
71 let mut min_idx = 0;
72 for (i, &v) in table.iter().enumerate() {
73 let dist = value.abs_diff(v);
74 if (dist as u16) < min_dist {
75 min_dist = dist as u16;
76 min_idx = i;
77 }
78 }
79 min_idx
80 };
81
82 let ri = find_closest(r, &CUBE_VALUES);
83 let gi = find_closest(g, &CUBE_VALUES);
84 let bi = find_closest(b, &CUBE_VALUES);
85 let cube_index = 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8;
86
87 let gray = (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000;
89 let gi = find_closest(gray as u8, &GRAY_VALUES);
90 let gray_index = 232 + gi as u8;
91
92 let spread = r.max(g).max(b) - r.min(g).min(b);
93 if spread < 10 {
94 return gray_index;
95 }
96 cube_index
97 }
98
99 fn fg_escape(color: &str, mode: ColorMode) -> String {
101 if color.is_empty() {
102 return "\x1b[39m".to_string();
103 }
104 if let Ok(idx) = color.parse::<u8>() {
105 return format!("\x1b[38;5;{}m", idx);
106 }
107 if let Some((r, g, b)) = Self::hex_to_rgb(color) {
108 return match mode {
109 ColorMode::TrueColor => format!("\x1b[38;2;{};{};{}m", r, g, b),
110 ColorMode::Ansi256 => format!("\x1b[38;5;{}m", Self::rgb_to_256(r, g, b)),
111 };
112 }
113 "\x1b[39m".to_string()
114 }
115
116 fn bg_escape(color: &str, mode: ColorMode) -> String {
118 if color.is_empty() {
119 return "\x1b[49m".to_string();
120 }
121 if let Ok(idx) = color.parse::<u8>() {
122 return format!("\x1b[48;5;{}m", idx);
123 }
124 if let Some((r, g, b)) = Self::hex_to_rgb(color) {
125 return match mode {
126 ColorMode::TrueColor => format!("\x1b[48;2;{};{};{}m", r, g, b),
127 ColorMode::Ansi256 => format!("\x1b[48;5;{}m", Self::rgb_to_256(r, g, b)),
128 };
129 }
130 "\x1b[49m".to_string()
131 }
132
133 fn resolve_colors(config: &ThemeConfig) -> HashMap<String, String> {
135 let mut resolved: HashMap<String, String> = HashMap::new();
136
137 for (name, value) in &config.colors {
138 let hex = match value {
139 ColorValue::HexOrVar(s) => {
140 if s.starts_with('#') {
141 s.clone()
142 } else if let Some(v) = config.vars.get(s) {
143 v.clone()
144 } else {
145 s.clone()
146 }
147 }
148 ColorValue::Index(idx) => idx.to_string(),
149 };
150 resolved.insert(name.clone(), hex);
151 }
152 resolved
153 }
154
155 const BG_KEYS: &'static [&'static str] = &[
157 "selectedBg",
158 "userMessageBg",
159 "customMessageBg",
160 "toolPendingBg",
161 "toolSuccessBg",
162 "toolErrorBg",
163 "thinking_bg",
164 ];
165
166 pub fn from_config(config: &ThemeConfig, mode: ColorMode) -> Self {
168 let colors = Self::resolve_colors(config);
169
170 let mut fg_ansi = HashMap::new();
171 let mut bg_ansi = HashMap::new();
172
173 for (key, value) in &colors {
174 if Self::BG_KEYS.contains(&key.as_str()) {
175 bg_ansi.insert(key.clone(), Self::bg_escape(value, mode));
176 } else {
177 fg_ansi.insert(key.clone(), Self::fg_escape(value, mode));
178 }
179 }
180
181 if let Some(text_color) = colors.get("thinkingText")
183 && !bg_ansi.contains_key("thinking_bg")
184 {
185 let bg_color = if let Some((r, g, b)) = Self::hex_to_rgb(text_color) {
187 let dr = (r as f64 * 0.7) as u8;
188 let dg = (g as f64 * 0.7) as u8;
189 let db = (b as f64 * 0.7) as u8;
190 format!("#{:02x}{:02x}{:02x}", dr, dg, db)
191 } else {
192 text_color.clone()
193 };
194 bg_ansi.insert("thinking_bg".to_string(), Self::bg_escape(&bg_color, mode));
195 }
196
197 Self {
198 name: config.name.clone(),
199 mode,
200 fg_ansi,
201 bg_ansi,
202 }
203 }
204
205 pub fn fg_ansi(&self, color: &str) -> &str {
207 self.fg_ansi
208 .get(color)
209 .map(|s| s.as_str())
210 .unwrap_or("\x1b[39m")
211 }
212
213 pub fn bg_ansi(&self, color: &str) -> &str {
215 self.bg_ansi
216 .get(color)
217 .map(|s| s.as_str())
218 .unwrap_or("\x1b[49m")
219 }
220
221 pub fn fg(&self, color: &str, text: &str) -> String {
223 format!("{}{}\x1b[39m", self.fg_ansi(color), text)
224 }
225
226 pub fn bg(&self, color: &str, text: &str) -> String {
228 format!("{}{}\x1b[49m", self.bg_ansi(color), text)
229 }
230
231 pub fn bold(&self, text: &str) -> String {
233 format!("\x1b[1m{}\x1b[22m", text)
234 }
235
236 pub fn italic(&self, text: &str) -> String {
238 format!("\x1b[3m{}\x1b[23m", text)
239 }
240
241 pub fn inverse(&self, text: &str) -> String {
243 format!("\x1b[7m{}\x1b[27m", text)
244 }
245
246 pub fn underline(&self, text: &str) -> String {
248 format!("\x1b[4m{}\x1b[24m", text)
249 }
250
251 pub fn strikethrough(&self, text: &str) -> String {
253 format!("\x1b[9m{}\x1b[29m", text)
254 }
255
256 pub fn color_mode(&self) -> ColorMode {
258 self.mode
259 }
260
261 pub fn bold_fg(&self, color: &str, text: &str) -> String {
263 format!("\x1b[1m{}{}\x1b[22m\x1b[39m", self.fg_ansi(color), text)
264 }
265
266 pub fn accent(&self, text: &str) -> String {
270 self.fg("accent", text)
271 }
272
273 pub fn dim(&self, text: &str) -> String {
275 self.fg("dim", text)
276 }
277
278 pub fn muted(&self, text: &str) -> String {
280 self.fg("muted", text)
281 }
282
283 pub fn success(&self, text: &str) -> String {
285 self.fg("success", text)
286 }
287
288 pub fn error(&self, text: &str) -> String {
290 self.fg("error", text)
291 }
292
293 pub fn text_color(&self, text: &str) -> String {
295 self.fg("text", text)
296 }
297
298 pub fn border(&self, text: &str) -> String {
300 self.fg("border", text)
301 }
302
303 pub fn user_msg_bg(&self, text: &str) -> String {
305 self.bg("userMessageBg", text)
306 }
307
308 pub fn thinking_bg(&self, text: &str) -> String {
310 self.bg("thinking_bg", text)
311 }
312
313 pub fn bold_accent(&self, text: &str) -> String {
315 self.bold_fg("accent", text)
316 }
317
318 pub fn fg_style(&self, color: &str) -> crate::tui::Style {
322 crate::tui::Style::new().fg(self.fg_ansi(color).to_string())
323 }
324
325 pub fn bg_style(&self, color: &str) -> crate::tui::Style {
327 crate::tui::Style::new().bg(self.bg_ansi(color).to_string())
328 }
329}
330
331pub use crate::tui::ThemeKey;
334
335impl RabTheme {
336 pub fn fg_ansi_key(&self, key: ThemeKey) -> &str {
338 self.fg_ansi(key.as_str())
339 }
340
341 pub fn bg_ansi_key(&self, key: ThemeKey) -> &str {
343 self.bg_ansi(key.as_str())
344 }
345
346 pub fn fg_key(&self, key: ThemeKey, text: &str) -> String {
348 self.fg(key.as_str(), text)
349 }
350
351 pub fn bg_key(&self, key: ThemeKey, text: &str) -> String {
353 self.bg(key.as_str(), text)
354 }
355
356 pub fn fg_style_key(&self, key: ThemeKey) -> crate::tui::Style {
358 self.fg_style(key.as_str())
359 }
360
361 pub fn bg_style_key(&self, key: ThemeKey) -> crate::tui::Style {
363 self.bg_style(key.as_str())
364 }
365}
366
367impl Theme for RabTheme {
368 fn fg(&self, color: &str, text: &str) -> String {
369 self.fg(color, text)
370 }
371
372 fn bg(&self, color: &str, text: &str) -> String {
373 self.bg(color, text)
374 }
375
376 fn bold(&self, text: &str) -> String {
377 self.bold(text)
378 }
379
380 fn italic(&self, text: &str) -> String {
381 self.italic(text)
382 }
383
384 fn inverse(&self, text: &str) -> String {
385 self.inverse(text)
386 }
387
388 fn fg_ansi(&self, color: &str) -> &str {
389 self.fg_ansi(color)
390 }
391
392 fn fg_ansi_key(&self, key: ThemeKey) -> &str {
393 self.fg_ansi_key(key)
394 }
395}
396
397use std::sync::{Mutex, OnceLock};
400
401static THEME: OnceLock<Mutex<RabTheme>> = OnceLock::new();
402static THEME_MODE: AtomicU16 = AtomicU16::new(1); fn get_theme_lock() -> &'static Mutex<RabTheme> {
405 THEME.get_or_init(|| Mutex::new(fallback_theme()))
406}
407
408pub fn init_theme(theme_name: Option<&str>, force_256: bool) {
410 let mode = if force_256 {
411 ColorMode::Ansi256
412 } else {
413 ColorMode::TrueColor
414 };
415 THEME_MODE.store(
416 if force_256 { 2 } else { 1 },
417 std::sync::atomic::Ordering::Relaxed,
418 );
419
420 let name = theme_name.unwrap_or("dark");
421 match load_theme_config(name) {
422 Ok(config) => {
423 let theme = RabTheme::from_config(&config, mode);
424 if let Ok(mut t) = get_theme_lock().lock() {
425 *t = theme;
426 }
427 }
428 Err(_) => {
429 if name != "dark"
431 && let Ok(config) = load_theme_config("dark")
432 {
433 let theme = RabTheme::from_config(&config, mode);
434 if let Ok(mut t) = get_theme_lock().lock() {
435 *t = theme;
436 }
437 }
438 }
439 }
440}
441
442fn load_theme_config(name: &str) -> Result<ThemeConfig, String> {
444 match name {
445 "dark" => {
446 let json = include_str!("themes/dark.json");
447 serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
448 }
449 "light" => {
450 let json = include_str!("themes/light.json");
451 serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
452 }
453 _ => {
454 let themes_dir = get_themes_dir();
455 let theme_path = themes_dir.join(format!("{}.json", name));
456 if theme_path.exists() {
457 let content = std::fs::read_to_string(&theme_path).map_err(|e| e.to_string())?;
458 serde_json::from_str::<ThemeConfig>(&content).map_err(|e| e.to_string())
459 } else {
460 Err(format!("Theme not found: {}", name))
461 }
462 }
463 }
464}
465
466fn get_themes_dir() -> PathBuf {
468 let base = directories::BaseDirs::new()
469 .map(|d| d.home_dir().join(".rab"))
470 .unwrap_or_else(|| PathBuf::from("/tmp/.rab"));
471 let dir = base.join("themes");
472 let _ = std::fs::create_dir_all(&dir);
473 dir
474}
475
476pub fn get_available_themes() -> Vec<String> {
478 let mut themes: Vec<String> = vec!["dark".to_string(), "light".to_string()];
479
480 let themes_dir = get_themes_dir();
481 if let Ok(entries) = std::fs::read_dir(&themes_dir) {
482 for entry in entries.flatten() {
483 let path = entry.path();
484 if path.extension().map(|e| e == "json").unwrap_or(false)
485 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
486 && name != "dark"
487 && name != "light"
488 {
489 themes.push(name.to_string());
490 }
491 }
492 }
493
494 themes.sort();
495 themes.dedup();
496 themes
497}
498
499pub fn current_theme() -> std::sync::MutexGuard<'static, RabTheme> {
501 get_theme_lock().lock().expect("Theme lock poisoned")
502}
503
504pub fn set_theme(name: &str) -> Result<(), String> {
506 let mode = match THEME_MODE.load(std::sync::atomic::Ordering::Relaxed) {
507 2 => ColorMode::Ansi256,
508 _ => ColorMode::TrueColor,
509 };
510 let config = load_theme_config(name)?;
511 let theme = RabTheme::from_config(&config, mode);
512 if let Ok(mut t) = get_theme_lock().lock() {
513 *t = theme;
514 }
515 Ok(())
516}
517
518pub fn detect_terminal_theme() -> &'static str {
521 if let Ok(colorfgbg) = std::env::var("COLORFGBG")
522 && let Some(bg_str) = colorfgbg.split(';').next_back()
523 && let Ok(bg) = bg_str.trim().parse::<u8>()
524 {
525 let luminance = match bg {
526 0..=7 => 0.2,
527 8..=15 => 0.8,
528 _ => {
529 (bg - 16) as f64 / 239.0
532 }
533 };
534 return if luminance > 0.5 { "light" } else { "dark" };
535 }
536 "dark"
537}
538
539fn fallback_theme() -> RabTheme {
541 let mut config = ThemeConfig {
542 name: "dark".into(),
543 vars: HashMap::new(),
544 colors: HashMap::new(),
545 };
546 let entries: Vec<(&str, &str)> = vec![
547 ("text", "#d4d4d4"),
548 ("dim", "#666666"),
549 ("muted", "#808080"),
550 ("accent", "#8abeb7"),
551 ("success", "#b5bd68"),
552 ("error", "#cc6666"),
553 ("warning", "#ffff00"),
554 ("thinkingText", "#808080"),
555 ("thinking_level_low", "#5f87af"),
556 ("thinking_level_medium", "#81a2be"),
557 ("thinking_level_high", "#b294bb"),
558 ("thinking_level_xhigh", "#d183e8"),
559 ("userMessageBg", "#343541"),
560 ("toolPendingBg", "#282832"),
561 ("toolSuccessBg", "#283228"),
562 ("toolErrorBg", "#3c2828"),
563 ("toolTitle", "#d4d4d4"),
564 ("toolOutput", "#808080"),
565 ];
566 for (k, v) in entries {
567 config
568 .colors
569 .insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
570 }
571 RabTheme::from_config(&config, ColorMode::TrueColor)
572}
573
574pub fn get_markdown_theme() -> MarkdownTheme {
577 let theme = current_theme();
578
579 let heading = mk_style(theme.fg_ansi("mdHeading"));
580 let link = mk_style(theme.fg_ansi("mdLink"));
581 let link_url = mk_style(theme.fg_ansi("mdLinkUrl"));
582 let code = mk_style(theme.fg_ansi("mdCode"));
583 let code_block = mk_style(theme.fg_ansi("mdCodeBlock"));
584 let code_block_border = mk_style(theme.fg_ansi("mdCodeBlockBorder"));
585 let quote = mk_style(theme.fg_ansi("mdQuote"));
586 let quote_border = mk_style(theme.fg_ansi("mdQuoteBorder"));
587 let hr = mk_style(theme.fg_ansi("mdHr"));
588 let list_bullet = mk_style(theme.fg_ansi("mdListBullet"));
589
590 drop(theme);
592
593 let mut md = MarkdownTheme::new(
594 heading,
595 link,
596 link_url,
597 code,
598 code_block,
599 code_block_border,
600 quote,
601 quote_border,
602 hr,
603 list_bullet,
604 style_bold(),
605 style_italic(),
606 style_strikethrough(),
607 style_underline(),
608 );
609 md.highlight_code = create_highlight_fn();
610 md
611}
612
613fn mk_style(prefix: &str) -> StyleFn {
615 let p = prefix.to_string();
616 Arc::new(move |text: &str| format!("{}{}\x1b[39m", p, text))
617}
618
619fn style_bold() -> StyleFn {
621 Arc::new(|text: &str| format!("\x1b[1m{}\x1b[22m", text))
622}
623
624fn style_italic() -> StyleFn {
626 Arc::new(|text: &str| format!("\x1b[3m{}\x1b[23m", text))
627}
628
629fn style_strikethrough() -> StyleFn {
631 Arc::new(|text: &str| format!("\x1b[9m{}\x1b[29m", text))
632}
633
634fn style_underline() -> StyleFn {
636 Arc::new(|text: &str| format!("\x1b[4m{}\x1b[24m", text))
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 #[test]
644 fn test_load_dark_theme() {
645 let config = load_theme_config("dark").unwrap();
646 assert_eq!(config.name, "dark");
647 assert!(config.colors.contains_key("accent"));
648 assert!(config.colors.contains_key("text"));
649 }
650
651 #[test]
652 fn test_load_light_theme() {
653 let config = load_theme_config("light").unwrap();
654 assert_eq!(config.name, "light");
655 assert!(config.colors.contains_key("accent"));
656 }
657
658 #[test]
659 fn test_resolve_colors() {
660 let config = load_theme_config("dark").unwrap();
661 let colors = RabTheme::resolve_colors(&config);
662 assert!(colors.contains_key("accent"));
663 assert!(colors.contains_key("text"));
664 assert!(colors.get("accent").unwrap().starts_with('#'));
665 }
666
667 #[test]
668 fn test_theme_from_config() {
669 let config = load_theme_config("dark").unwrap();
670 let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
671 let colored = theme.fg("accent", "hello");
672 assert!(colored.contains("hello"));
673 assert!(colored.contains("\x1b[38;2;"));
674 assert!(colored.ends_with("\x1b[39m"));
675 }
676
677 #[test]
678 fn test_theme_256_fallback() {
679 let config = load_theme_config("dark").unwrap();
680 let theme = RabTheme::from_config(&config, ColorMode::Ansi256);
681 let colored = theme.fg("accent", "hello");
682 assert!(colored.contains("hello"));
683 assert!(colored.contains("\x1b[38;5;"));
684 }
685
686 #[test]
687 fn test_bold_italic() {
688 let config = load_theme_config("dark").unwrap();
689 let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
690 assert_eq!(theme.bold("x"), "\x1b[1mx\x1b[22m");
691 assert_eq!(theme.italic("x"), "\x1b[3mx\x1b[23m");
692 }
693
694 #[test]
695 fn test_hex_to_rgb() {
696 assert_eq!(RabTheme::hex_to_rgb("#ff0000"), Some((255, 0, 0)));
697 assert_eq!(RabTheme::hex_to_rgb("00ff00"), Some((0, 255, 0)));
698 assert_eq!(RabTheme::hex_to_rgb("#zzz"), None);
699 }
700
701 #[test]
702 fn test_fallback_theme() {
703 let theme = fallback_theme();
704 assert_eq!(theme.name, "dark");
705 let text = theme.fg("text", "test");
706 assert!(text.contains("test"));
707 }
708
709 #[test]
710 fn test_set_and_get() {
711 init_theme(Some("dark"), false);
712 let theme = current_theme();
713 assert_eq!(theme.name, "dark");
714 }
715}