Skip to main content

datui_lib/config/
themes.rs

1//! Named themes: the two built in, and every `*.toml` in the config directory's
2//! `themes/`, each a set of `theme.colors` slots named by its file. `theme.dark` and
3//! `theme.light` pick one per mode; `theme.colors` lies over whichever is in use.
4
5use std::path::{Path, PathBuf};
6
7use crate::config::{ColorConfig, ColorParser, ThemeMode};
8
9/// The config directory's folder of theme files.
10pub const FOLDER: &str = "themes";
11/// The built-in dark theme: Tokyo Night with one cyan accent.
12pub const NIGHT_MARKET: &str = "night-market";
13/// The built-in light theme: Tokyo Night's day variant.
14pub const DAY_MARKET: &str = "day-market";
15
16/// The keys a theme file holds besides its slots.
17const EXTENDS: &str = "extends";
18const DESCRIPTION: &str = "description";
19/// How long an `extends` chain may be: deeper is surely a mistake.
20const MAX_DEPTH: usize = 16;
21
22/// The built-in theme for a mode, by name.
23pub fn built_in_name(mode: ThemeMode) -> &'static str {
24    match mode {
25        ThemeMode::Light => DAY_MARKET,
26        _ => NIGHT_MARKET,
27    }
28}
29
30/// A theme file that parsed.
31#[derive(Debug, Clone, PartialEq)]
32pub struct ThemeFile {
33    /// Its file stem.
34    pub name: String,
35    pub path: PathBuf,
36    pub extends: Option<String>,
37    pub description: Option<String>,
38    /// The slots it sets, each checked to be a color.
39    pub colors: toml::Table,
40}
41
42/// A theme file left out for a mistake.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Broken {
45    pub name: String,
46    pub path: PathBuf,
47    pub message: String,
48}
49
50impl Broken {
51    /// `path: message`, for a warning.
52    pub fn full(&self) -> String {
53        format!("{}: {}", self.path.display(), self.message)
54    }
55}
56
57/// Every theme there is: the built-ins, and what `themes/` holds.
58#[derive(Debug, Clone, Default, PartialEq)]
59pub struct Library {
60    /// Sorted by name.
61    pub files: Vec<ThemeFile>,
62    pub broken: Vec<Broken>,
63}
64
65/// Where a theme comes from.
66#[derive(Debug, Clone, PartialEq)]
67pub enum Source<'a> {
68    BuiltIn(ThemeMode),
69    File(&'a ThemeFile),
70}
71
72impl Library {
73    /// The themes in `config_dir`'s `themes/`; only the built-ins without one. A file
74    /// with a mistake is left out and kept in `broken`, so one bad file never stops
75    /// datui from starting.
76    pub fn read(config_dir: Option<&Path>) -> Self {
77        let mut library = Self::default();
78        let Some(dir) = config_dir else {
79            return library;
80        };
81        let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir.join(FOLDER)) {
82            Ok(entries) => entries
83                .filter_map(|e| e.ok().map(|e| e.path()))
84                .filter(|p| {
85                    p.extension().is_some_and(|x| x == "toml")
86                        && std::fs::metadata(p).is_ok_and(|m| m.is_file())
87                })
88                .collect(),
89            Err(_) => Vec::new(),
90        };
91        paths.sort();
92        for path in paths {
93            let name = path
94                .file_stem()
95                .map(|s| s.to_string_lossy().into_owned())
96                .unwrap_or_default();
97            let read = std::fs::read_to_string(&path)
98                .map_err(|e| e.to_string())
99                .and_then(|text| parse(&name, &path, &text));
100            match read {
101                Ok(file) => library.files.push(file),
102                Err(message) => library.broken.push(Broken {
103                    name,
104                    path,
105                    message,
106                }),
107            }
108        }
109        library
110    }
111
112    /// The theme named `name`, if there is one.
113    pub fn find(&self, name: &str) -> Option<Source<'_>> {
114        match name {
115            NIGHT_MARKET => Some(Source::BuiltIn(ThemeMode::Dark)),
116            DAY_MARKET => Some(Source::BuiltIn(ThemeMode::Light)),
117            _ => self.files.iter().find(|f| f.name == name).map(Source::File),
118        }
119    }
120
121    /// Every theme's name, the built-ins first.
122    pub fn names(&self) -> Vec<&str> {
123        let mut names = vec![NIGHT_MARKET, DAY_MARKET];
124        names.extend(self.files.iter().map(|f| f.name.as_str()));
125        names
126    }
127
128    /// The colors of theme `name` when it is used in `mode`. Its unset slots come from
129    /// the theme it `extends`, or, when it extends none, from the built-in for `mode`.
130    pub fn resolve(&self, name: &str, mode: ThemeMode) -> Result<ColorConfig, String> {
131        let mut chain: Vec<&ThemeFile> = Vec::new();
132        let mut at = name;
133        let base = loop {
134            match self.find(at) {
135                Some(Source::BuiltIn(built)) => break ColorConfig::for_mode(built),
136                Some(Source::File(file)) => {
137                    if let Some(seen) = chain.iter().position(|f| f.name == file.name) {
138                        let names: Vec<&str> = chain[seen..]
139                            .iter()
140                            .map(|f| f.name.as_str())
141                            .chain([file.name.as_str()])
142                            .collect();
143                        return Err(format!("themes extend in a circle: {}", names.join(" > ")));
144                    }
145                    if chain.len() >= MAX_DEPTH {
146                        return Err(format!(
147                            "theme {name} extends more than {MAX_DEPTH} themes deep"
148                        ));
149                    }
150                    chain.push(file);
151                    match &file.extends {
152                        Some(next) => at = next,
153                        None => break ColorConfig::for_mode(mode),
154                    }
155                }
156                None => return Err(self.missing(at, chain.last().copied())),
157            }
158        };
159        let mut palette = slots(&base);
160        for file in chain.iter().rev() {
161            palette.extend(file.colors.clone());
162        }
163        toml::Value::Table(palette)
164            .try_into()
165            .map_err(|e: toml::de::Error| e.message().to_string())
166    }
167
168    /// Why `name`, asked for by `by` (or by the config), is not a theme.
169    fn missing(&self, name: &str, by: Option<&ThemeFile>) -> String {
170        let from = by.map_or_else(String::new, |f| {
171            format!(" ({} extends it)", f.path.display())
172        });
173        if let Some(broken) = self.broken.iter().find(|b| b.name == name) {
174            return format!(
175                "theme {name} was left out for a mistake{from}: {}",
176                broken.full()
177            );
178        }
179        format!(
180            "no theme is named {name}{from}. Themes: {}",
181            self.names().join(", ")
182        )
183    }
184}
185
186/// A slot table for `colors`.
187pub fn slots(colors: &ColorConfig) -> toml::Table {
188    match toml::Value::try_from(colors) {
189        Ok(toml::Value::Table(table)) => table,
190        _ => unreachable!("a struct serializes to a table"),
191    }
192}
193
194/// A theme file's text, checked: its keys are slots, `extends` or `description`,
195/// and every slot is a color `theme.colors` would take.
196pub fn parse(name: &str, path: &Path, text: &str) -> Result<ThemeFile, String> {
197    if name == NIGHT_MARKET || name == DAY_MARKET {
198        return Err(format!(
199            "{name} is a built-in theme's name; rename the file and set extends = \"{name}\""
200        ));
201    }
202    let table: toml::Table = text.parse().map_err(|e: toml::de::Error| match e.span() {
203        Some(span) => format!(
204            "line {}: {}",
205            text[..span.start].matches('\n').count() + 1,
206            e.message()
207        ),
208        None => e.message().to_string(),
209    })?;
210    let known = slots(&ColorConfig::dark());
211    let parser = ColorParser::new();
212    let mut file = ThemeFile {
213        name: name.to_string(),
214        path: path.to_path_buf(),
215        extends: None,
216        description: None,
217        colors: toml::Table::new(),
218    };
219    for (key, value) in table {
220        let text = |value: toml::Value| match value {
221            toml::Value::String(s) => Ok(s),
222            other => Err(format!("{key} must be a string, got {other}")),
223        };
224        match key.as_str() {
225            EXTENDS => file.extends = Some(text(value)?),
226            DESCRIPTION => file.description = Some(text(value)?),
227            _ if known.contains_key(&key) => {
228                let color = text(value)?;
229                // "default" is no stripe, not a color.
230                if !(key == "table_alternate_row" && color == "default") {
231                    parser.parse(&color).map_err(|e| format!("{key}: {e}"))?;
232                }
233                file.colors.insert(key, toml::Value::String(color));
234            }
235            "theme" | "colors" => {
236                return Err(format!(
237                    "a theme file holds the slots at its top level; drop the [{key}...] header"
238                ));
239            }
240            _ => {
241                let near: Vec<&str> =
242                    datui_cli::settings::suggestions(&format!("theme.colors.{key}"))
243                        .into_iter()
244                        .filter_map(|k| k.strip_prefix("theme.colors."))
245                        .collect();
246                let mut message = format!("{key} is not a color slot");
247                if !near.is_empty() {
248                    message.push_str(&format!("; did you mean {}?", near.join(" or ")));
249                }
250                return Err(message);
251            }
252        }
253    }
254    if file.extends.as_deref() == Some(name) {
255        return Err(format!("{name} extends itself"));
256    }
257    Ok(file)
258}
259
260/// The built-in themes' descriptions.
261fn built_in_description(mode: ThemeMode) -> &'static str {
262    match mode {
263        ThemeMode::Light => "Tokyo Night's day variant, for a light terminal",
264        _ => "Tokyo Night with one cyan accent, for a dark terminal",
265    }
266}
267
268/// What `datui theme ACTION` prints, and its exit code. `config` is the
269/// configuration in effect, or why it could not be read.
270pub fn command(
271    action: &datui_cli::ThemeAction,
272    config: color_eyre::Result<crate::config::AppConfig>,
273) -> (String, i32) {
274    use datui_cli::ThemeAction;
275    use datui_cli::exit::{FAILURE, SUCCESS};
276    let config = match config {
277        Ok(config) => config,
278        Err(e) => return (format!("{e}\n"), FAILURE),
279    };
280    let theme = &config.theme;
281    let library = &theme.library;
282    match action {
283        ThemeAction::List => {
284            let set_for = |name: &str| {
285                let modes: Vec<&str> = [
286                    (theme.dark_theme.as_str(), "dark"),
287                    (theme.light_theme.as_str(), "light"),
288                ]
289                .into_iter()
290                .filter(|(used, _)| *used == name)
291                .map(|(_, mode)| mode)
292                .collect();
293                if modes.is_empty() {
294                    "-".to_string()
295                } else {
296                    modes.join(", ")
297                }
298            };
299            let mut rows = vec![vec![
300                "NAME".to_string(),
301                "SET FOR".to_string(),
302                "FROM".to_string(),
303                "DESCRIPTION".to_string(),
304            ]];
305            for mode in [ThemeMode::Dark, ThemeMode::Light] {
306                let name = built_in_name(mode);
307                rows.push(vec![
308                    name.to_string(),
309                    set_for(name),
310                    "built in".to_string(),
311                    built_in_description(mode).to_string(),
312                ]);
313            }
314            for file in &library.files {
315                rows.push(vec![
316                    file.name.clone(),
317                    set_for(&file.name),
318                    file.path.display().to_string(),
319                    file.description.clone().unwrap_or_default(),
320                ]);
321            }
322            for broken in &library.broken {
323                rows.push(vec![
324                    broken.name.clone(),
325                    "-".to_string(),
326                    broken.path.display().to_string(),
327                    format!(
328                        "{} not read: {}",
329                        crate::glyphs::get().warning,
330                        broken.message
331                    ),
332                ]);
333            }
334            (crate::home::catalog::table(&rows), SUCCESS)
335        }
336        ThemeAction::Show { name } => {
337            let (mode, description) = match library.find(name) {
338                // A copy is a new theme: the built-in's description is not its own.
339                Some(Source::BuiltIn(mode)) => (mode, None),
340                // A theme that extends none fills its unset slots from the built-in for
341                // the mode it is used in: show it as the light one only where it is that.
342                Some(Source::File(file)) => (
343                    if theme.light_theme == *name && theme.dark_theme != *name {
344                        ThemeMode::Light
345                    } else {
346                        ThemeMode::Dark
347                    },
348                    file.description.clone(),
349                ),
350                None => return (format!("{}\n", library.missing(name, None)), FAILURE),
351            };
352            match library.resolve(name, mode) {
353                Ok(colors) => (show(name, description.as_deref(), &colors), SUCCESS),
354                Err(e) => (format!("{e}\n"), FAILURE),
355            }
356        }
357    }
358}
359
360/// A theme file with every slot, each under what it colors: a starting point to save
361/// into `themes/` and edit.
362pub fn show(name: &str, description: Option<&str>, colors: &ColorConfig) -> String {
363    let palette = slots(colors);
364    let mut out = format!(
365        "# Every slot of the {name} theme. Saved as themes/NAME.toml in the config\n\
366         # directory, it is a theme named NAME to edit. A file with extends = \"{name}\"\n\
367         # and only the slots it changes works too.\n"
368    );
369    if let Some(description) = description {
370        out.push_str(&format!(
371            "description = {}\n",
372            toml::Value::String(description.to_string())
373        ));
374    }
375    for setting in datui_cli::settings::in_section("theme.colors") {
376        let slot = setting.name();
377        let Some(value) = palette.get(slot) else {
378            continue;
379        };
380        out.push_str(&format!("\n# {}\n{slot} = {value}\n", setting.doc));
381    }
382    out
383}
384
385#[cfg(test)]
386mod tests;