use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[tatara(keyword = "defeffect")]
pub struct EffectSpec {
pub name: String,
pub kind: String,
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub intensity: f64,
#[serde(default)]
pub radius: f64,
#[serde(default)]
pub threshold: f64,
#[serde(default)]
pub color: String,
#[serde(default)]
pub shader: String,
}
pub const KNOWN_KINDS: &[&str] = &[
"cursor",
"screen",
"cursor-trail",
"underglow",
"custom",
];
pub const CANONICAL_EFFECTS: &[(&str, &str)] = &[
("cursor-glow", "cursor"),
("cursor-pulse", "cursor"),
("cursor-trail", "cursor-trail"),
("bloom", "screen"),
("scanlines", "screen"),
("film-grain", "screen"),
("crt-warp", "screen"),
("chromatic-aberration", "screen"),
("underglow", "underglow"),
];
#[must_use]
pub fn is_known_kind(name: &str) -> bool {
KNOWN_KINDS.iter().any(|k| *k == name)
}
#[must_use]
pub fn is_canonical_effect(name: &str) -> bool {
CANONICAL_EFFECTS.iter().any(|(n, _)| *n == name)
}
impl EffectSpec {
#[must_use]
pub fn is_malformed_custom(&self) -> bool {
self.kind == "custom" && self.shader.is_empty()
}
#[must_use]
pub fn intensity_clamped(&self) -> f64 {
self.intensity.clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_effect_table_covers_ghostty_set() {
for name in ["cursor-glow", "bloom", "scanlines", "film-grain"] {
assert!(
is_canonical_effect(name),
"canonical effect {name} missing from table",
);
}
assert!(!is_canonical_effect("laser-unicorn"));
}
#[test]
fn known_kinds_accept_all_variants() {
for k in ["cursor", "screen", "cursor-trail", "underglow", "custom"] {
assert!(is_known_kind(k));
}
assert!(!is_known_kind("quantum"));
}
#[test]
fn malformed_custom_classifier() {
let bad = EffectSpec {
name: "x".into(),
kind: "custom".into(),
..Default::default()
};
assert!(bad.is_malformed_custom());
let ok = EffectSpec {
name: "x".into(),
kind: "custom".into(),
shader: "/path/to.wgsl".into(),
..Default::default()
};
assert!(!ok.is_malformed_custom());
let builtin = EffectSpec {
name: "bloom".into(),
kind: "screen".into(),
..Default::default()
};
assert!(!builtin.is_malformed_custom());
}
#[test]
fn intensity_clamps_both_ends() {
let e = EffectSpec {
intensity: -0.5,
..Default::default()
};
assert_eq!(e.intensity_clamped(), 0.0);
let e = EffectSpec {
intensity: 2.7,
..Default::default()
};
assert_eq!(e.intensity_clamped(), 1.0);
let e = EffectSpec {
intensity: 0.42,
..Default::default()
};
assert_eq!(e.intensity_clamped(), 0.42);
}
}
impl Default for EffectSpec {
fn default() -> Self {
Self {
name: String::new(),
kind: String::new(),
enable: false,
intensity: 0.0,
radius: 0.0,
threshold: 0.0,
color: String::new(),
shader: String::new(),
}
}
}