scud/commands/spawn/tui/
theme.rs

1//! Theme system for TUI monitor
2//!
3//! Loads JSON themes (dark/light) and applies styles to ratatui widgets.
4//! Mirrors pi's theme handling for consistent UI.
5
6use ratatui::style::{Color, Modifier, Style};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10
11// Embedded theme files
12const DARK_THEME_JSON: &str = include_str!("../../../assets/themes/dark.json");
13const LIGHT_THEME_JSON: &str = include_str!("../../../assets/themes/light.json");
14
15/// Global theme instance
16static THEME: OnceLock<Theme> = OnceLock::new();
17
18/// JSON theme file structure (mirrors pi's format)
19#[derive(Debug, Deserialize)]
20struct ThemeJson {
21    name: String,
22    vars: HashMap<String, String>,
23    colors: HashMap<String, String>,
24}
25
26/// Resolved theme with ratatui colors
27#[derive(Debug, Clone)]
28pub struct Theme {
29    pub name: String,
30
31    // Background colors
32    pub bg_primary: Color,
33    pub bg_secondary: Color,
34    pub bg_terminal: Color,
35
36    // Text colors
37    pub text_primary: Color,
38    pub text_muted: Color,
39    pub text_terminal: Color,
40
41    // Border colors
42    pub border_default: Color,
43    pub border_active: Color,
44
45    // Accent and status colors
46    pub accent: Color,
47    pub success: Color,
48    pub error: Color,
49
50    // Status-specific colors
51    pub status_starting: Color,
52    pub status_running: Color,
53    pub status_completed: Color,
54    pub status_failed: Color,
55
56    // Special colors for specific UI elements
57    pub swarm_purple: Color,
58    pub ralph_orange: Color,
59    pub failed_validation_red: Color,
60}
61
62impl Theme {
63    /// Parse a hex color string (#RRGGBB) to ratatui Color
64    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    /// Resolve a color value - either a variable reference or a hex color
78    fn resolve_color(value: &str, vars: &HashMap<String, String>) -> Color {
79        // If value starts with #, it's a hex color
80        if value.starts_with('#') {
81            return Self::parse_hex(value).unwrap_or(Color::Reset);
82        }
83
84        // Otherwise it's a variable reference - look it up
85        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    /// Load theme from JSON string
93    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    /// Load the dark theme
129    pub fn dark() -> Self {
130        Self::from_json(DARK_THEME_JSON).expect("Embedded dark theme should be valid")
131    }
132
133    /// Load the light theme
134    pub fn light() -> Self {
135        Self::from_json(LIGHT_THEME_JSON).expect("Embedded light theme should be valid")
136    }
137
138    // ─────────────────────────────────────────────────────────────
139    // Style helpers for common widget patterns
140    // ─────────────────────────────────────────────────────────────
141
142    /// Style for primary background areas
143    pub fn bg_style(&self) -> Style {
144        Style::default().bg(self.bg_primary)
145    }
146
147    /// Style for secondary/elevated background areas
148    pub fn bg_secondary_style(&self) -> Style {
149        Style::default().bg(self.bg_secondary)
150    }
151
152    /// Style for terminal output areas
153    pub fn bg_terminal_style(&self) -> Style {
154        Style::default().bg(self.bg_terminal)
155    }
156
157    /// Style for primary text
158    pub fn text_style(&self) -> Style {
159        Style::default().fg(self.text_primary)
160    }
161
162    /// Style for muted/secondary text
163    pub fn text_muted_style(&self) -> Style {
164        Style::default().fg(self.text_muted)
165    }
166
167    /// Style for terminal text
168    pub fn text_terminal_style(&self) -> Style {
169        Style::default().fg(self.text_terminal)
170    }
171
172    /// Style for default borders
173    pub fn border_style(&self) -> Style {
174        Style::default().fg(self.border_default)
175    }
176
177    /// Style for active/focused borders
178    pub fn border_active_style(&self) -> Style {
179        Style::default().fg(self.border_active)
180    }
181
182    /// Style for accent elements (titles, highlights)
183    pub fn accent_style(&self) -> Style {
184        Style::default().fg(self.accent)
185    }
186
187    /// Style for bold accent elements
188    pub fn accent_bold_style(&self) -> Style {
189        Style::default()
190            .fg(self.accent)
191            .add_modifier(Modifier::BOLD)
192    }
193
194    /// Style for success indicators
195    pub fn success_style(&self) -> Style {
196        Style::default().fg(self.success)
197    }
198
199    /// Style for error indicators
200    pub fn error_style(&self) -> Style {
201        Style::default().fg(self.error)
202    }
203
204    /// Get border style based on focus state
205    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    /// Get title color based on focus state
214    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
223/// Initialize the global theme (call once at startup)
224pub 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
232/// Get the current theme (initializes to dark if not set)
233pub fn theme() -> &'static Theme {
234    THEME.get_or_init(Theme::dark)
235}
236
237/// Available theme variants
238#[derive(Debug, Clone, Copy, Default)]
239pub enum ThemeVariant {
240    #[default]
241    Dark,
242    Light,
243}
244
245// ─────────────────────────────────────────────────────────────
246// Convenience re-exports for backward compatibility
247// These allow existing code to continue using color constants
248// ─────────────────────────────────────────────────────────────
249
250// Background colors
251pub 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
261// Text colors
262pub 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
272// Border colors
273pub fn border_default() -> Color {
274    theme().border_default
275}
276pub fn border_active() -> Color {
277    theme().border_active
278}
279
280// Accent and status colors
281pub fn accent() -> Color {
282    theme().accent
283}
284pub fn success() -> Color {
285    theme().success
286}
287pub fn error() -> Color {
288    theme().error
289}
290
291// Status-specific colors
292pub 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
305// Special colors
306pub 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
316// ─────────────────────────────────────────────────────────────
317// Legacy constant aliases for backward compatibility
318// These are the original constants that existing code uses
319// ─────────────────────────────────────────────────────────────
320
321/// Background colors
322pub 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
326/// Text colors
327pub 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
331/// Border colors
332pub const BORDER_DEFAULT: Color = Color::Rgb(51, 65, 85);
333pub const BORDER_ACTIVE: Color = Color::Rgb(96, 165, 250);
334
335/// Accent and status colors
336pub 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
340/// Status-specific colors
341pub 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
346/// Special colors for specific UI elements
347pub 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); // Too short
362        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        // Light theme should have different colors
378        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        // bgPrimary should resolve from variable "myColor"
413        assert_eq!(theme.bg_primary, Color::Rgb(0x12, 0x34, 0x56));
414        // bgSecondary should be direct hex
415        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        // Test style helpers return expected values
423        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}