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 underline(&self, text: &str) -> String {
243 format!("\x1b[4m{}\x1b[24m", text)
244 }
245
246 pub fn strikethrough(&self, text: &str) -> String {
248 format!("\x1b[9m{}\x1b[29m", text)
249 }
250
251 pub fn color_mode(&self) -> ColorMode {
253 self.mode
254 }
255
256 pub fn bold_fg(&self, color: &str, text: &str) -> String {
258 format!("\x1b[1m{}{}\x1b[22m\x1b[39m", self.fg_ansi(color), text)
259 }
260
261 pub fn accent(&self, text: &str) -> String {
265 self.fg("accent", text)
266 }
267
268 pub fn dim(&self, text: &str) -> String {
270 self.fg("dim", text)
271 }
272
273 pub fn muted(&self, text: &str) -> String {
275 self.fg("muted", text)
276 }
277
278 pub fn success(&self, text: &str) -> String {
280 self.fg("success", text)
281 }
282
283 pub fn error(&self, text: &str) -> String {
285 self.fg("error", text)
286 }
287
288 pub fn text_color(&self, text: &str) -> String {
290 self.fg("text", text)
291 }
292
293 pub fn border(&self, text: &str) -> String {
295 self.fg("border", text)
296 }
297
298 pub fn user_msg_bg(&self, text: &str) -> String {
300 self.bg("userMessageBg", text)
301 }
302
303 pub fn thinking_bg(&self, text: &str) -> String {
305 self.bg("thinking_bg", text)
306 }
307
308 pub fn bold_accent(&self, text: &str) -> String {
310 self.bold_fg("accent", text)
311 }
312}
313
314impl Theme for RabTheme {
315 fn fg(&self, color: &str, text: &str) -> String {
316 self.fg(color, text)
317 }
318
319 fn bg(&self, color: &str, text: &str) -> String {
320 self.bg(color, text)
321 }
322
323 fn bold(&self, text: &str) -> String {
324 self.bold(text)
325 }
326
327 fn italic(&self, text: &str) -> String {
328 self.italic(text)
329 }
330}
331
332use std::sync::{Mutex, OnceLock};
335
336static THEME: OnceLock<Mutex<RabTheme>> = OnceLock::new();
337static THEME_MODE: AtomicU16 = AtomicU16::new(1); fn get_theme_lock() -> &'static Mutex<RabTheme> {
340 THEME.get_or_init(|| Mutex::new(fallback_theme()))
341}
342
343pub fn init_theme(theme_name: Option<&str>, force_256: bool) {
345 let mode = if force_256 {
346 ColorMode::Ansi256
347 } else {
348 ColorMode::TrueColor
349 };
350 THEME_MODE.store(
351 if force_256 { 2 } else { 1 },
352 std::sync::atomic::Ordering::Relaxed,
353 );
354
355 let name = theme_name.unwrap_or("dark");
356 match load_theme_config(name) {
357 Ok(config) => {
358 let theme = RabTheme::from_config(&config, mode);
359 if let Ok(mut t) = get_theme_lock().lock() {
360 *t = theme;
361 }
362 }
363 Err(_) => {
364 if name != "dark"
366 && let Ok(config) = load_theme_config("dark")
367 {
368 let theme = RabTheme::from_config(&config, mode);
369 if let Ok(mut t) = get_theme_lock().lock() {
370 *t = theme;
371 }
372 }
373 }
374 }
375}
376
377fn load_theme_config(name: &str) -> Result<ThemeConfig, String> {
379 match name {
380 "dark" => {
381 let json = include_str!("themes/dark.json");
382 serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
383 }
384 "light" => {
385 let json = include_str!("themes/light.json");
386 serde_json::from_str::<ThemeConfig>(json).map_err(|e| e.to_string())
387 }
388 _ => {
389 let themes_dir = get_themes_dir();
390 let theme_path = themes_dir.join(format!("{}.json", name));
391 if theme_path.exists() {
392 let content = std::fs::read_to_string(&theme_path).map_err(|e| e.to_string())?;
393 serde_json::from_str::<ThemeConfig>(&content).map_err(|e| e.to_string())
394 } else {
395 Err(format!("Theme not found: {}", name))
396 }
397 }
398 }
399}
400
401fn get_themes_dir() -> PathBuf {
403 let base = directories::BaseDirs::new()
404 .map(|d| d.home_dir().join(".rab"))
405 .unwrap_or_else(|| PathBuf::from("/tmp/.rab"));
406 let dir = base.join("themes");
407 let _ = std::fs::create_dir_all(&dir);
408 dir
409}
410
411pub fn get_available_themes() -> Vec<String> {
413 let mut themes: Vec<String> = vec!["dark".to_string(), "light".to_string()];
414
415 let themes_dir = get_themes_dir();
416 if let Ok(entries) = std::fs::read_dir(&themes_dir) {
417 for entry in entries.flatten() {
418 let path = entry.path();
419 if path.extension().map(|e| e == "json").unwrap_or(false)
420 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
421 && name != "dark"
422 && name != "light"
423 {
424 themes.push(name.to_string());
425 }
426 }
427 }
428
429 themes.sort();
430 themes.dedup();
431 themes
432}
433
434pub fn current_theme() -> std::sync::MutexGuard<'static, RabTheme> {
436 get_theme_lock().lock().expect("Theme lock poisoned")
437}
438
439pub fn set_theme(name: &str) -> Result<(), String> {
441 let mode = match THEME_MODE.load(std::sync::atomic::Ordering::Relaxed) {
442 2 => ColorMode::Ansi256,
443 _ => ColorMode::TrueColor,
444 };
445 let config = load_theme_config(name)?;
446 let theme = RabTheme::from_config(&config, mode);
447 if let Ok(mut t) = get_theme_lock().lock() {
448 *t = theme;
449 }
450 Ok(())
451}
452
453pub fn detect_terminal_theme() -> &'static str {
456 if let Ok(colorfgbg) = std::env::var("COLORFGBG")
457 && let Some(bg_str) = colorfgbg.split(';').next_back()
458 && let Ok(bg) = bg_str.trim().parse::<u8>()
459 {
460 let luminance = match bg {
461 0..=7 => 0.2,
462 8..=15 => 0.8,
463 _ => {
464 (bg - 16) as f64 / 239.0
467 }
468 };
469 return if luminance > 0.5 { "light" } else { "dark" };
470 }
471 "dark"
472}
473
474fn fallback_theme() -> RabTheme {
476 let mut config = ThemeConfig {
477 name: "dark".into(),
478 vars: HashMap::new(),
479 colors: HashMap::new(),
480 };
481 let entries: Vec<(&str, &str)> = vec![
482 ("text", "#d4d4d4"),
483 ("dim", "#666666"),
484 ("muted", "#808080"),
485 ("accent", "#8abeb7"),
486 ("success", "#b5bd68"),
487 ("error", "#cc6666"),
488 ("warning", "#ffff00"),
489 ("thinkingText", "#808080"),
490 ("thinking_level_low", "#5f87af"),
491 ("thinking_level_medium", "#81a2be"),
492 ("thinking_level_high", "#b294bb"),
493 ("thinking_level_xhigh", "#d183e8"),
494 ("userMessageBg", "#343541"),
495 ("toolPendingBg", "#282832"),
496 ("toolSuccessBg", "#283228"),
497 ("toolErrorBg", "#3c2828"),
498 ("toolTitle", "#d4d4d4"),
499 ("toolOutput", "#808080"),
500 ];
501 for (k, v) in entries {
502 config
503 .colors
504 .insert(k.to_string(), ColorValue::HexOrVar(v.to_string()));
505 }
506 RabTheme::from_config(&config, ColorMode::TrueColor)
507}
508
509pub fn get_markdown_theme() -> MarkdownTheme {
512 let theme = current_theme();
513
514 let heading = mk_style(theme.fg_ansi("mdHeading"));
515 let link = mk_style(theme.fg_ansi("mdLink"));
516 let link_url = mk_style(theme.fg_ansi("mdLinkUrl"));
517 let code = mk_style(theme.fg_ansi("mdCode"));
518 let code_block = mk_style(theme.fg_ansi("mdCodeBlock"));
519 let code_block_border = mk_style(theme.fg_ansi("mdCodeBlockBorder"));
520 let quote = mk_style(theme.fg_ansi("mdQuote"));
521 let quote_border = mk_style(theme.fg_ansi("mdQuoteBorder"));
522 let hr = mk_style(theme.fg_ansi("mdHr"));
523 let list_bullet = mk_style(theme.fg_ansi("mdListBullet"));
524
525 drop(theme);
527
528 let mut md = MarkdownTheme::new(
529 heading,
530 link,
531 link_url,
532 code,
533 code_block,
534 code_block_border,
535 quote,
536 quote_border,
537 hr,
538 list_bullet,
539 style_bold(),
540 style_italic(),
541 style_strikethrough(),
542 style_underline(),
543 );
544 md.highlight_code = create_highlight_fn();
545 md
546}
547
548fn mk_style(prefix: &str) -> StyleFn {
550 let p = prefix.to_string();
551 Arc::new(move |text: &str| format!("{}{}\x1b[39m", p, text))
552}
553
554fn style_bold() -> StyleFn {
556 Arc::new(|text: &str| format!("\x1b[1m{}\x1b[22m", text))
557}
558
559fn style_italic() -> StyleFn {
561 Arc::new(|text: &str| format!("\x1b[3m{}\x1b[23m", text))
562}
563
564fn style_strikethrough() -> StyleFn {
566 Arc::new(|text: &str| format!("\x1b[9m{}\x1b[29m", text))
567}
568
569fn style_underline() -> StyleFn {
571 Arc::new(|text: &str| format!("\x1b[4m{}\x1b[24m", text))
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn test_load_dark_theme() {
580 let config = load_theme_config("dark").unwrap();
581 assert_eq!(config.name, "dark");
582 assert!(config.colors.contains_key("accent"));
583 assert!(config.colors.contains_key("text"));
584 }
585
586 #[test]
587 fn test_load_light_theme() {
588 let config = load_theme_config("light").unwrap();
589 assert_eq!(config.name, "light");
590 assert!(config.colors.contains_key("accent"));
591 }
592
593 #[test]
594 fn test_resolve_colors() {
595 let config = load_theme_config("dark").unwrap();
596 let colors = RabTheme::resolve_colors(&config);
597 assert!(colors.contains_key("accent"));
598 assert!(colors.contains_key("text"));
599 assert!(colors.get("accent").unwrap().starts_with('#'));
600 }
601
602 #[test]
603 fn test_theme_from_config() {
604 let config = load_theme_config("dark").unwrap();
605 let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
606 let colored = theme.fg("accent", "hello");
607 assert!(colored.contains("hello"));
608 assert!(colored.contains("\x1b[38;2;"));
609 assert!(colored.ends_with("\x1b[39m"));
610 }
611
612 #[test]
613 fn test_theme_256_fallback() {
614 let config = load_theme_config("dark").unwrap();
615 let theme = RabTheme::from_config(&config, ColorMode::Ansi256);
616 let colored = theme.fg("accent", "hello");
617 assert!(colored.contains("hello"));
618 assert!(colored.contains("\x1b[38;5;"));
619 }
620
621 #[test]
622 fn test_bold_italic() {
623 let config = load_theme_config("dark").unwrap();
624 let theme = RabTheme::from_config(&config, ColorMode::TrueColor);
625 assert_eq!(theme.bold("x"), "\x1b[1mx\x1b[22m");
626 assert_eq!(theme.italic("x"), "\x1b[3mx\x1b[23m");
627 }
628
629 #[test]
630 fn test_hex_to_rgb() {
631 assert_eq!(RabTheme::hex_to_rgb("#ff0000"), Some((255, 0, 0)));
632 assert_eq!(RabTheme::hex_to_rgb("00ff00"), Some((0, 255, 0)));
633 assert_eq!(RabTheme::hex_to_rgb("#zzz"), None);
634 }
635
636 #[test]
637 fn test_fallback_theme() {
638 let theme = fallback_theme();
639 assert_eq!(theme.name, "dark");
640 let text = theme.fg("text", "test");
641 assert!(text.contains("test"));
642 }
643
644 #[test]
645 fn test_set_and_get() {
646 init_theme(Some("dark"), false);
647 let theme = current_theme();
648 assert_eq!(theme.name, "dark");
649 }
650}