void-focus 0.3.0-alpha.5

A feature-rich terminal focus timer with task tracking
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use super::builtin::BUILTINS;
use super::file::ThemeFile;

const EMBEDDED: &[(&str, &str, &str)] = &[
    (
        "catppuccin-mocha",
        "Catppuccin Mocha",
        include_str!("../../themes/catppuccin-mocha.toml"),
    ),
    (
        "catppuccin-latte",
        "Catppuccin Latte",
        include_str!("../../themes/catppuccin-latte.toml"),
    ),
];

#[derive(Debug, Clone)]
pub struct ThemeEntry {
    pub id: String,
    pub label: String,
    pub source: ThemeSource,
}

#[derive(Debug, Clone)]
pub enum ThemeSource {
    Builtin,
    Embedded(&'static str),
    File(PathBuf),
}

#[derive(Debug, Clone, Default)]
pub struct ThemeCatalog {
    entries: Vec<ThemeEntry>,
}

impl ThemeCatalog {
    pub fn load() -> Self {
        let mut catalog = Self::default();
        for (id, label) in BUILTINS {
            catalog.entries.push(ThemeEntry {
                id: (*id).to_string(),
                label: (*label).to_string(),
                source: ThemeSource::Builtin,
            });
        }
        for (id, label, toml) in EMBEDDED {
            catalog.entries.push(ThemeEntry {
                id: (*id).to_string(),
                label: (*label).to_string(),
                source: ThemeSource::Embedded(toml),
            });
        }
        if let Ok(dir) = themes_dir() {
            catalog.scan_dir(&dir);
        }
        if let Ok(extra) = std::env::var("VOID_THEMES_DIR") {
            catalog.scan_dir(Path::new(&extra));
        }
        catalog
    }

    pub fn entries(&self) -> &[ThemeEntry] {
        &self.entries
    }

    pub fn label(&self, id: &str) -> String {
        self.entries
            .iter()
            .find(|entry| entry.id == id)
            .map(|entry| entry.label.clone())
            .unwrap_or_else(|| id.to_string())
    }

    pub fn next_id(&self, current: &str) -> String {
        if self.entries.is_empty() {
            return current.to_string();
        }
        let idx = self
            .entries
            .iter()
            .position(|entry| entry.id == current)
            .unwrap_or(0);
        self.entries[(idx + 1) % self.entries.len()].id.clone()
    }

    pub fn resolve_entry(&self, id: &str) -> Result<&ThemeEntry> {
        self.entries
            .iter()
            .find(|entry| entry.id == id)
            .with_context(|| format!("unknown theme `{id}`"))
    }

    fn scan_dir(&mut self, dir: &Path) {
        let Ok(read) = fs::read_dir(dir) else {
            return;
        };
        let mut found = Vec::new();
        for entry in read.flatten() {
            let path = entry.path();
            if path.extension().is_none_or(|ext| ext != "toml") {
                continue;
            }
            let Ok(file) = ThemeFile::from_path(&path) else {
                continue;
            };
            let id = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("")
                .to_string();
            if id.is_empty() || self.entries.iter().any(|e| e.id == id) {
                continue;
            }
            found.push(ThemeEntry {
                id,
                label: file.name,
                source: ThemeSource::File(path),
            });
        }
        found.sort_by(|a, b| a.label.cmp(&b.label));
        self.entries.extend(found);
    }
}

pub fn themes_dir() -> Result<PathBuf> {
    dirs::config_dir()
        .map(|dir| dir.join("void").join("themes"))
        .context("resolve config directory")
}

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

    #[test]
    fn catalog_includes_catppuccin_embedded() {
        let catalog = ThemeCatalog::load();
        assert!(catalog.entries.iter().any(|e| e.id == "catppuccin-mocha"));
    }

    #[test]
    fn cycles_through_entries() {
        let catalog = ThemeCatalog::load();
        let first = catalog.entries[0].id.clone();
        let second = catalog.next_id(&first);
        assert_ne!(first, second);
        let wrap = catalog.next_id(catalog.entries.last().unwrap().id.as_str());
        assert_eq!(wrap, first);
    }
}