steer_tui/tui/theme/
mod.rs

1//! Theme system for steer-tui
2//!
3//! This module provides a flexible theming system that allows users to customize
4//! the appearance of the TUI without recompilation. Themes are loaded from TOML
5//! files and can be switched at runtime.
6
7use once_cell::sync::Lazy;
8use ratatui::style::{Color, Modifier, Style};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use syntect::highlighting::ThemeSet;
13use thiserror::Error;
14use tracing::debug;
15
16mod loader;
17
18pub use loader::ThemeLoader;
19
20/// Load syntect theme sets lazily
21static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
22
23/// Errors that can occur during theme operations
24#[derive(Debug, Error)]
25pub enum ThemeError {
26    #[error("IO error: {0}")]
27    Io(#[from] std::io::Error),
28
29    #[error("Parse error: {0}")]
30    Parse(#[from] toml::de::Error),
31
32    #[error("Validation error: {0}")]
33    Validation(String),
34
35    #[error("Color not found in palette: {0}")]
36    ColorNotFound(String),
37
38    #[error("Invalid color value: {0}")]
39    InvalidColor(String),
40}
41
42/// A color value that can be either a palette reference or a direct color
43#[derive(Debug, Clone, Deserialize)]
44#[serde(untagged)]
45pub enum ColorValue {
46    /// Reference to a palette color (e.g., "background", "red")
47    Palette(String),
48    /// Direct color value (e.g., "#ff0000", "red")
49    Direct(String),
50}
51
52/// Style definition for a component
53#[derive(Debug, Clone, Deserialize)]
54pub struct ComponentStyle {
55    pub fg: Option<ColorValue>,
56    pub bg: Option<ColorValue>,
57    #[serde(default)]
58    pub bold: bool,
59    #[serde(default)]
60    pub italic: bool,
61    #[serde(default)]
62    pub underlined: bool,
63}
64
65/// Raw theme as loaded from TOML file
66#[derive(Debug, Clone, Deserialize)]
67pub struct RawTheme {
68    pub name: String,
69    pub palette: HashMap<String, RgbColor>,
70    pub components: HashMap<Component, ComponentStyle>,
71    pub syntax: Option<SyntaxConfig>,
72}
73
74/// Syntax highlighting configuration
75#[derive(Debug, Clone, Deserialize)]
76pub struct SyntaxConfig {
77    /// Name of a built-in syntect theme
78    pub syntect_theme: Option<String>,
79}
80
81pub type Theme = CompiledTheme;
82
83impl Theme {
84    /// Number of blank lines between chat messages
85    pub fn message_spacing(&self) -> u16 {
86        1 // Could later be made configurable from theme file
87    }
88}
89
90/// RGB color that can be deserialized from hex strings
91#[derive(Debug, Clone, Copy)]
92pub struct RgbColor(pub u8, pub u8, pub u8);
93
94impl<'de> Deserialize<'de> for RgbColor {
95    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96    where
97        D: Deserializer<'de>,
98    {
99        let s = String::deserialize(deserializer)?;
100
101        // Try hex color first
102        if let Some(hex) = s.strip_prefix('#') {
103            if hex.len() == 6 {
104                let r = u8::from_str_radix(&hex[0..2], 16)
105                    .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
106                let g = u8::from_str_radix(&hex[2..4], 16)
107                    .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
108                let b = u8::from_str_radix(&hex[4..6], 16)
109                    .map_err(|_| serde::de::Error::custom(format!("Invalid hex color: {s}")))?;
110                return Ok(RgbColor(r, g, b));
111            }
112        }
113
114        // Try named colors
115        match s.to_lowercase().as_str() {
116            "black" => Ok(RgbColor(0, 0, 0)),
117            "red" => Ok(RgbColor(255, 0, 0)),
118            "green" => Ok(RgbColor(0, 255, 0)),
119            "yellow" => Ok(RgbColor(255, 255, 0)),
120            "blue" => Ok(RgbColor(0, 0, 255)),
121            "magenta" => Ok(RgbColor(255, 0, 255)),
122            "cyan" => Ok(RgbColor(0, 255, 255)),
123            "white" => Ok(RgbColor(255, 255, 255)),
124            "gray" | "grey" => Ok(RgbColor(128, 128, 128)),
125            "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(RgbColor(64, 64, 64)),
126            _ => Err(serde::de::Error::custom(format!("Unknown color: {s}"))),
127        }
128    }
129}
130
131impl From<RgbColor> for Color {
132    fn from(rgb: RgbColor) -> Self {
133        Color::Rgb(rgb.0, rgb.1, rgb.2)
134    }
135}
136
137/// All themeable components in the TUI
138#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Deserialize, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum Component {
141    // Status bar
142    StatusBar,
143
144    // Input panel
145    InputPanelBorder,
146    InputPanelBorderActive,
147    InputPanelBorderCommand,
148    InputPanelBorderApproval,
149    InputPanelBorderError,
150    InputPanelLabel,
151    InputPanelLabelActive,
152    InputPanelLabelCommand,
153    InputPanelLabelConfirmExit,
154
155    // Chat list
156    ChatListBorder,
157    ChatListBackground,
158    UserMessage,
159    UserMessageRole,
160    AssistantMessage,
161    AssistantMessageRole,
162    SystemMessage,
163    SystemMessageRole,
164
165    // Tool calls
166    ToolCall,
167    ToolCallBorder,
168    ToolCallHeader,
169    ToolCallId,
170    ToolOutput,
171    ToolSuccess,
172    ToolError,
173
174    // Assistant thoughts
175    ThoughtBox,
176    ThoughtHeader,
177    ThoughtBorder,
178    ThoughtText,
179
180    // Commands
181    CommandPrompt,
182    CommandText,
183    CommandSuccess,
184    CommandError,
185
186    // General
187    ErrorText,
188    ErrorBold,
189    DimText,
190    SelectionHighlight,
191    PlaceholderText,
192
193    // Model info
194    ModelInfo,
195
196    // Notices
197    NoticeInfo,
198    NoticeWarn,
199    NoticeError,
200
201    // Todo items
202    TodoHigh,
203    TodoMedium,
204    TodoLow,
205    TodoPending,
206    TodoInProgress,
207    TodoCompleted,
208
209    // Code editing
210    CodeAddition,
211    CodeDeletion,
212    CodeFilePath,
213
214    // Popup
215    PopupBorder,
216    PopupSelection,
217
218    // Markdown elements
219    MarkdownH1,
220    MarkdownH2,
221    MarkdownH3,
222    MarkdownH4,
223    MarkdownH5,
224    MarkdownH6,
225    MarkdownParagraph,
226    MarkdownBold,
227    MarkdownItalic,
228    MarkdownStrikethrough,
229    MarkdownCode,
230    MarkdownCodeBlock,
231    MarkdownLink,
232    MarkdownBlockquote,
233    MarkdownListBullet,
234    MarkdownListNumber,
235    MarkdownTaskChecked,
236    MarkdownTaskUnchecked,
237
238    // Markdown table elements
239    MarkdownTableBorder,
240    MarkdownTableHeader,
241    MarkdownTableCell,
242
243    // Setup UI components
244    SetupTitle,
245    SetupBorder,
246    SetupBorderActive,
247    SetupHeader,
248    SetupText,
249    SetupHighlight,
250    SetupKeyBinding,
251    SetupProviderName,
252    SetupProviderSelected,
253    SetupStatusActive,
254    SetupStatusInactive,
255    SetupStatusInProgress,
256    SetupSuccessIcon,
257    SetupErrorMessage,
258    SetupHint,
259    SetupUrl,
260    SetupInputLabel,
261    SetupInputValue,
262    SetupBigText,
263}
264
265impl fmt::Display for Component {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        write!(f, "{self:?}")
268    }
269}
270
271/// Compiled theme ready for use in the TUI
272#[derive(Debug, Clone)]
273pub struct CompiledTheme {
274    pub name: String,
275    pub styles: HashMap<Component, Style>,
276    pub background_color: Option<Color>,
277    pub syntax_theme: Option<syntect::highlighting::Theme>,
278}
279
280impl RawTheme {
281    /// Compile the theme into a usable format
282    pub fn into_theme(self) -> Result<Theme, ThemeError> {
283        let mut styles = HashMap::new();
284
285        // Extract background color from palette if it exists
286        let background_color = self.palette.get("background").map(|&rgb| rgb.into());
287
288        // Load syntect theme if configured
289        let syntax_theme = if let Some(syntax_config) = &self.syntax {
290            debug!("Loading syntect theme from config: {:?}", syntax_config);
291            Some(load_syntect_theme(syntax_config)?)
292        } else {
293            debug!("No syntax config found in theme");
294            None
295        };
296
297        // Build the style for each component
298        for (component, style_def) in &self.components {
299            let mut style = Style::default();
300
301            // Resolve foreground color
302            if let Some(fg) = &style_def.fg {
303                let color = self.resolve_color(fg.clone())?;
304                style = style.fg(color);
305            }
306
307            // Resolve background color
308            if let Some(bg) = &style_def.bg {
309                let color = self.resolve_color(bg.clone())?;
310                style = style.bg(color);
311            }
312
313            // Apply modifiers
314            if style_def.bold {
315                style = style.add_modifier(Modifier::BOLD);
316            }
317            if style_def.italic {
318                style = style.add_modifier(Modifier::ITALIC);
319            }
320            if style_def.underlined {
321                style = style.add_modifier(Modifier::UNDERLINED);
322            }
323
324            styles.insert(*component, style);
325        }
326
327        Ok(Theme {
328            name: self.name,
329            styles,
330            background_color,
331            syntax_theme,
332        })
333    }
334
335    /// Resolve a color value to a ratatui Color
336    fn resolve_color(&self, color_value: ColorValue) -> Result<Color, ThemeError> {
337        match color_value {
338            ColorValue::Palette(name) => {
339                // Look up in palette
340                self.palette
341                    .get(&name)
342                    .map(|&rgb| rgb.into())
343                    .ok_or(ThemeError::ColorNotFound(name))
344            }
345            ColorValue::Direct(color_str) => {
346                // Parse as direct color
347                parse_direct_color(&color_str)
348            }
349        }
350    }
351}
352
353/// Load a syntect theme based on configuration
354fn load_syntect_theme(config: &SyntaxConfig) -> Result<syntect::highlighting::Theme, ThemeError> {
355    if let Some(theme_name) = &config.syntect_theme {
356        // Try to load from built-in themes
357        THEME_SET.themes.get(theme_name).cloned().ok_or_else(|| {
358            ThemeError::Validation(format!("Syntect theme '{theme_name}' not found"))
359        })
360    } else {
361        // Default to a reasonable theme
362        THEME_SET
363            .themes
364            .get("base16-ocean.dark")
365            .cloned()
366            .ok_or_else(|| ThemeError::Validation("Default syntect theme not found".to_string()))
367    }
368}
369
370fn parse_direct_color(color_str: &str) -> Result<Color, ThemeError> {
371    // Try hex color first
372    if let Some(hex) = color_str.strip_prefix('#') {
373        if hex.len() == 6 {
374            let r = u8::from_str_radix(&hex[0..2], 16)
375                .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
376            let g = u8::from_str_radix(&hex[2..4], 16)
377                .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
378            let b = u8::from_str_radix(&hex[4..6], 16)
379                .map_err(|_| ThemeError::InvalidColor(color_str.to_string()))?;
380            return Ok(Color::Rgb(r, g, b));
381        }
382    }
383
384    // Try named colors
385    match color_str.to_lowercase().as_str() {
386        "black" => Ok(Color::Black),
387        "red" => Ok(Color::Red),
388        "green" => Ok(Color::Green),
389        "yellow" => Ok(Color::Yellow),
390        "blue" => Ok(Color::Blue),
391        "magenta" => Ok(Color::Magenta),
392        "cyan" => Ok(Color::Cyan),
393        "white" => Ok(Color::White),
394        "gray" | "grey" => Ok(Color::Gray),
395        "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Ok(Color::DarkGray),
396        "lightred" | "light_red" => Ok(Color::LightRed),
397        "lightgreen" | "light_green" => Ok(Color::LightGreen),
398        "lightyellow" | "light_yellow" => Ok(Color::LightYellow),
399        "lightblue" | "light_blue" => Ok(Color::LightBlue),
400        "lightmagenta" | "light_magenta" => Ok(Color::LightMagenta),
401        "lightcyan" | "light_cyan" => Ok(Color::LightCyan),
402        "reset" => Ok(Color::Reset),
403        _ => Err(ThemeError::InvalidColor(color_str.to_string())),
404    }
405}
406
407impl CompiledTheme {
408    /// Get a style for a component, falling back to default if not found
409    pub fn style(&self, component: Component) -> Style {
410        self.styles.get(&component).copied().unwrap_or_default()
411    }
412
413    /// Get the background color from the theme, if any
414    pub fn get_background_color(&self) -> Option<Color> {
415        self.background_color
416    }
417
418    // Convenience methods for common styles
419    pub fn error_text(&self) -> Style {
420        self.style(Component::ErrorText)
421    }
422
423    pub fn dim_text(&self) -> Style {
424        self.style(Component::DimText)
425    }
426
427    pub fn subtle_text(&self) -> Style {
428        self.style(Component::DimText)
429    }
430
431    pub fn text(&self) -> Style {
432        Style::default()
433    }
434}
435
436impl Default for CompiledTheme {
437    fn default() -> Self {
438        create_default_theme()
439    }
440}
441
442/// Create the default theme based on current hardcoded colors
443fn create_default_theme() -> CompiledTheme {
444    let mut styles = HashMap::new();
445
446    // Define default component styles based on current styles.rs
447    styles.insert(Component::StatusBar, Style::default().fg(Color::LightCyan));
448
449    // Input panel styles
450    styles.insert(
451        Component::InputPanelBorder,
452        Style::default().fg(Color::DarkGray),
453    );
454    styles.insert(
455        Component::InputPanelBorderActive,
456        Style::default().fg(Color::Yellow),
457    );
458    styles.insert(
459        Component::InputPanelBorderCommand,
460        Style::default().fg(Color::Cyan),
461    );
462    styles.insert(
463        Component::InputPanelBorderApproval,
464        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
465    );
466    styles.insert(
467        Component::InputPanelBorderError,
468        Style::default()
469            .fg(Color::LightRed)
470            .add_modifier(Modifier::BOLD),
471    );
472
473    // Chat list styles
474    styles.insert(
475        Component::ChatListBorder,
476        Style::default().fg(Color::DarkGray),
477    );
478    styles.insert(Component::ChatListBackground, Style::default());
479    styles.insert(Component::UserMessage, Style::default());
480    styles.insert(
481        Component::UserMessageRole,
482        Style::default()
483            .fg(Color::Green)
484            .add_modifier(Modifier::BOLD),
485    );
486    styles.insert(Component::AssistantMessage, Style::default());
487    styles.insert(
488        Component::AssistantMessageRole,
489        Style::default()
490            .fg(Color::Blue)
491            .add_modifier(Modifier::BOLD),
492    );
493    styles.insert(Component::SystemMessage, Style::default());
494    styles.insert(
495        Component::SystemMessageRole,
496        Style::default().fg(Color::Yellow),
497    );
498
499    // Tool styles
500    styles.insert(Component::ToolCall, Style::default().fg(Color::Cyan));
501    styles.insert(Component::ToolCallBorder, Style::default().fg(Color::Cyan));
502    styles.insert(Component::ToolCallHeader, Style::default().fg(Color::Cyan));
503    styles.insert(Component::ToolCallId, Style::default().fg(Color::DarkGray));
504    styles.insert(Component::ToolOutput, Style::default());
505    styles.insert(Component::ToolSuccess, Style::default().fg(Color::Green));
506    styles.insert(Component::ToolError, Style::default().fg(Color::Red));
507
508    // Thought styles
509    styles.insert(Component::ThoughtBox, Style::default().fg(Color::DarkGray));
510    styles.insert(Component::ThoughtHeader, Style::default().fg(Color::Gray));
511    styles.insert(
512        Component::ThoughtBorder,
513        Style::default().fg(Color::DarkGray),
514    );
515    styles.insert(
516        Component::ThoughtText,
517        Style::default()
518            .fg(Color::DarkGray)
519            .add_modifier(Modifier::ITALIC),
520    );
521
522    // Command styles
523    styles.insert(
524        Component::CommandPrompt,
525        Style::default()
526            .fg(Color::Green)
527            .add_modifier(Modifier::BOLD),
528    );
529    styles.insert(Component::CommandText, Style::default().fg(Color::Cyan));
530    styles.insert(Component::CommandSuccess, Style::default().fg(Color::Green));
531    styles.insert(Component::CommandError, Style::default().fg(Color::Red));
532
533    // General styles
534    styles.insert(Component::ErrorText, Style::default().fg(Color::Red));
535    styles.insert(
536        Component::ErrorBold,
537        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
538    );
539    styles.insert(Component::DimText, Style::default().fg(Color::DarkGray));
540    styles.insert(
541        Component::SelectionHighlight,
542        Style::default()
543            .fg(Color::Yellow)
544            .add_modifier(Modifier::BOLD),
545    );
546    styles.insert(
547        Component::PlaceholderText,
548        Style::default()
549            .fg(Color::DarkGray)
550            .add_modifier(Modifier::ITALIC),
551    );
552
553    // Model info
554    styles.insert(
555        Component::ModelInfo,
556        Style::default().fg(Color::LightMagenta),
557    );
558
559    // Notices
560    styles.insert(Component::NoticeInfo, Style::default().fg(Color::Blue));
561    styles.insert(Component::NoticeWarn, Style::default().fg(Color::Yellow));
562    styles.insert(Component::NoticeError, Style::default().fg(Color::Red));
563
564    // Todo priorities
565    styles.insert(Component::TodoHigh, Style::default().fg(Color::Red));
566    styles.insert(Component::TodoMedium, Style::default().fg(Color::Yellow));
567    styles.insert(Component::TodoLow, Style::default().fg(Color::Green));
568    styles.insert(Component::TodoPending, Style::default().fg(Color::Blue));
569    styles.insert(
570        Component::TodoInProgress,
571        Style::default().fg(Color::Yellow),
572    );
573    styles.insert(Component::TodoCompleted, Style::default().fg(Color::Green));
574
575    // Code editing
576    styles.insert(Component::CodeAddition, Style::default().fg(Color::Green));
577    styles.insert(Component::CodeDeletion, Style::default().fg(Color::Red));
578    styles.insert(Component::CodeFilePath, Style::default().fg(Color::Yellow));
579
580    // Popup
581    styles.insert(Component::PopupBorder, Style::default().fg(Color::White));
582    styles.insert(
583        Component::PopupSelection,
584        Style::default().fg(Color::Yellow).bg(Color::DarkGray),
585    );
586
587    // Markdown styles
588    styles.insert(Component::MarkdownH1, Style::default().fg(Color::Cyan));
589    styles.insert(Component::MarkdownH2, Style::default().fg(Color::Cyan));
590    styles.insert(Component::MarkdownH3, Style::default().fg(Color::Cyan));
591    styles.insert(Component::MarkdownH4, Style::default().fg(Color::LightCyan));
592    styles.insert(Component::MarkdownH5, Style::default().fg(Color::LightCyan));
593    styles.insert(Component::MarkdownH6, Style::default().fg(Color::Gray));
594    styles.insert(Component::MarkdownParagraph, Style::default());
595    styles.insert(Component::MarkdownBold, Style::default());
596    styles.insert(Component::MarkdownItalic, Style::default());
597    styles.insert(Component::MarkdownStrikethrough, Style::default());
598    styles.insert(
599        Component::MarkdownCode,
600        Style::default().fg(Color::White).bg(Color::Black),
601    );
602    styles.insert(
603        Component::MarkdownCodeBlock,
604        Style::default().bg(Color::Black),
605    );
606    styles.insert(Component::MarkdownLink, Style::default().fg(Color::Blue));
607    styles.insert(
608        Component::MarkdownBlockquote,
609        Style::default().fg(Color::Green),
610    );
611    styles.insert(
612        Component::MarkdownListBullet,
613        Style::default().fg(Color::Gray),
614    );
615    styles.insert(
616        Component::MarkdownListNumber,
617        Style::default().fg(Color::LightBlue),
618    );
619
620    // Table styles
621    styles.insert(
622        Component::MarkdownTableBorder,
623        Style::default().fg(Color::DarkGray),
624    );
625    styles.insert(
626        Component::MarkdownTableHeader,
627        Style::default()
628            .fg(Color::Cyan)
629            .add_modifier(Modifier::BOLD),
630    );
631    styles.insert(Component::MarkdownTableCell, Style::default());
632
633    // Task list styles
634    styles.insert(
635        Component::MarkdownTaskChecked,
636        Style::default().fg(Color::Green),
637    );
638    styles.insert(
639        Component::MarkdownTaskUnchecked,
640        Style::default().fg(Color::Gray),
641    );
642
643    // Setup UI styles
644    styles.insert(
645        Component::SetupTitle,
646        Style::default()
647            .fg(Color::Cyan)
648            .add_modifier(Modifier::BOLD),
649    );
650    styles.insert(Component::SetupBorder, Style::default().fg(Color::DarkGray));
651    styles.insert(
652        Component::SetupBorderActive,
653        Style::default().fg(Color::Yellow),
654    );
655    styles.insert(
656        Component::SetupHeader,
657        Style::default()
658            .fg(Color::Cyan)
659            .add_modifier(Modifier::BOLD),
660    );
661    styles.insert(Component::SetupText, Style::default());
662    styles.insert(
663        Component::SetupHighlight,
664        Style::default()
665            .bg(Color::DarkGray)
666            .add_modifier(Modifier::BOLD),
667    );
668    styles.insert(
669        Component::SetupKeyBinding,
670        Style::default()
671            .fg(Color::Green)
672            .add_modifier(Modifier::BOLD),
673    );
674    styles.insert(Component::SetupProviderName, Style::default());
675    styles.insert(
676        Component::SetupProviderSelected,
677        Style::default()
678            .bg(Color::DarkGray)
679            .add_modifier(Modifier::BOLD),
680    );
681    styles.insert(
682        Component::SetupStatusActive,
683        Style::default().fg(Color::Green),
684    );
685    styles.insert(
686        Component::SetupStatusInactive,
687        Style::default().fg(Color::Red),
688    );
689    styles.insert(
690        Component::SetupStatusInProgress,
691        Style::default().fg(Color::Yellow),
692    );
693    styles.insert(
694        Component::SetupSuccessIcon,
695        Style::default()
696            .fg(Color::Green)
697            .add_modifier(Modifier::BOLD),
698    );
699    styles.insert(
700        Component::SetupErrorMessage,
701        Style::default().fg(Color::Red),
702    );
703    styles.insert(Component::SetupHint, Style::default().fg(Color::DarkGray));
704    styles.insert(
705        Component::SetupUrl,
706        Style::default()
707            .fg(Color::Blue)
708            .add_modifier(Modifier::UNDERLINED),
709    );
710    styles.insert(Component::SetupInputLabel, Style::default());
711    styles.insert(
712        Component::SetupInputValue,
713        Style::default().fg(Color::Yellow),
714    );
715    styles.insert(
716        Component::SetupBigText,
717        Style::default()
718            .fg(Color::Cyan)
719            .add_modifier(Modifier::BOLD),
720    );
721
722    CompiledTheme {
723        name: "Default".to_string(),
724        styles,
725        background_color: None, // Default theme has no background color
726        syntax_theme: None,
727    }
728}