termflix 0.7.2

Terminal animation player with 60 procedurally generated animations, multiple render modes, and true color support
use crate::render::{ColorMode, RenderMode};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;

/// User configuration loaded from config file.
/// All fields are optional — CLI flags override config, config overrides defaults.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Default animation name
    pub animation: Option<String>,
    /// Default render mode
    pub render: Option<RenderModeConfig>,
    /// Default color mode
    pub color: Option<ColorModeConfig>,
    /// Target FPS (1-120)
    pub fps: Option<u32>,
    /// Particle/element scale factor (0.5-2.0)
    pub scale: Option<f64>,
    /// Hide status bar
    pub clean: Option<bool>,
    /// Auto-cycle interval in seconds (0 = disabled)
    pub cycle: Option<u32>,
    /// Color quantization step (0 = off, 4/8/16 = coarser colors for less output)
    pub color_quant: Option<u8>,
    /// Remove FPS cap and render as fast as possible
    pub unlimited_fps: Option<bool>,
    /// Path to a file to watch for external control params (ndjson)
    pub data_file: Option<String>,
    /// Custom keybindings (action -> key name)
    pub keybindings: Option<HashMap<String, String>>,
    /// Post-processing effects configuration
    pub postproc: Option<PostProcConfig>,
    /// Temporal brightness smoothing time constant in seconds (0 = off).
    /// Reduces flicker in fire/plasma/aurora; best on continuous-noise animations.
    pub smoothing: Option<f64>,
    /// Colorblind-safe remap palette: viridis | magma | inferno | plasma | okabe-ito
    pub palette: Option<String>,
    /// Daltonization correction: protanopia | deuteranopia | tritanopia
    /// (mutually exclusive with `palette`).
    pub colorblind: Option<String>,
    /// Ordered (Bayer 4x4) dithering for ANSI-256 mode (reduces banding).
    pub dither: Option<bool>,
}

/// Render mode names for config file (kebab-case friendly)
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RenderModeConfig {
    Braille,
    HalfBlock,
    Ascii,
}

impl From<RenderModeConfig> for RenderMode {
    fn from(c: RenderModeConfig) -> Self {
        match c {
            RenderModeConfig::Braille => RenderMode::Braille,
            RenderModeConfig::HalfBlock => RenderMode::HalfBlock,
            RenderModeConfig::Ascii => RenderMode::Ascii,
        }
    }
}

/// Color mode names for config file (kebab-case friendly)
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ColorModeConfig {
    Mono,
    Ansi16,
    Ansi256,
    TrueColor,
}

impl From<ColorModeConfig> for ColorMode {
    fn from(c: ColorModeConfig) -> Self {
        match c {
            ColorModeConfig::Mono => ColorMode::Mono,
            ColorModeConfig::Ansi16 => ColorMode::Ansi16,
            ColorModeConfig::Ansi256 => ColorMode::Ansi256,
            ColorModeConfig::TrueColor => ColorMode::TrueColor,
        }
    }
}

#[derive(Debug, Default, Clone, Copy, Deserialize)]
#[serde(default)]
pub struct PostProcConfig {
    pub bloom: Option<f64>,
    pub bloom_threshold: Option<f64>,
    pub vignette: Option<f64>,
    pub scanlines: Option<bool>,
}

/// Get the config file path: ~/.config/termflix/config.toml
pub fn config_path() -> Option<PathBuf> {
    dirs::config_dir().map(|d| d.join("termflix").join("config.toml"))
}

/// Load config from file. Returns default config if file doesn't exist.
pub fn load_config() -> Config {
    let Some(path) = config_path() else {
        return Config::default();
    };
    let Ok(contents) = std::fs::read_to_string(&path) else {
        return Config::default();
    };
    match toml::from_str(&contents) {
        Ok(config) => config,
        Err(e) => {
            eprintln!("Warning: failed to parse {}: {}", path.display(), e);
            Config::default()
        }
    }
}

/// Generate a default config file with all options commented out
pub fn default_config_string() -> String {
    r#"# termflix configuration
# Use --show-config to see the active config file path.
# CLI flags override these settings.

# Default animation (use --list to see all)
# animation = "fire"

# Default render mode: braille, half-block, ascii
# render = "half-block"

# Default color mode: mono, ansi16, ansi256, true-color
# color = "true-color"

# Target FPS (1-120)
# fps = 24

# Particle/element scale factor (0.5-2.0)
# scale = 1.0

# Hide status bar
# clean = false

# Auto-cycle interval in seconds (0 = disabled)
# cycle = 0

# Color quantization step (0 = off, 4/8/16 = coarser colors, less output)
# Useful for slow terminals or tmux
# color_quant = 0

# Remove FPS cap and render as fast as possible (overrides fps)
# unlimited_fps = false

# Watch a file for external control params (ndjson — one JSON object per line)
# data_file = "/tmp/termflix.json"

# Custom keybindings (key names: q, n, Right, Left, Esc, Space, Tab, etc.)
# [keybindings]
# next = "Right"
# prev = "Left"
# quit = "q"
# render = "r"
# color = "c"
# status = "h"

# Post-processing effects
# [postproc]
# bloom = 0.3               # Glow effect intensity (0.0-1.0)
# bloom_threshold = 0.6     # Brightness threshold to trigger bloom (0.0-1.0)
# vignette = 0.4            # Edge darkening (0.0-1.0)
# scanlines = false         # CRT scanline effect

# Temporal brightness smoothing time constant in seconds (0 = off).
# Reduces flicker in fire/plasma/aurora. Best on continuous-noise animations.
# smoothing = 0.08

# Colorblind-safe remap palette: viridis | magma | inferno | plasma | okabe-ito
# palette = "viridis"

# Daltonization correction: protanopia | deuteranopia | tritanopia
# (mutually exclusive with palette)
# colorblind = "deuteranopia"

# Ordered (Bayer 4x4) dithering for ANSI-256 mode (reduces banding on 256-color terminals)
# dither = true
"#
    .to_string()
}

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

    #[test]
    fn test_config_default_is_all_none() {
        let cfg = Config::default();
        assert!(cfg.animation.is_none());
        assert!(cfg.fps.is_none());
        assert!(cfg.scale.is_none());
    }

    #[test]
    fn test_config_parses_valid_toml() {
        let toml = r#"
            animation = "fire"
            fps = 30
            scale = 1.5
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.animation.as_deref(), Some("fire"));
        assert_eq!(cfg.fps, Some(30));
        assert_eq!(cfg.scale, Some(1.5));
    }

    #[test]
    fn test_config_returns_default_on_missing_file() {
        // load_config() must not panic when config file doesn't exist
        // It returns Config::default() in that case
        // We just verify the type is correct and no panic
        let cfg = Config::default();
        assert!(cfg.fps.is_none()); // All fields are None by default
    }

    #[test]
    fn config_parses_smoothing() {
        let toml = "smoothing = 0.08\n";
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.smoothing, Some(0.08));
    }

    #[test]
    fn config_parses_palette_colorblind_dither() {
        let toml = "palette = \"viridis\"\ncolorblind = \"deuteranopia\"\ndither = true\n";
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.palette.as_deref(), Some("viridis"));
        assert_eq!(cfg.colorblind.as_deref(), Some("deuteranopia"));
        assert_eq!(cfg.dither, Some(true));
    }

    #[test]
    fn test_config_parses_keybindings() {
        let toml = r#"
            [keybindings]
            next = "Right"
            quit = "Esc"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let kb = cfg.keybindings.unwrap();
        assert_eq!(kb.get("next").unwrap(), "Right");
        assert_eq!(kb.get("quit").unwrap(), "Esc");
    }
}