1use ratatui::style::{Color, Modifier, Style};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10
11const DARK_THEME_JSON: &str = include_str!("../../../assets/themes/dark.json");
13const LIGHT_THEME_JSON: &str = include_str!("../../../assets/themes/light.json");
14
15static THEME: OnceLock<Theme> = OnceLock::new();
17
18#[derive(Debug, Deserialize)]
20struct ThemeJson {
21 name: String,
22 vars: HashMap<String, String>,
23 colors: HashMap<String, String>,
24}
25
26#[derive(Debug, Clone)]
28pub struct Theme {
29 pub name: String,
30
31 pub bg_primary: Color,
33 pub bg_secondary: Color,
34 pub bg_terminal: Color,
35
36 pub text_primary: Color,
38 pub text_muted: Color,
39 pub text_terminal: Color,
40
41 pub border_default: Color,
43 pub border_active: Color,
44
45 pub accent: Color,
47 pub success: Color,
48 pub error: Color,
49
50 pub status_starting: Color,
52 pub status_running: Color,
53 pub status_completed: Color,
54 pub status_failed: Color,
55
56 pub swarm_purple: Color,
58 pub ralph_orange: Color,
59 pub failed_validation_red: Color,
60}
61
62impl Theme {
63 fn parse_hex(hex: &str) -> Option<Color> {
65 let hex = hex.trim_start_matches('#');
66 if hex.len() != 6 {
67 return None;
68 }
69
70 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
71 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
72 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
73
74 Some(Color::Rgb(r, g, b))
75 }
76
77 fn resolve_color(value: &str, vars: &HashMap<String, String>) -> Color {
79 if value.starts_with('#') {
81 return Self::parse_hex(value).unwrap_or(Color::Reset);
82 }
83
84 if let Some(var_value) = vars.get(value) {
86 Self::parse_hex(var_value).unwrap_or(Color::Reset)
87 } else {
88 Color::Reset
89 }
90 }
91
92 pub fn from_json(json: &str) -> Result<Self, String> {
94 let theme_json: ThemeJson =
95 serde_json::from_str(json).map_err(|e| format!("Failed to parse theme JSON: {}", e))?;
96
97 let get_color = |key: &str| -> Color {
98 theme_json
99 .colors
100 .get(key)
101 .map(|v| Self::resolve_color(v, &theme_json.vars))
102 .unwrap_or(Color::Reset)
103 };
104
105 Ok(Theme {
106 name: theme_json.name,
107 bg_primary: get_color("bgPrimary"),
108 bg_secondary: get_color("bgSecondary"),
109 bg_terminal: get_color("bgTerminal"),
110 text_primary: get_color("textPrimary"),
111 text_muted: get_color("textMuted"),
112 text_terminal: get_color("textTerminal"),
113 border_default: get_color("borderDefault"),
114 border_active: get_color("borderActive"),
115 accent: get_color("accent"),
116 success: get_color("success"),
117 error: get_color("error"),
118 status_starting: get_color("statusStarting"),
119 status_running: get_color("statusRunning"),
120 status_completed: get_color("statusCompleted"),
121 status_failed: get_color("statusFailed"),
122 swarm_purple: get_color("swarmPurple"),
123 ralph_orange: get_color("ralphOrange"),
124 failed_validation_red: get_color("failedValidationRed"),
125 })
126 }
127
128 pub fn dark() -> Self {
130 Self::from_json(DARK_THEME_JSON).expect("Embedded dark theme should be valid")
131 }
132
133 pub fn light() -> Self {
135 Self::from_json(LIGHT_THEME_JSON).expect("Embedded light theme should be valid")
136 }
137
138 pub fn bg_style(&self) -> Style {
144 Style::default().bg(self.bg_primary)
145 }
146
147 pub fn bg_secondary_style(&self) -> Style {
149 Style::default().bg(self.bg_secondary)
150 }
151
152 pub fn bg_terminal_style(&self) -> Style {
154 Style::default().bg(self.bg_terminal)
155 }
156
157 pub fn text_style(&self) -> Style {
159 Style::default().fg(self.text_primary)
160 }
161
162 pub fn text_muted_style(&self) -> Style {
164 Style::default().fg(self.text_muted)
165 }
166
167 pub fn text_terminal_style(&self) -> Style {
169 Style::default().fg(self.text_terminal)
170 }
171
172 pub fn border_style(&self) -> Style {
174 Style::default().fg(self.border_default)
175 }
176
177 pub fn border_active_style(&self) -> Style {
179 Style::default().fg(self.border_active)
180 }
181
182 pub fn accent_style(&self) -> Style {
184 Style::default().fg(self.accent)
185 }
186
187 pub fn accent_bold_style(&self) -> Style {
189 Style::default()
190 .fg(self.accent)
191 .add_modifier(Modifier::BOLD)
192 }
193
194 pub fn success_style(&self) -> Style {
196 Style::default().fg(self.success)
197 }
198
199 pub fn error_style(&self) -> Style {
201 Style::default().fg(self.error)
202 }
203
204 pub fn border_for_focus(&self, focused: bool) -> Style {
206 if focused {
207 self.border_active_style()
208 } else {
209 self.border_style()
210 }
211 }
212
213 pub fn title_color_for_focus(&self, focused: bool) -> Color {
215 if focused {
216 self.accent
217 } else {
218 self.text_muted
219 }
220 }
221}
222
223pub fn init_theme(variant: ThemeVariant) {
225 let theme = match variant {
226 ThemeVariant::Dark => Theme::dark(),
227 ThemeVariant::Light => Theme::light(),
228 };
229 let _ = THEME.set(theme);
230}
231
232pub fn theme() -> &'static Theme {
234 THEME.get_or_init(Theme::dark)
235}
236
237#[derive(Debug, Clone, Copy, Default)]
239pub enum ThemeVariant {
240 #[default]
241 Dark,
242 Light,
243}
244
245pub fn bg_primary() -> Color {
252 theme().bg_primary
253}
254pub fn bg_secondary() -> Color {
255 theme().bg_secondary
256}
257pub fn bg_terminal() -> Color {
258 theme().bg_terminal
259}
260
261pub fn text_primary() -> Color {
263 theme().text_primary
264}
265pub fn text_muted() -> Color {
266 theme().text_muted
267}
268pub fn text_terminal() -> Color {
269 theme().text_terminal
270}
271
272pub fn border_default() -> Color {
274 theme().border_default
275}
276pub fn border_active() -> Color {
277 theme().border_active
278}
279
280pub fn accent() -> Color {
282 theme().accent
283}
284pub fn success() -> Color {
285 theme().success
286}
287pub fn error() -> Color {
288 theme().error
289}
290
291pub fn status_starting() -> Color {
293 theme().status_starting
294}
295pub fn status_running() -> Color {
296 theme().status_running
297}
298pub fn status_completed() -> Color {
299 theme().status_completed
300}
301pub fn status_failed() -> Color {
302 theme().status_failed
303}
304
305pub fn swarm_purple() -> Color {
307 theme().swarm_purple
308}
309pub fn ralph_orange() -> Color {
310 theme().ralph_orange
311}
312pub fn failed_validation_red() -> Color {
313 theme().failed_validation_red
314}
315
316pub const BG_PRIMARY: Color = Color::Rgb(15, 23, 42);
323pub const BG_SECONDARY: Color = Color::Rgb(30, 41, 59);
324pub const BG_TERMINAL: Color = Color::Rgb(22, 22, 22);
325
326pub const TEXT_PRIMARY: Color = Color::Rgb(226, 232, 240);
328pub const TEXT_MUTED: Color = Color::Rgb(100, 116, 139);
329pub const TEXT_TERMINAL: Color = Color::Rgb(200, 200, 200);
330
331pub const BORDER_DEFAULT: Color = Color::Rgb(51, 65, 85);
333pub const BORDER_ACTIVE: Color = Color::Rgb(96, 165, 250);
334
335pub const ACCENT: Color = Color::Rgb(96, 165, 250);
337pub const SUCCESS: Color = Color::Rgb(34, 197, 94);
338pub const ERROR: Color = Color::Rgb(248, 113, 113);
339
340pub const STATUS_STARTING: Color = Color::Rgb(148, 163, 184);
342pub const STATUS_RUNNING: Color = Color::Rgb(34, 197, 94);
343pub const STATUS_COMPLETED: Color = Color::Rgb(96, 165, 250);
344pub const STATUS_FAILED: Color = Color::Rgb(248, 113, 113);
345
346pub const SWARM_PURPLE: Color = Color::Rgb(168, 85, 247);
348pub const RALPH_ORANGE: Color = Color::Rgb(255, 165, 0);
349pub const FAILED_VALIDATION_RED: Color = Color::Rgb(239, 68, 68);
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_parse_hex() {
357 assert_eq!(Theme::parse_hex("#ff0000"), Some(Color::Rgb(255, 0, 0)));
358 assert_eq!(Theme::parse_hex("#00ff00"), Some(Color::Rgb(0, 255, 0)));
359 assert_eq!(Theme::parse_hex("#0000ff"), Some(Color::Rgb(0, 0, 255)));
360 assert_eq!(Theme::parse_hex("ff0000"), Some(Color::Rgb(255, 0, 0)));
361 assert_eq!(Theme::parse_hex("#fff"), None); assert_eq!(Theme::parse_hex("invalid"), None);
363 }
364
365 #[test]
366 fn test_load_dark_theme() {
367 let theme = Theme::dark();
368 assert_eq!(theme.name, "dark");
369 assert_eq!(theme.bg_primary, Color::Rgb(15, 23, 42));
370 assert_eq!(theme.accent, Color::Rgb(96, 165, 250));
371 }
372
373 #[test]
374 fn test_load_light_theme() {
375 let theme = Theme::light();
376 assert_eq!(theme.name, "light");
377 assert_ne!(theme.bg_primary, Color::Rgb(15, 23, 42));
379 }
380
381 #[test]
382 fn test_variable_resolution() {
383 let json = r##"{
384 "name": "test",
385 "vars": {
386 "myColor": "#123456"
387 },
388 "colors": {
389 "bgPrimary": "myColor",
390 "bgSecondary": "#abcdef",
391 "bgTerminal": "#000000",
392 "textPrimary": "#ffffff",
393 "textMuted": "#888888",
394 "textTerminal": "#cccccc",
395 "borderDefault": "#333333",
396 "borderActive": "#0066ff",
397 "accent": "#0066ff",
398 "success": "#00ff00",
399 "error": "#ff0000",
400 "statusStarting": "#808080",
401 "statusRunning": "#00ff00",
402 "statusCompleted": "#0066ff",
403 "statusFailed": "#ff0000",
404 "swarmPurple": "#9900ff",
405 "ralphOrange": "#ff9900",
406 "failedValidationRed": "#cc0000"
407 }
408 }"##;
409
410 let theme = Theme::from_json(json).unwrap();
411 assert_eq!(theme.name, "test");
412 assert_eq!(theme.bg_primary, Color::Rgb(0x12, 0x34, 0x56));
414 assert_eq!(theme.bg_secondary, Color::Rgb(0xab, 0xcd, 0xef));
416 }
417
418 #[test]
419 fn test_theme_styles() {
420 let theme = Theme::dark();
421
422 let accent_style = theme.accent_style();
424 assert_eq!(accent_style.fg, Some(theme.accent));
425
426 let border_style = theme.border_for_focus(true);
427 assert_eq!(border_style.fg, Some(theme.border_active));
428
429 let border_style = theme.border_for_focus(false);
430 assert_eq!(border_style.fg, Some(theme.border_default));
431 }
432}