1use std::{
2 collections::HashMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use ratatui::style::{Color, Modifier, Style};
9use serde::Deserialize;
10
11use crate::options::IconMode;
12
13const EMBER_TOML: &str = include_str!("../themes/ember.toml");
14
15#[derive(Debug, Clone, Deserialize, Default)]
16pub struct StyleSpec {
17 pub foreground: Option<String>,
18 pub background: Option<String>,
19 #[serde(default)]
20 pub bold: bool,
21 #[serde(default)]
22 pub italic: bool,
23 #[serde(default)]
24 pub underline: bool,
25 #[serde(default)]
26 pub strikethrough: bool,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30struct HeadingStyleSpec {
31 #[serde(flatten)]
32 style: StyleSpec,
33 separator: Option<String>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37struct HeadingSection {
38 h1: HeadingStyleSpec,
39 h2: HeadingStyleSpec,
40 h3: HeadingStyleSpec,
41 h4: HeadingStyleSpec,
42 h5: HeadingStyleSpec,
43 h6: HeadingStyleSpec,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47struct ListSpec {
48 #[serde(flatten)]
49 style: StyleSpec,
50 marker_foreground: Option<String>,
51 bullet: String,
52 indent: usize,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56struct QuoteSpec {
57 #[serde(flatten)]
58 style: StyleSpec,
59 border_foreground: Option<String>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63struct CodeSpec {
64 #[serde(flatten)]
65 style: StyleSpec,
66 border_foreground: Option<String>,
67 syntax_theme: String,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71struct TableSpec {
72 #[serde(flatten)]
73 style: StyleSpec,
74 border_foreground: Option<String>,
75 header_foreground: Option<String>,
76 #[serde(default)]
77 header_bold: bool,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81struct TaskSection {
82 checked: StyleSpec,
83 unchecked: StyleSpec,
84}
85
86#[derive(Debug, Clone, Deserialize)]
87struct ImageSpec {
88 #[serde(flatten)]
89 style: StyleSpec,
90 #[serde(default = "default_image_height")]
91 max_height: u16,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95struct HtmlSection {
96 mark: StyleSpec,
97 kbd: StyleSpec,
98 underline: StyleSpec,
99 subtle: StyleSpec,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103struct SymbolSetSpec {
104 task_checked: String,
105 task_unchecked: String,
106 alert_note: String,
107 alert_tip: String,
108 alert_important: String,
109 alert_warning: String,
110 alert_caution: String,
111 image: String,
112}
113
114#[derive(Debug, Clone, Deserialize)]
115struct SymbolSection {
116 nerd_font: SymbolSetSpec,
117 unicode: SymbolSetSpec,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121struct AlertSection {
122 note: StyleSpec,
123 tip: StyleSpec,
124 important: StyleSpec,
125 warning: StyleSpec,
126 caution: StyleSpec,
127}
128
129#[derive(Debug, Clone, Deserialize)]
130struct SearchSection {
131 #[serde(rename = "match")]
132 match_style: StyleSpec,
133 current: StyleSpec,
134 prompt: StyleSpec,
135}
136
137#[derive(Debug, Clone, Deserialize)]
138struct UiSection {
139 status: StyleSpec,
140 status_accent: StyleSpec,
141 help: StyleSpec,
142 help_border: StyleSpec,
143 help_heading: StyleSpec,
144 help_key: StyleSpec,
145}
146
147#[derive(Debug, Clone, Deserialize)]
148struct TextSection {
149 strong: StyleSpec,
150 emphasis: StyleSpec,
151 strikethrough: StyleSpec,
152}
153
154#[derive(Debug, Clone, Deserialize)]
155struct ThemeFile {
156 name: String,
157 palette: HashMap<String, String>,
158 document: StyleSpec,
159 heading: HeadingSection,
160 text: TextSection,
161 inline_code: StyleSpec,
162 link: StyleSpec,
163 list: ListSpec,
164 quote: QuoteSpec,
165 code: CodeSpec,
166 table: TableSpec,
167 horizontal_rule: StyleSpec,
168 task: TaskSection,
169 image: ImageSpec,
170 html: HtmlSection,
171 symbols: SymbolSection,
172 alert: AlertSection,
173 search: SearchSection,
174 ui: UiSection,
175}
176
177#[derive(Debug, Clone)]
178pub struct HeadingTheme {
179 pub styles: [Style; 6],
180 pub separators: [Option<String>; 6],
181}
182
183#[derive(Debug, Clone)]
184pub struct ListTheme {
185 pub style: Style,
186 pub marker_style: Style,
187 pub bullet: String,
188 pub indent: usize,
189}
190
191#[derive(Debug, Clone)]
192pub struct SymbolTheme {
193 pub task_checked: String,
194 pub task_unchecked: String,
195 pub alert_note: String,
196 pub alert_tip: String,
197 pub alert_important: String,
198 pub alert_warning: String,
199 pub alert_caution: String,
200 pub image: String,
201}
202
203#[derive(Debug, Clone)]
204pub struct Theme {
205 pub name: String,
206 pub document: Style,
207 pub heading: HeadingTheme,
208 pub strong: Style,
209 pub emphasis: Style,
210 pub strikethrough: Style,
211 pub inline_code: Style,
212 pub link: Style,
213 pub list: ListTheme,
214 pub quote: Style,
215 pub quote_border: Style,
216 pub code: Style,
217 pub code_border: Style,
218 pub syntax_theme: String,
219 pub table: Style,
220 pub table_border: Style,
221 pub table_header: Style,
222 pub horizontal_rule: Style,
223 pub task_checked: Style,
224 pub task_unchecked: Style,
225 pub image: Style,
226 pub image_max_height: u16,
227 pub html_mark: Style,
228 pub html_kbd: Style,
229 pub html_underline: Style,
230 pub html_subtle: Style,
231 pub symbols: SymbolTheme,
232 pub alert_note: Style,
233 pub alert_tip: Style,
234 pub alert_important: Style,
235 pub alert_warning: Style,
236 pub alert_caution: Style,
237 pub search_match: Style,
238 pub search_current: Style,
239 pub search_prompt: Style,
240 pub status: Style,
241 pub status_accent: Style,
242 pub help: Style,
243 pub help_border: Style,
244 pub help_heading: Style,
245 pub help_key: Style,
246}
247
248impl Theme {
249 pub fn ember(icon_mode: IconMode) -> Result<Self> {
250 Self::from_toml(EMBER_TOML, "built-in Ember theme", icon_mode)
251 }
252
253 pub fn load(name_or_path: &str, icon_mode: IconMode) -> Result<Self> {
254 if name_or_path.eq_ignore_ascii_case("ember") {
255 return Self::ember(icon_mode);
256 }
257
258 let direct = Path::new(name_or_path);
259 if direct.is_file() {
260 let source = fs::read_to_string(direct)
261 .with_context(|| format!("failed to read theme '{}'", direct.display()))?;
262 return Self::from_toml(&source, &direct.display().to_string(), icon_mode);
263 }
264
265 let path = themes_dir()
266 .map(|dir| dir.join(format!("{name_or_path}.toml")))
267 .ok_or_else(|| anyhow::anyhow!("could not determine the user config directory"))?;
268
269 if !path.is_file() {
270 bail!(
271 "unknown theme '{name_or_path}' (expected '{}' or a theme file path)",
272 path.display()
273 );
274 }
275
276 let source = fs::read_to_string(&path)
277 .with_context(|| format!("failed to read theme '{}'", path.display()))?;
278 Self::from_toml(&source, &path.display().to_string(), icon_mode)
279 }
280
281 pub fn list_available() -> Vec<String> {
282 let mut themes = vec!["ember (built-in)".to_string()];
283 if let Some(dir) = themes_dir()
284 && let Ok(entries) = fs::read_dir(dir)
285 {
286 for entry in entries.flatten() {
287 let path = entry.path();
288 if path.extension().and_then(|v| v.to_str()) == Some("toml")
289 && let Some(name) = path.file_stem().and_then(|v| v.to_str())
290 && !name.eq_ignore_ascii_case("ember")
291 {
292 themes.push(name.to_string());
293 }
294 }
295 }
296 themes.sort();
297 themes
298 }
299
300 fn from_toml(source: &str, label: &str, icon_mode: IconMode) -> Result<Self> {
301 let file: ThemeFile = toml::from_str(source)
302 .with_context(|| format!("invalid theme configuration in {label}"))?;
303 let palette = &file.palette;
304
305 let heading_specs = [
306 &file.heading.h1,
307 &file.heading.h2,
308 &file.heading.h3,
309 &file.heading.h4,
310 &file.heading.h5,
311 &file.heading.h6,
312 ];
313 let styles_vec = heading_specs
314 .iter()
315 .map(|spec| resolve_style(&spec.style, palette))
316 .collect::<Result<Vec<_>>>()?;
317 let styles: [Style; 6] = styles_vec
318 .try_into()
319 .map_err(|_| anyhow::anyhow!("theme must define six heading styles"))?;
320 let separators = heading_specs.map(|spec| spec.separator.clone());
321
322 let list_style = resolve_style(&file.list.style, palette)?;
323 let marker_style =
324 style_with_fg(list_style, file.list.marker_foreground.as_deref(), palette)?;
325 let quote_style = resolve_style(&file.quote.style, palette)?;
326 let quote_border = style_with_fg(
327 Style::default(),
328 file.quote.border_foreground.as_deref(),
329 palette,
330 )?;
331 let code_style = resolve_style(&file.code.style, palette)?;
332 let code_border = style_with_fg(
333 Style::default(),
334 file.code.border_foreground.as_deref(),
335 palette,
336 )?;
337 let table_style = resolve_style(&file.table.style, palette)?;
338 let mut table_header = style_with_fg(
339 table_style,
340 file.table.header_foreground.as_deref(),
341 palette,
342 )?;
343 if file.table.header_bold {
344 table_header = table_header.add_modifier(Modifier::BOLD);
345 }
346
347 let symbols = match icon_mode {
348 IconMode::NerdFont => &file.symbols.nerd_font,
349 IconMode::Unicode => &file.symbols.unicode,
350 };
351
352 Ok(Self {
353 name: file.name,
354 document: resolve_style(&file.document, palette)?,
355 heading: HeadingTheme { styles, separators },
356 strong: resolve_style(&file.text.strong, palette)?,
357 emphasis: resolve_style(&file.text.emphasis, palette)?,
358 strikethrough: resolve_style(&file.text.strikethrough, palette)?,
359 inline_code: resolve_style(&file.inline_code, palette)?,
360 link: resolve_style(&file.link, palette)?,
361 list: ListTheme {
362 style: list_style,
363 marker_style,
364 bullet: file.list.bullet,
365 indent: file.list.indent.max(1),
366 },
367 quote: quote_style,
368 quote_border,
369 code: code_style,
370 code_border,
371 syntax_theme: file.code.syntax_theme,
372 table: table_style,
373 table_border: style_with_fg(
374 Style::default(),
375 file.table.border_foreground.as_deref(),
376 palette,
377 )?,
378 table_header,
379 horizontal_rule: resolve_style(&file.horizontal_rule, palette)?,
380 task_checked: resolve_style(&file.task.checked, palette)?,
381 task_unchecked: resolve_style(&file.task.unchecked, palette)?,
382 image: resolve_style(&file.image.style, palette)?,
383 image_max_height: file.image.max_height.max(1),
384 html_mark: resolve_style(&file.html.mark, palette)?,
385 html_kbd: resolve_style(&file.html.kbd, palette)?,
386 html_underline: resolve_style(&file.html.underline, palette)?,
387 html_subtle: resolve_style(&file.html.subtle, palette)?,
388 symbols: SymbolTheme {
389 task_checked: symbols.task_checked.clone(),
390 task_unchecked: symbols.task_unchecked.clone(),
391 alert_note: symbols.alert_note.clone(),
392 alert_tip: symbols.alert_tip.clone(),
393 alert_important: symbols.alert_important.clone(),
394 alert_warning: symbols.alert_warning.clone(),
395 alert_caution: symbols.alert_caution.clone(),
396 image: symbols.image.clone(),
397 },
398 alert_note: resolve_style(&file.alert.note, palette)?,
399 alert_tip: resolve_style(&file.alert.tip, palette)?,
400 alert_important: resolve_style(&file.alert.important, palette)?,
401 alert_warning: resolve_style(&file.alert.warning, palette)?,
402 alert_caution: resolve_style(&file.alert.caution, palette)?,
403 search_match: resolve_style(&file.search.match_style, palette)?,
404 search_current: resolve_style(&file.search.current, palette)?,
405 search_prompt: resolve_style(&file.search.prompt, palette)?,
406 status: resolve_style(&file.ui.status, palette)?,
407 status_accent: resolve_style(&file.ui.status_accent, palette)?,
408 help: resolve_style(&file.ui.help, palette)?,
409 help_border: resolve_style(&file.ui.help_border, palette)?,
410 help_heading: resolve_style(&file.ui.help_heading, palette)?,
411 help_key: resolve_style(&file.ui.help_key, palette)?,
412 })
413 }
414
415 pub fn heading_style(&self, level: u8) -> Style {
416 self.heading.styles[level.clamp(1, 6) as usize - 1]
417 }
418
419 pub fn heading_separator(&self, level: u8) -> Option<&str> {
420 self.heading.separators[level.clamp(1, 6) as usize - 1].as_deref()
421 }
422}
423
424fn default_image_height() -> u16 {
425 14
426}
427
428pub fn themes_dir() -> Option<PathBuf> {
429 dirs::config_dir().map(|p| p.join("iris").join("themes"))
430}
431
432fn resolve_style(spec: &StyleSpec, palette: &HashMap<String, String>) -> Result<Style> {
433 let mut style = Style::default();
434 if let Some(value) = &spec.foreground {
435 style = style.fg(resolve_color(value, palette)?);
436 }
437 if let Some(value) = &spec.background {
438 style = style.bg(resolve_color(value, palette)?);
439 }
440
441 let mut modifiers = Modifier::empty();
442 if spec.bold {
443 modifiers |= Modifier::BOLD;
444 }
445 if spec.italic {
446 modifiers |= Modifier::ITALIC;
447 }
448 if spec.underline {
449 modifiers |= Modifier::UNDERLINED;
450 }
451 if spec.strikethrough {
452 modifiers |= Modifier::CROSSED_OUT;
453 }
454 Ok(style.add_modifier(modifiers))
455}
456
457fn style_with_fg(
458 mut style: Style,
459 value: Option<&str>,
460 palette: &HashMap<String, String>,
461) -> Result<Style> {
462 if let Some(value) = value {
463 style = style.fg(resolve_color(value, palette)?);
464 }
465 Ok(style)
466}
467
468fn resolve_color(value: &str, palette: &HashMap<String, String>) -> Result<Color> {
469 let resolved = palette.get(value).map(String::as_str).unwrap_or(value);
470 parse_color(resolved).with_context(|| format!("unknown color '{value}'"))
471}
472
473fn parse_color(value: &str) -> Result<Color> {
474 if let Some(hex) = value.strip_prefix('#')
475 && hex.len() == 6
476 {
477 let r = u8::from_str_radix(&hex[0..2], 16)?;
478 let g = u8::from_str_radix(&hex[2..4], 16)?;
479 let b = u8::from_str_radix(&hex[4..6], 16)?;
480 return Ok(Color::Rgb(r, g, b));
481 }
482
483 let color = match value.to_ascii_lowercase().as_str() {
484 "black" => Color::Black,
485 "red" => Color::Red,
486 "green" => Color::Green,
487 "yellow" => Color::Yellow,
488 "blue" => Color::Blue,
489 "magenta" | "purple" => Color::Magenta,
490 "cyan" => Color::Cyan,
491 "gray" | "grey" => Color::Gray,
492 "darkgray" | "darkgrey" => Color::DarkGray,
493 "white" => Color::White,
494 "default" | "reset" => Color::Reset,
495 other => bail!("invalid color '{other}'"),
496 };
497 Ok(color)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn built_in_ember_loads() {
506 let theme = Theme::ember(IconMode::NerdFont).unwrap();
507 assert_eq!(theme.name, "Ember");
508 assert_eq!(theme.heading.styles.len(), 6);
509 assert_eq!(theme.symbols.task_checked, "");
510 }
511
512 #[test]
513 fn built_in_ember_has_unicode_fallback_symbols() {
514 let theme = Theme::ember(IconMode::Unicode).unwrap();
515 assert_eq!(theme.symbols.task_checked, "☑");
516 assert_eq!(theme.symbols.alert_warning, "⚠");
517 }
518}