use egui::{Pos2, Rect, pos2};
use crate::Theme;
use crate::effects::{RavenSprite, easing, glow_rect};
#[derive(Clone, Debug, PartialEq)]
pub struct DeckFx {
palette: Option<usize>,
pub glow: bool,
pub glow_speed: f32,
pub glow_layers: u32,
}
impl Default for DeckFx {
fn default() -> Self {
Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6 }
}
}
impl DeckFx {
pub const OFF: Self = Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6 };
pub fn palette(&self) -> Option<usize> {
self.palette
}
pub fn theme(&self) -> Option<Theme> {
self.palette.map(|i| Theme::ALL[i % Theme::ALL.len()]())
}
pub fn set_palette(&mut self, i: usize) {
self.palette = Some(i % Theme::ALL.len().max(1));
}
pub fn set_palette_named(&mut self, name: &str) -> Option<usize> {
let i = palette_index(name)?;
self.palette = Some(i);
Some(i)
}
pub fn clear_palette(&mut self) {
self.palette = None;
}
pub fn cycle_palette(&mut self) -> usize {
let next = next_palette(self.palette);
self.palette = Some(next);
next
}
pub fn glow_intensity_at(&self, time: f64) -> f32 {
if !self.glow {
return 0.0;
}
let raw = ((time * self.glow_speed as f64).sin() as f32) * 0.5 + 0.5;
easing::ease_in_out_cubic(raw.clamp(0.0, 1.0))
}
}
pub fn next_palette(current: Option<usize>) -> usize {
let len = Theme::ALL.len().max(1);
match current {
Some(i) => (i + 1) % len,
None => 0,
}
}
pub fn palette_index(name: &str) -> Option<usize> {
let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
let want = norm(name);
Theme::ALL.iter().position(|ctor| norm(ctor().name) == want)
}
#[derive(Clone)]
pub struct DeckRaven {
pub(crate) sprite: RavenSprite,
pub(crate) target: Rect,
}
impl DeckRaven {
pub fn new(target: Rect, theme: &Theme) -> Self {
let start = launch_point(target);
let sprite = RavenSprite::new()
.from(start)
.color(raven_body(theme))
.scale(1.2)
.fly_to(target);
Self { sprite, target }
}
pub fn from(mut self, start: Pos2) -> Self {
self.sprite = self.sprite.from(start).fly_to(self.target);
self
}
pub fn is_perched(&self) -> bool {
self.sprite.is_perched()
}
}
pub fn launch_point(target: Rect) -> Pos2 {
pos2(target.left() - 80.0, target.top() - 130.0)
}
fn raven_body(theme: &Theme) -> egui::Color32 {
let bg = theme.bg;
let lift = |c: u8| c.saturating_add(10).max(14);
egui::Color32::from_rgb(lift(bg.r()), lift(bg.g()), lift(bg.b()))
}
pub(crate) fn paint_active_glow(painter: &egui::Painter, rect: Rect, theme: &Theme, fx: &DeckFx, time: f64) {
let intensity = fx.glow_intensity_at(time);
if intensity <= 0.0 {
return;
}
glow_rect(painter, rect, theme.glow, intensity, fx.glow_layers);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cycle_wraps_through_every_palette_and_returns() {
let mut fx = DeckFx::OFF;
assert_eq!(fx.palette(), None);
let mut seen = Vec::new();
for _ in 0..Theme::ALL.len() {
seen.push(fx.cycle_palette());
}
assert_eq!(seen, (0..Theme::ALL.len()).collect::<Vec<_>>());
assert_eq!(fx.cycle_palette(), 0);
}
#[test]
fn next_palette_is_pure_and_wraps() {
assert_eq!(next_palette(None), 0);
assert_eq!(next_palette(Some(0)), 1 % Theme::ALL.len());
let last = Theme::ALL.len() - 1;
assert_eq!(next_palette(Some(last)), 0, "wraps at the end");
}
#[test]
fn set_palette_named_resolves_fuzzy_names() {
let mut fx = DeckFx::OFF;
let i = fx.set_palette_named("Nordic Aurora").expect("known palette");
assert_eq!(fx.theme().map(|t| t.name), Some("nordic-aurora"));
assert_eq!(palette_index("nordic_aurora"), Some(i));
assert!(fx.set_palette_named("nonesuch").is_none(), "unknown leaves selection");
assert_eq!(fx.palette(), Some(i));
}
#[test]
fn set_palette_wraps_into_range() {
let mut fx = DeckFx::OFF;
fx.set_palette(Theme::ALL.len() + 2);
assert_eq!(fx.palette(), Some(2 % Theme::ALL.len()));
}
#[test]
fn glow_intensity_is_zero_when_off_and_bounded_when_on() {
let off = DeckFx::OFF;
assert_eq!(off.glow_intensity_at(0.0), 0.0);
assert_eq!(off.glow_intensity_at(123.4), 0.0, "off → no glow regardless of time");
let mut on = DeckFx::OFF;
on.glow = true;
for k in 0..200 {
let t = k as f64 * 0.05;
let v = on.glow_intensity_at(t);
assert!((0.0..=1.0).contains(&v), "glow intensity in [0,1], got {v} at t={t}");
}
}
#[test]
fn clear_palette_drops_override() {
let mut fx = DeckFx::OFF;
fx.set_palette(3);
assert!(fx.theme().is_some());
fx.clear_palette();
assert_eq!(fx.palette(), None);
assert!(fx.theme().is_none());
}
#[test]
fn deck_raven_launches_off_target_and_perches_after_flight() {
use crate::effects::RAVEN_FLIGHT_SECS;
let target = Rect::from_min_size(pos2(300.0, 200.0), egui::vec2(180.0, 24.0));
let mut raven = DeckRaven::new(target, &Theme::default());
let lp = launch_point(target);
assert!(lp.x < target.left() && lp.y < target.top(), "launches off-target");
assert!(!raven.is_perched());
raven.sprite.advance(RAVEN_FLIGHT_SECS);
assert!(raven.is_perched(), "perched after the flight duration");
let perch = pos2(target.center().x, target.top());
assert!((raven.sprite.pos() - perch).length() <= 2.0, "converges onto the perch");
}
}