1use std::collections::HashMap;
7use std::path::Path;
8
9use ratatui::style::{Color, Modifier, Style as RatatuiStyle};
10use serde::Deserialize;
11
12#[derive(Debug)]
14pub enum ThemeError {
15 Io(std::io::Error),
17 Parse(toml::de::Error),
19 InvalidColor(String),
21}
22
23impl std::fmt::Display for ThemeError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 ThemeError::Io(e) => write!(f, "IO error: {}", e),
27 ThemeError::Parse(e) => write!(f, "Parse error: {}", e),
28 ThemeError::InvalidColor(c) => write!(f, "Invalid color: {}", c),
29 }
30 }
31}
32
33impl std::error::Error for ThemeError {}
34
35impl From<std::io::Error> for ThemeError {
36 fn from(e: std::io::Error) -> Self {
37 ThemeError::Io(e)
38 }
39}
40
41impl From<toml::de::Error> for ThemeError {
42 fn from(e: toml::de::Error) -> Self {
43 ThemeError::Parse(e)
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum StyleModifier {
51 Bold,
52 Dim,
53 Italic,
54 Underlined,
55 SlowBlink,
56 RapidBlink,
57 Reversed,
58 Hidden,
59 CrossedOut,
60}
61
62impl StyleModifier {
63 fn to_ratatui_modifier(self) -> Modifier {
64 match self {
65 StyleModifier::Bold => Modifier::BOLD,
66 StyleModifier::Dim => Modifier::DIM,
67 StyleModifier::Italic => Modifier::ITALIC,
68 StyleModifier::Underlined => Modifier::UNDERLINED,
69 StyleModifier::SlowBlink => Modifier::SLOW_BLINK,
70 StyleModifier::RapidBlink => Modifier::RAPID_BLINK,
71 StyleModifier::Reversed => Modifier::REVERSED,
72 StyleModifier::Hidden => Modifier::HIDDEN,
73 StyleModifier::CrossedOut => Modifier::CROSSED_OUT,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(default)]
81pub struct Style {
82 pub fg: Option<String>,
84 pub bg: Option<String>,
86 #[serde(default)]
88 pub modifiers: Vec<StyleModifier>,
89}
90
91impl Style {
92 pub fn to_ratatui_style(&self, palette: &HashMap<String, String>) -> RatatuiStyle {
94 let mut style = RatatuiStyle::default();
95
96 if let Some(ref fg) = self.fg {
97 if let Some(color) = resolve_color(fg, palette) {
98 style = style.fg(color);
99 }
100 }
101
102 if let Some(ref bg) = self.bg {
103 if let Some(color) = resolve_color(bg, palette) {
104 style = style.bg(color);
105 }
106 }
107
108 for modifier in &self.modifiers {
109 style = style.add_modifier(modifier.to_ratatui_modifier());
110 }
111
112 style
113 }
114}
115
116fn resolve_color(color: &str, palette: &HashMap<String, String>) -> Option<Color> {
123 if let Some(resolved) = palette.get(color) {
125 return parse_color(resolved);
126 }
127
128 parse_color(color)
130}
131
132fn parse_color(color: &str) -> Option<Color> {
134 let color = color.trim();
135
136 if let Some(hex) = color.strip_prefix('#') {
138 return match hex.len() {
139 6 => {
140 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
141 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
142 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
143 Some(Color::Rgb(r, g, b))
144 }
145 3 => {
146 let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
147 let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
148 let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
149 Some(Color::Rgb(r, g, b))
150 }
151 _ => None,
152 };
153 }
154
155 match color.to_lowercase().as_str() {
157 "black" => Some(Color::Black),
158 "red" => Some(Color::Red),
159 "green" => Some(Color::Green),
160 "yellow" => Some(Color::Yellow),
161 "blue" => Some(Color::Blue),
162 "magenta" => Some(Color::Magenta),
163 "cyan" => Some(Color::Cyan),
164 "gray" | "grey" => Some(Color::Gray),
165 "darkgray" | "darkgrey" => Some(Color::DarkGray),
166 "lightred" => Some(Color::LightRed),
167 "lightgreen" => Some(Color::LightGreen),
168 "lightyellow" => Some(Color::LightYellow),
169 "lightblue" => Some(Color::LightBlue),
170 "lightmagenta" => Some(Color::LightMagenta),
171 "lightcyan" => Some(Color::LightCyan),
172 "white" => Some(Color::White),
173 _ => None,
174 }
175}
176
177#[derive(Debug, Deserialize)]
179struct RawTheme {
180 #[serde(default)]
181 palette: HashMap<String, String>,
182 #[serde(flatten)]
183 styles: HashMap<String, StyleValue>,
184}
185
186#[derive(Debug, Deserialize)]
188#[serde(untagged)]
189enum StyleValue {
190 Full(Style),
192 Simple(String),
194}
195
196impl StyleValue {
197 fn into_style(self) -> Style {
198 match self {
199 StyleValue::Full(s) => s,
200 StyleValue::Simple(fg) => Style {
201 fg: Some(fg),
202 bg: None,
203 modifiers: Vec::new(),
204 },
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
213pub struct Theme {
214 pub name: String,
216 palette: HashMap<String, String>,
218 styles: HashMap<String, Style>,
220 cached_styles: HashMap<String, RatatuiStyle>,
222}
223
224impl Theme {
225 pub fn from_toml(toml_str: &str) -> Result<Self, ThemeError> {
227 Self::from_toml_with_name(toml_str, "custom")
228 }
229
230 pub fn from_toml_with_name(toml_str: &str, name: &str) -> Result<Self, ThemeError> {
232 let raw: RawTheme = toml::from_str(toml_str)?;
233
234 let mut styles = HashMap::new();
235 for (key, value) in raw.styles {
236 if key == "palette" {
238 continue;
239 }
240 styles.insert(key, value.into_style());
241 }
242
243 let mut theme = Self {
244 name: name.to_string(),
245 palette: raw.palette,
246 styles,
247 cached_styles: HashMap::new(),
248 };
249
250 theme.cache_styles();
252
253 Ok(theme)
254 }
255
256 pub fn from_file(path: &Path) -> Result<Self, ThemeError> {
258 let content = std::fs::read_to_string(path)?;
259 let name = path
260 .file_stem()
261 .and_then(|s| s.to_str())
262 .unwrap_or("custom");
263 Self::from_toml_with_name(&content, name)
264 }
265
266 pub fn style_for(&self, capture: &str) -> RatatuiStyle {
270 if let Some(style) = self.cached_styles.get(capture) {
272 return *style;
273 }
274
275 let mut parts: Vec<&str> = capture.split('.').collect();
277 while parts.len() > 1 {
278 parts.pop();
279 let parent = parts.join(".");
280 if let Some(style) = self.cached_styles.get(&parent) {
281 return *style;
282 }
283 }
284
285 RatatuiStyle::default()
287 }
288
289 fn cache_styles(&mut self) {
291 self.cached_styles.clear();
292 for (name, style) in &self.styles {
293 let ratatui_style = style.to_ratatui_style(&self.palette);
294 self.cached_styles.insert(name.clone(), ratatui_style);
295 }
296 }
297
298 pub fn capture_names(&self) -> Vec<&str> {
300 self.styles.keys().map(|s| s.as_str()).collect()
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_parse_hex_color() {
310 assert_eq!(parse_color("#FF0000"), Some(Color::Rgb(255, 0, 0)));
311 assert_eq!(parse_color("#00FF00"), Some(Color::Rgb(0, 255, 0)));
312 assert_eq!(parse_color("#0000FF"), Some(Color::Rgb(0, 0, 255)));
313 assert_eq!(parse_color("#F00"), Some(Color::Rgb(255, 0, 0)));
314 }
315
316 #[test]
317 fn test_parse_named_color() {
318 assert_eq!(parse_color("red"), Some(Color::Red));
319 assert_eq!(parse_color("Blue"), Some(Color::Blue));
320 assert_eq!(parse_color("GREEN"), Some(Color::Green));
321 }
322
323 #[test]
324 fn test_theme_from_toml() {
325 let toml = r##"
326 [palette]
327 red = "#E06C75"
328 green = "#98C379"
329
330 [keyword]
331 fg = "red"
332 modifiers = ["bold"]
333
334 [string]
335 fg = "green"
336 "##;
337
338 let theme = Theme::from_toml(toml).unwrap();
339
340 let keyword_style = theme.style_for("keyword");
341 assert!(keyword_style.fg.is_some());
342
343 let string_style = theme.style_for("string");
344 assert!(string_style.fg.is_some());
345 }
346
347 #[test]
348 fn test_hierarchical_fallback() {
349 let toml = r##"
350 [keyword]
351 fg = "#FF0000"
352 "##;
353
354 let theme = Theme::from_toml(toml).unwrap();
355
356 let style = theme.style_for("keyword.control");
358 assert_eq!(style.fg, Some(Color::Rgb(255, 0, 0)));
359 }
360
361 #[test]
362 fn test_simple_style_value() {
363 let toml = r##"
364 keyword = "#FF0000"
365 string = "green"
366 "##;
367
368 let theme = Theme::from_toml(toml).unwrap();
369
370 let keyword_style = theme.style_for("keyword");
371 assert_eq!(keyword_style.fg, Some(Color::Rgb(255, 0, 0)));
372
373 let string_style = theme.style_for("string");
374 assert_eq!(string_style.fg, Some(Color::Green));
375 }
376}