use crate::render::{ColorMode, RenderMode};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub animation: Option<String>,
pub render: Option<RenderModeConfig>,
pub color: Option<ColorModeConfig>,
pub fps: Option<u32>,
pub scale: Option<f64>,
pub clean: Option<bool>,
pub cycle: Option<u32>,
pub color_quant: Option<u8>,
pub unlimited_fps: Option<bool>,
pub data_file: Option<String>,
pub keybindings: Option<HashMap<String, String>>,
pub postproc: Option<PostProcConfig>,
pub smoothing: Option<f64>,
pub palette: Option<String>,
pub colorblind: Option<String>,
pub dither: Option<bool>,
}
#[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,
}
}
}
#[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>,
}
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("termflix").join("config.toml"))
}
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()
}
}
}
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() {
let cfg = Config::default();
assert!(cfg.fps.is_none()); }
#[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");
}
}