Skip to main content

iris_reader/
theme.rs

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}