Skip to main content

oo_ide/
theme.rs

1//! Theme system — loads colour palettes from YAML and exposes typed accessors.
2//!
3//! Resolution order for a theme named `<name>`:
4//!   1. `.oo/themes/<name>.yaml`          (project-local override)
5//!   2. `~/.config/oo/themes/<name>.yaml` (global user override)
6//!   3. Built-in embedded `dark` / `light` (compiled-in via `include_str!`)
7//!
8//! Non-base themes may declare `extends: dark` or `extends: light`; missing
9//! keys are then inherited from the parent.  Base themes must define every key.
10//!
11//! Per-key overrides can be set in settings under `theme.colors.<field>`.
12
13use ratatui::style::Color;
14use saphyr::{LoadableYamlNode, Yaml};
15
16use crate::settings::Settings;
17
18// ---------------------------------------------------------------------------
19// Embedded base themes
20// ---------------------------------------------------------------------------
21
22const DARK_YAML: &str = include_str!("../data/themes/dark.yaml");
23const LIGHT_YAML: &str = include_str!("../data/themes/light.yaml");
24
25// ---------------------------------------------------------------------------
26// Theme struct
27// ---------------------------------------------------------------------------
28
29/// Resolved colour palette.  All colours are stored as `ratatui::style::Color`.
30#[derive(Debug, Clone)]
31pub struct Theme {
32    bg: Color,
33    bg_active: Color,
34    bg_inactive: Color,
35    fg: Color,
36    fg_dim: Color,
37    fg_active: Color,
38    accent: Color,
39    selection_bg: Color,
40    selection_fg: Color,
41    gutter_bg: Color,
42    level_error: Color,
43    level_warn: Color,
44    level_info: Color,
45    level_success: Color,
46    level_debug: Color,
47    level_trace: Color,
48    tree_dir: Color,
49    status_bar_bg: Color,
50    status_bar_fg: Color,
51}
52
53impl Theme {
54    // ── Accessors ─────────────────────────────────────────────────────────
55
56    pub fn bg(&self) -> Color { self.bg }
57    pub fn bg_active(&self) -> Color { self.bg_active }
58    pub fn bg_inactive(&self) -> Color { self.bg_inactive }
59    pub fn fg(&self) -> Color { self.fg }
60    pub fn fg_dim(&self) -> Color { self.fg_dim }
61    pub fn fg_active(&self) -> Color { self.fg_active }
62    pub fn accent(&self) -> Color { self.accent }
63    pub fn selection_bg(&self) -> Color { self.selection_bg }
64    pub fn selection_fg(&self) -> Color { self.selection_fg }
65    pub fn gutter_bg(&self) -> Color { self.gutter_bg }
66    pub fn level_error(&self) -> Color { self.level_error }
67    pub fn level_warn(&self) -> Color { self.level_warn }
68    pub fn level_info(&self) -> Color { self.level_info }
69    pub fn level_success(&self) -> Color { self.level_success }
70    pub fn level_debug(&self) -> Color { self.level_debug }
71    pub fn level_trace(&self) -> Color { self.level_trace }
72    pub fn tree_dir(&self) -> Color { self.tree_dir }
73    pub fn status_bar_bg(&self) -> Color { self.status_bar_bg }
74    pub fn status_bar_fg(&self) -> Color { self.status_bar_fg }
75
76    // ── Resolution ────────────────────────────────────────────────────────
77
78    /// Build a `Theme` from settings.
79    ///
80    /// Reads `theme.name` (default `"dark"`), locates the YAML, resolves
81    /// inheritance from a base theme, then applies any per-key overrides
82    /// from `theme.colors.*` settings keys.
83    pub fn resolve(settings: &Settings) -> Self {
84        let theme_name = settings
85            .get_optional::<String>("theme.name")
86            .cloned()
87            .unwrap_or_else(|| "dark".to_owned());
88        let name: &str = &theme_name;
89
90        let mut theme = Self::load_named(name, settings);
91
92        // Apply per-key overrides from settings.
93        macro_rules! override_key {
94            ($field:ident) => {
95                let key = concat!("theme.colors.", stringify!($field));
96                if let Some(val) = settings.get_optional::<String>(key) {
97                    theme.$field = parse_color(val);
98                }
99            };
100        }
101        override_key!(bg);
102        override_key!(bg_active);
103        override_key!(bg_inactive);
104        override_key!(fg);
105        override_key!(fg_dim);
106        override_key!(fg_active);
107        override_key!(accent);
108        override_key!(selection_bg);
109        override_key!(selection_fg);
110        override_key!(gutter_bg);
111        override_key!(level_error);
112        override_key!(level_warn);
113        override_key!(level_info);
114        override_key!(level_success);
115        override_key!(level_debug);
116        override_key!(level_trace);
117        override_key!(tree_dir);
118        override_key!(status_bar_bg);
119        override_key!(status_bar_fg);
120
121        theme
122    }
123
124    // ── Internals ─────────────────────────────────────────────────────────
125
126    fn load_named(name: &str, settings: &Settings) -> Self {
127        // Try to find an external YAML first.
128        let yaml_text = Self::find_external_yaml(name, settings)
129            .or_else(|| embedded_yaml(name))
130            .unwrap_or_else(|| {
131                log::warn!("Theme '{}' not found, falling back to 'dark'", name);
132                DARK_YAML.to_owned()
133            });
134
135        Self::parse_yaml_with_inheritance(&yaml_text, settings)
136    }
137
138    fn find_external_yaml(name: &str, settings: &Settings) -> Option<String> {
139        let filename = format!("{}.yaml", name);
140
141        // 1. Project-local: .oo/themes/<name>.yaml
142        let project_theme = settings.project_config_dir().join("themes").join(&filename);
143        if project_theme.exists()
144            && let Ok(text) = std::fs::read_to_string(&project_theme) {
145                return Some(text);
146            }
147
148        // 2. Global: ~/.config/oo/themes/<name>.yaml
149        let global_theme = settings.global_config_dir().join("themes").join(&filename);
150        if global_theme.exists()
151            && let Ok(text) = std::fs::read_to_string(&global_theme) {
152                return Some(text);
153            }
154
155        None
156    }
157
158    /// Parse a theme YAML, optionally inheriting from a base theme.
159    fn parse_yaml_with_inheritance(yaml_text: &str, settings: &Settings) -> Self {
160        let map = load_yaml_map(yaml_text);
161
162        // Check for `extends` key — must be "dark" or "light".
163        let parent = map
164            .get("extends")
165            .and_then(|v| embedded_yaml(v.as_str()));
166
167        let mut base = if let Some(parent_yaml) = parent {
168            // Load parent first (always a built-in base, no further inheritance).
169            Self::from_yaml_map(&load_yaml_map(&parent_yaml))
170        } else {
171            // No extends → treat as self-contained base.
172            Self::dark_fallback()
173        };
174
175        // Overlay keys present in the child theme.
176        Self::apply_map_to(&map, &mut base, settings);
177        base
178    }
179
180    fn from_yaml_map(map: &std::collections::HashMap<String, String>) -> Self {
181        let get = |key: &str| {
182            map.get(key)
183                .map(|s| parse_color(s))
184                .unwrap_or(Color::Reset)
185        };
186        Self {
187            bg: get("bg"),
188            bg_active: get("bg_active"),
189            bg_inactive: get("bg_inactive"),
190            fg: get("fg"),
191            fg_dim: get("fg_dim"),
192            fg_active: get("fg_active"),
193            accent: get("accent"),
194            selection_bg: get("selection_bg"),
195            selection_fg: get("selection_fg"),
196            gutter_bg: get("gutter_bg"),
197            level_error: get("level_error"),
198            level_warn: get("level_warn"),
199            level_info: get("level_info"),
200            level_success: get("level_success"),
201            level_debug: get("level_debug"),
202            level_trace: get("level_trace"),
203            tree_dir: get("tree_dir"),
204            status_bar_bg: get("status_bar_bg"),
205            status_bar_fg: get("status_bar_fg"),
206        }
207    }
208
209    fn apply_map_to(
210        map: &std::collections::HashMap<String, String>,
211        base: &mut Self,
212        _settings: &Settings,
213    ) {
214        macro_rules! apply {
215            ($field:ident) => {
216                if let Some(v) = map.get(stringify!($field)) {
217                    base.$field = parse_color(v);
218                }
219            };
220        }
221        apply!(bg);
222        apply!(bg_active);
223        apply!(bg_inactive);
224        apply!(fg);
225        apply!(fg_dim);
226        apply!(fg_active);
227        apply!(accent);
228        apply!(selection_bg);
229        apply!(selection_fg);
230        apply!(gutter_bg);
231        apply!(level_error);
232        apply!(level_warn);
233        apply!(level_info);
234        apply!(level_success);
235        apply!(level_debug);
236        apply!(level_trace);
237        apply!(tree_dir);
238        apply!(status_bar_bg);
239        apply!(status_bar_fg);
240    }
241
242    fn dark_fallback() -> Self {
243        Self::from_yaml_map(&load_yaml_map(DARK_YAML))
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Helpers
249// ---------------------------------------------------------------------------
250
251fn embedded_yaml(name: &str) -> Option<String> {
252    match name {
253        "dark" => Some(DARK_YAML.to_owned()),
254        "light" => Some(LIGHT_YAML.to_owned()),
255        _ => None,
256    }
257}
258
259/// Parse a flat YAML file into a `String → String` map.
260fn load_yaml_map(text: &str) -> std::collections::HashMap<String, String> {
261    let mut map = std::collections::HashMap::new();
262    let nodes: Vec<Yaml> = match Yaml::load_from_str(text) {
263        Ok(n) => n,
264        Err(e) => {
265            log::error!("Failed to parse theme YAML: {e}");
266            return map;
267        }
268    };
269    if let Some(Yaml::Mapping(mapping)) = nodes.first() {
270        for (k, v) in mapping.iter() {
271            if let (Some(key), Some(val)) = (k.as_str(), v.as_str()) {
272                map.insert(key.to_owned(), val.to_owned());
273            }
274        }
275    }
276    map
277}
278
279/// Parse a colour string into a `ratatui::style::Color`.
280///
281/// Supports:
282/// - `"default"` → `Color::Reset`
283/// - `"#RRGGBB"` → `Color::Rgb(r, g, b)`
284/// - Named terminal colours: `black`, `red`, `green`, `yellow`, `blue`,
285///   `magenta`, `cyan`, `white`, `gray` / `grey`, `darkgray` / `darkgrey`
286pub fn parse_color(s: &str) -> Color {
287    let s = s.trim();
288    if s.eq_ignore_ascii_case("default") || s.eq_ignore_ascii_case("reset") {
289        return Color::Reset;
290    }
291    if let Some(hex) = s.strip_prefix('#')
292        && hex.len() == 6
293            && let (Ok(r), Ok(g), Ok(b)) = (
294                u8::from_str_radix(&hex[0..2], 16),
295                u8::from_str_radix(&hex[2..4], 16),
296                u8::from_str_radix(&hex[4..6], 16),
297            ) {
298                return Color::Rgb(r, g, b);
299            }
300    match s.to_ascii_lowercase().as_str() {
301        "black" => Color::Black,
302        "red" => Color::Red,
303        "green" => Color::Green,
304        "yellow" => Color::Yellow,
305        "blue" => Color::Blue,
306        "magenta" => Color::Magenta,
307        "cyan" => Color::Cyan,
308        "white" => Color::White,
309        "gray" | "grey" => Color::Gray,
310        "darkgray" | "darkgrey" | "dark_gray" | "dark_grey" => Color::DarkGray,
311        other => {
312            log::warn!("Unknown colour '{}', using Reset", other);
313            Color::Reset
314        }
315    }
316}