1use ratatui::style::Color;
14use saphyr::{LoadableYamlNode, Yaml};
15
16use crate::settings::Settings;
17
18const DARK_YAML: &str = include_str!("../data/themes/dark.yaml");
23const LIGHT_YAML: &str = include_str!("../data/themes/light.yaml");
24
25#[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 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 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 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 fn load_named(name: &str, settings: &Settings) -> Self {
127 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 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 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 fn parse_yaml_with_inheritance(yaml_text: &str, settings: &Settings) -> Self {
160 let map = load_yaml_map(yaml_text);
161
162 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 Self::from_yaml_map(&load_yaml_map(&parent_yaml))
170 } else {
171 Self::dark_fallback()
173 };
174
175 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
247fn 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
259fn 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
279pub 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}