git_iris/theme/
mod.rs

1//! Token-based theme system for Git-Iris.
2//!
3//! This module provides a flexible, TOML-configurable theme system that supports:
4//! - Color primitives defined in a palette
5//! - Semantic tokens that reference palette colors or other tokens
6//! - Composed styles with foreground, background, and modifiers
7//! - Gradient definitions for smooth color transitions
8//! - Runtime theme switching with thread-safe global state
9//!
10//! # Theme File Format
11//!
12//! Themes are defined in TOML files with the following structure:
13//!
14//! ```toml
15//! [meta]
16//! name = "My Theme"
17//! author = "Your Name"
18//! variant = "dark"  # or "light"
19//!
20//! [palette]
21//! purple_500 = "#e135ff"
22//! cyan_400 = "#80ffea"
23//!
24//! [tokens]
25//! text.primary = "#f8f8f2"
26//! accent.primary = "purple_500"  # references palette
27//! success = "cyan_400"
28//!
29//! [styles]
30//! keyword = { fg = "accent.primary", bold = true }
31//!
32//! [gradients]
33//! primary = ["purple_500", "cyan_400"]
34//! ```
35//!
36//! # Usage
37//!
38//! ```ignore
39//! use git_iris::theme;
40//!
41//! // Get current theme
42//! let theme = theme::current();
43//!
44//! // Access colors
45//! let color = theme.color("accent.primary");
46//!
47//! // Access styles
48//! let style = theme.style("keyword");
49//!
50//! // Access gradients
51//! let gradient_color = theme.gradient("primary", 0.5);
52//! ```
53
54pub mod adapters;
55mod color;
56mod error;
57mod gradient;
58mod loader;
59mod resolver;
60mod schema;
61
62pub mod builtins;
63
64use std::collections::HashMap;
65use std::path::Path;
66use std::sync::{Arc, LazyLock};
67
68use parking_lot::RwLock;
69
70// Re-exports
71pub use color::ThemeColor;
72pub use error::ThemeError;
73pub use gradient::Gradient;
74pub use schema::{ThemeMeta, ThemeVariant};
75pub use style::ThemeStyle;
76
77mod style;
78
79/// A resolved theme with all tokens and styles ready for use.
80#[derive(Debug, Clone)]
81pub struct Theme {
82    /// Theme metadata.
83    pub meta: ThemeMeta,
84
85    /// Resolved color palette (palette name -> color).
86    palette: HashMap<String, ThemeColor>,
87
88    /// Resolved semantic tokens (token name -> color).
89    tokens: HashMap<String, ThemeColor>,
90
91    /// Resolved composed styles (style name -> style).
92    styles: HashMap<String, ThemeStyle>,
93
94    /// Resolved gradients (gradient name -> gradient).
95    gradients: HashMap<String, Gradient>,
96}
97
98impl Theme {
99    /// Get a color by token name.
100    ///
101    /// Falls back to `ThemeColor::FALLBACK` if the token is not found.
102    #[must_use]
103    pub fn color(&self, token: &str) -> ThemeColor {
104        self.tokens
105            .get(token)
106            .or_else(|| self.palette.get(token))
107            .copied()
108            .unwrap_or(ThemeColor::FALLBACK)
109    }
110
111    /// Get a style by name.
112    ///
113    /// Returns a default (empty) style if not found.
114    #[must_use]
115    pub fn style(&self, name: &str) -> ThemeStyle {
116        self.styles.get(name).cloned().unwrap_or_default()
117    }
118
119    /// Get a gradient color at position `t` (0.0 to 1.0).
120    ///
121    /// Falls back to `ThemeColor::FALLBACK` if the gradient is not found.
122    #[must_use]
123    pub fn gradient(&self, name: &str, t: f32) -> ThemeColor {
124        self.gradients
125            .get(name)
126            .map_or(ThemeColor::FALLBACK, |g| g.at(t))
127    }
128
129    /// Get a gradient by name for manual interpolation.
130    #[must_use]
131    pub fn get_gradient(&self, name: &str) -> Option<&Gradient> {
132        self.gradients.get(name)
133    }
134
135    /// Check if a token exists.
136    #[must_use]
137    pub fn has_token(&self, token: &str) -> bool {
138        self.tokens.contains_key(token) || self.palette.contains_key(token)
139    }
140
141    /// Check if a style exists.
142    #[must_use]
143    pub fn has_style(&self, name: &str) -> bool {
144        self.styles.contains_key(name)
145    }
146
147    /// Check if a gradient exists.
148    #[must_use]
149    pub fn has_gradient(&self, name: &str) -> bool {
150        self.gradients.contains_key(name)
151    }
152
153    /// Get all token names.
154    #[must_use]
155    pub fn token_names(&self) -> Vec<&str> {
156        self.tokens.keys().map(String::as_str).collect()
157    }
158
159    /// Get all style names.
160    #[must_use]
161    pub fn style_names(&self) -> Vec<&str> {
162        self.styles.keys().map(String::as_str).collect()
163    }
164
165    /// Get all gradient names.
166    #[must_use]
167    pub fn gradient_names(&self) -> Vec<&str> {
168        self.gradients.keys().map(String::as_str).collect()
169    }
170
171    /// Load the builtin `SilkCircuit` Neon theme.
172    #[must_use]
173    pub fn builtin_neon() -> Self {
174        builtins::silkcircuit_neon()
175    }
176}
177
178impl Default for Theme {
179    fn default() -> Self {
180        Self::builtin_neon()
181    }
182}
183
184// ═══════════════════════════════════════════════════════════════════════════════
185// Global Theme State
186// ═══════════════════════════════════════════════════════════════════════════════
187
188/// Global active theme.
189static ACTIVE_THEME: LazyLock<RwLock<Arc<Theme>>> =
190    LazyLock::new(|| RwLock::new(Arc::new(Theme::builtin_neon())));
191
192/// Get the current active theme.
193#[must_use]
194pub fn current() -> Arc<Theme> {
195    ACTIVE_THEME.read().clone()
196}
197
198/// Set the active theme.
199pub fn set_theme(theme: Theme) {
200    *ACTIVE_THEME.write() = Arc::new(theme);
201}
202
203/// Load and set a theme from a file path.
204///
205/// # Errors
206/// Returns an error if the theme file cannot be loaded or parsed.
207pub fn load_theme(path: &Path) -> Result<(), ThemeError> {
208    let theme = loader::load_from_file(path)?;
209    set_theme(theme);
210    Ok(())
211}
212
213/// Load and set a theme by name (searches discovery paths).
214///
215/// # Errors
216/// Returns an error if the theme is not found or cannot be loaded.
217pub fn load_theme_by_name(name: &str) -> Result<(), ThemeError> {
218    // Check builtins first
219    if let Some(theme) = builtins::load_by_name(name) {
220        set_theme(theme);
221        return Ok(());
222    }
223
224    // Search discovery paths
225    for path in discovery_paths() {
226        let theme_path = path.join(format!("{name}.toml"));
227        if theme_path.exists() {
228            return load_theme(&theme_path);
229        }
230    }
231
232    Err(ThemeError::ThemeNotFound {
233        name: name.to_string(),
234    })
235}
236
237/// List all available themes.
238#[must_use]
239pub fn list_available_themes() -> Vec<ThemeInfo> {
240    // Start with all builtin themes
241    let mut themes: Vec<ThemeInfo> = builtins::builtin_names()
242        .iter()
243        .map(|(name, display_name)| {
244            let theme = builtins::load_by_name(name).expect("builtin theme should load");
245            ThemeInfo {
246                name: (*name).to_string(),
247                display_name: (*display_name).to_string(),
248                variant: theme.meta.variant,
249                author: theme.meta.author.clone().unwrap_or_default(),
250                description: theme.meta.description.clone().unwrap_or_default(),
251                builtin: true,
252                path: None,
253            }
254        })
255        .collect();
256
257    // Scan discovery paths for additional themes
258    for dir in discovery_paths() {
259        if let Ok(entries) = std::fs::read_dir(&dir) {
260            for entry in entries.flatten() {
261                let path = entry.path();
262                if path.extension().is_some_and(|ext| ext == "toml")
263                    && let Ok(theme) = loader::load_from_file(&path)
264                {
265                    let name = path
266                        .file_stem()
267                        .and_then(|s| s.to_str())
268                        .unwrap_or("unknown")
269                        .to_string();
270
271                    themes.push(ThemeInfo {
272                        name,
273                        display_name: theme.meta.name,
274                        variant: theme.meta.variant,
275                        author: theme.meta.author.unwrap_or_default(),
276                        description: theme.meta.description.unwrap_or_default(),
277                        builtin: false,
278                        path: Some(path),
279                    });
280                }
281            }
282        }
283    }
284
285    themes
286}
287
288/// Information about an available theme.
289#[derive(Debug, Clone)]
290pub struct ThemeInfo {
291    /// Theme identifier (filename without extension).
292    pub name: String,
293    /// Display name from theme metadata.
294    pub display_name: String,
295    /// Theme variant (dark/light).
296    pub variant: ThemeVariant,
297    /// Theme author.
298    pub author: String,
299    /// Theme description.
300    pub description: String,
301    /// Whether this is a builtin theme.
302    pub builtin: bool,
303    /// Path to theme file (None for builtins).
304    pub path: Option<std::path::PathBuf>,
305}
306
307/// Get the theme discovery paths.
308///
309/// Themes are searched in order:
310/// 1. `~/.config/git-iris/themes/`
311/// 2. `$XDG_CONFIG_HOME/git-iris/themes/` (if different from above)
312fn discovery_paths() -> Vec<std::path::PathBuf> {
313    let mut paths = Vec::new();
314
315    // User config directory
316    if let Some(home) = dirs::home_dir() {
317        paths.push(home.join(".config/git-iris/themes"));
318    }
319
320    // XDG config directory
321    if let Some(xdg_config) = dirs::config_dir() {
322        let xdg_path = xdg_config.join("git-iris/themes");
323        if !paths.contains(&xdg_path) {
324            paths.push(xdg_path);
325        }
326    }
327
328    paths
329}
330
331#[cfg(test)]
332mod tests;