shdo 0.1.0

A kanban board for the terminal: tasks are Markdown files in a git repository
//! Themes: semantic color tokens, built in via `include_str!` or as custom
//! TOML files in `~/.config/shdo/themes/`. Missing tokens come from the default theme.

use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;

use anyhow::{Context, Result, anyhow};
use ratatui::style::Color;
use rust_i18n::t;

pub const DEFAULT_THEME: &str = "tokyonight";

const BUILTIN: &[(&str, &str)] = &[
    ("tokyonight", include_str!("../themes/tokyonight.toml")),
    ("nord", include_str!("../themes/nord.toml")),
    ("gruvbox-dark", include_str!("../themes/gruvbox-dark.toml")),
    (
        "catppuccin-mocha",
        include_str!("../themes/catppuccin-mocha.toml"),
    ),
    (
        "gruvbox-light",
        include_str!("../themes/gruvbox-light.toml"),
    ),
    (
        "catppuccin-latte",
        include_str!("../themes/catppuccin-latte.toml"),
    ),
    ("terminal", include_str!("../themes/terminal.toml")),
];

#[derive(Debug, Clone)]
pub struct Palette {
    pub bg: Color,
    pub fg: Color,
    pub dim: Color,
    pub border: Color,
    pub border_active: Color,
    pub title: Color,
    pub accent: Color,
    pub selection: Color,
    pub prio_high: Color,
    pub prio_low: Color,
    pub overdue: Color,
    pub today: Color,
    pub done: Color,
    pub key: Color,
    pub warn: Color,
    pub weekend: Color,
    pub holiday: Color,
}

#[derive(Debug, Clone)]
pub struct Theme {
    pub id: String,
    pub name: String,
    pub c: Palette,
}

#[derive(serde::Deserialize)]
struct ThemeFile {
    name: Option<String>,
    #[serde(default)]
    colors: BTreeMap<String, String>,
}

fn parse_color(s: &str) -> Result<Color> {
    Color::from_str(s).map_err(|_| anyhow!(t!("theme.bad_color", color = s)))
}

impl Palette {
    fn apply(&mut self, colors: &BTreeMap<String, String>) -> Result<()> {
        for (key, val) in colors {
            let slot = match key.as_str() {
                "bg" => &mut self.bg,
                "fg" => &mut self.fg,
                "dim" => &mut self.dim,
                "border" => &mut self.border,
                "border_active" => &mut self.border_active,
                "title" => &mut self.title,
                "accent" => &mut self.accent,
                "selection" => &mut self.selection,
                "prio_high" => &mut self.prio_high,
                "prio_low" => &mut self.prio_low,
                "overdue" => &mut self.overdue,
                "today" => &mut self.today,
                "done" => &mut self.done,
                "key" => &mut self.key,
                "warn" => &mut self.warn,
                "weekend" => &mut self.weekend,
                "holiday" => &mut self.holiday,
                other => return Err(anyhow!(t!("theme.unknown_token", token = other))),
            };
            *slot =
                parse_color(val).with_context(|| t!("theme.token", token = key).into_owned())?;
        }
        Ok(())
    }
}

fn fallback() -> Palette {
    let mut p = Palette {
        bg: Color::Reset,
        fg: Color::Reset,
        dim: Color::DarkGray,
        border: Color::DarkGray,
        border_active: Color::Green,
        title: Color::Blue,
        accent: Color::Cyan,
        selection: Color::Black,
        prio_high: Color::Red,
        prio_low: Color::DarkGray,
        overdue: Color::Red,
        today: Color::Yellow,
        done: Color::Green,
        key: Color::Magenta,
        warn: Color::Yellow,
        weekend: Color::DarkGray,
        holiday: Color::LightRed,
    };
    let raw = BUILTIN
        .iter()
        .find(|(id, _)| *id == DEFAULT_THEME)
        .map(|(_, raw)| *raw)
        .expect("Default-Theme ist eingebaut");
    let base: ThemeFile = toml::from_str(raw).expect("default theme is valid");
    p.apply(&base.colors).expect("default theme is valid");
    p
}

fn parse_theme(id: &str, raw: &str) -> Result<Theme> {
    let file: ThemeFile = toml::from_str(raw)?;
    let mut c = fallback();
    c.apply(&file.colors)?;
    Ok(Theme {
        id: id.into(),
        name: file.name.unwrap_or_else(|| id.into()),
        c,
    })
}

/// All themes sorted by name: built-in and custom (a custom theme with the same ID wins).
/// Broken custom themes are returned as warnings.
pub fn load_all(custom_dir: &Path) -> (Vec<Theme>, Vec<String>) {
    let mut themes: Vec<Theme> = BUILTIN
        .iter()
        .map(|(id, raw)| parse_theme(id, raw).expect("built-in themes are valid"))
        .collect();
    let mut warnings = Vec::new();
    let mut files: Vec<_> = std::fs::read_dir(custom_dir)
        .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
        .unwrap_or_default();
    files.retain(|p: &std::path::PathBuf| p.extension().is_some_and(|x| x == "toml"));
    files.sort();
    for path in files {
        let id = path
            .file_stem()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        let parsed = std::fs::read_to_string(&path)
            .map_err(anyhow::Error::from)
            .and_then(|raw| parse_theme(&id, &raw));
        match parsed {
            Ok(t) => match themes.iter_mut().find(|x| x.id == id) {
                Some(slot) => *slot = t,
                None => themes.push(t),
            },
            Err(e) => warnings.push(format!(
                "{}: {e:#}",
                t!("theme.file", path = path.display())
            )),
        }
    }
    themes.sort_by_key(|t| t.name.to_lowercase());
    (themes, warnings)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builtins_parse() {
        let tmp = tempfile::tempdir().unwrap();
        let (themes, warnings) = load_all(tmp.path());
        assert!(warnings.is_empty());
        let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
        let mut sorted = names.clone();
        sorted.sort_by_key(|n| n.to_lowercase());
        assert_eq!(names, sorted);
        let by_id = |id: &str| themes.iter().find(|t| t.id == id).unwrap();
        assert_eq!(by_id(DEFAULT_THEME).c.bg, Color::Rgb(0x1a, 0x1b, 0x26));
        assert_eq!(by_id("nord").c.bg, Color::Rgb(0x2e, 0x34, 0x40));
        assert_eq!(by_id("terminal").c.bg, Color::Reset);
        // Every built-in theme sets all tokens itself (nothing silently inherits the default).
        for (id, raw) in BUILTIN {
            let file: ThemeFile = toml::from_str(raw).unwrap();
            assert_eq!(file.colors.len(), 17, "{id}");
        }
    }

    #[test]
    fn custom_themes_merge_and_warn() {
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("mein.toml"), "[colors]\nbg = \"#000000\"\n").unwrap();
        std::fs::write(tmp.path().join("kaputt.toml"), "[colors]\nbg = \"lila\"\n").unwrap();
        std::fs::write(
            tmp.path().join("tippfehler.toml"),
            "[colors]\nbgg = \"red\"\n",
        )
        .unwrap();
        let (themes, warnings) = load_all(tmp.path());
        let mein = themes.iter().find(|t| t.id == "mein").unwrap();
        assert_eq!(mein.name, "mein");
        assert_eq!(mein.c.bg, Color::Rgb(0, 0, 0));
        let default = themes.iter().find(|t| t.id == DEFAULT_THEME).unwrap();
        assert_eq!(mein.c.fg, default.c.fg);
        assert_eq!(warnings.len(), 2);
    }
}