1use egui::{Pos2, Rect, pos2};
24
25use crate::Theme;
26use crate::effects::{RavenSprite, easing, glow_rect};
27
28#[derive(Clone, Debug, PartialEq)]
31pub struct DeckFx {
32 palette: Option<usize>,
35 pub glow: bool,
37 pub glow_speed: f32,
39 pub glow_layers: u32,
41 pub chrome: bool,
45}
46
47impl Default for DeckFx {
48 fn default() -> Self {
49 Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6, chrome: false }
50 }
51}
52
53impl DeckFx {
54 pub const OFF: Self = Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6, chrome: false };
56
57 pub fn palette(&self) -> Option<usize> {
59 self.palette
60 }
61
62 pub fn theme(&self) -> Option<Theme> {
64 self.palette.map(|i| Theme::ALL[i % Theme::ALL.len()]())
65 }
66
67 pub fn set_palette(&mut self, i: usize) {
69 self.palette = Some(i % Theme::ALL.len().max(1));
70 }
71
72 pub fn set_palette_named(&mut self, name: &str) -> Option<usize> {
76 let i = palette_index(name)?;
77 self.palette = Some(i);
78 Some(i)
79 }
80
81 pub fn clear_palette(&mut self) {
83 self.palette = None;
84 }
85
86 pub fn cycle_palette(&mut self) -> usize {
89 let next = next_palette(self.palette);
90 self.palette = Some(next);
91 next
92 }
93
94 pub fn glow_intensity_at(&self, time: f64) -> f32 {
97 if !self.glow {
98 return 0.0;
99 }
100 let raw = ((time * self.glow_speed as f64).sin() as f32) * 0.5 + 0.5;
101 easing::ease_in_out_cubic(raw.clamp(0.0, 1.0))
102 }
103}
104
105pub fn next_palette(current: Option<usize>) -> usize {
108 let len = Theme::ALL.len().max(1);
109 match current {
110 Some(i) => (i + 1) % len,
111 None => 0,
112 }
113}
114
115pub fn palette_index(name: &str) -> Option<usize> {
118 let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
119 let want = norm(name);
120 Theme::ALL.iter().position(|ctor| norm(ctor().name) == want)
121}
122
123#[derive(Clone)]
127pub struct DeckRaven {
128 pub(crate) sprite: RavenSprite,
129 pub(crate) target: Rect,
130}
131
132impl DeckRaven {
133 pub fn new(target: Rect, theme: &Theme) -> Self {
137 let start = launch_point(target);
138 let sprite = RavenSprite::new()
139 .from(start)
140 .color(raven_body(theme))
141 .scale(1.2)
142 .fly_to(target);
143 Self { sprite, target }
144 }
145
146 pub fn from(mut self, start: Pos2) -> Self {
148 self.sprite = self.sprite.from(start).fly_to(self.target);
149 self
150 }
151
152 pub fn is_perched(&self) -> bool {
154 self.sprite.is_perched()
155 }
156}
157
158pub fn launch_point(target: Rect) -> Pos2 {
161 pos2(target.left() - 80.0, target.top() - 130.0)
162}
163
164fn raven_body(theme: &Theme) -> egui::Color32 {
167 let bg = theme.bg;
168 let lift = |c: u8| c.saturating_add(10).max(14);
170 egui::Color32::from_rgb(lift(bg.r()), lift(bg.g()), lift(bg.b()))
171}
172
173pub(crate) fn paint_active_glow(painter: &egui::Painter, rect: Rect, theme: &Theme, fx: &DeckFx, time: f64) {
176 let intensity = fx.glow_intensity_at(time);
177 if intensity <= 0.0 {
178 return;
179 }
180 glow_rect(painter, rect, theme.glow, intensity, fx.glow_layers);
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn cycle_wraps_through_every_palette_and_returns() {
189 let mut fx = DeckFx::OFF;
190 assert_eq!(fx.palette(), None);
191 let mut seen = Vec::new();
193 for _ in 0..Theme::ALL.len() {
194 seen.push(fx.cycle_palette());
195 }
196 assert_eq!(seen, (0..Theme::ALL.len()).collect::<Vec<_>>());
197 assert_eq!(fx.cycle_palette(), 0);
199 }
200
201 #[test]
202 fn next_palette_is_pure_and_wraps() {
203 assert_eq!(next_palette(None), 0);
204 assert_eq!(next_palette(Some(0)), 1 % Theme::ALL.len());
205 let last = Theme::ALL.len() - 1;
206 assert_eq!(next_palette(Some(last)), 0, "wraps at the end");
207 }
208
209 #[test]
210 fn set_palette_named_resolves_fuzzy_names() {
211 let mut fx = DeckFx::OFF;
212 let i = fx.set_palette_named("Nordic Aurora").expect("known palette");
213 assert_eq!(fx.theme().map(|t| t.name), Some("nordic-aurora"));
214 assert_eq!(palette_index("nordic_aurora"), Some(i));
215 assert!(fx.set_palette_named("nonesuch").is_none(), "unknown leaves selection");
216 assert_eq!(fx.palette(), Some(i));
218 }
219
220 #[test]
221 fn set_palette_wraps_into_range() {
222 let mut fx = DeckFx::OFF;
223 fx.set_palette(Theme::ALL.len() + 2);
224 assert_eq!(fx.palette(), Some(2 % Theme::ALL.len()));
225 }
226
227 #[test]
228 fn glow_intensity_is_zero_when_off_and_bounded_when_on() {
229 let off = DeckFx::OFF;
230 assert_eq!(off.glow_intensity_at(0.0), 0.0);
231 assert_eq!(off.glow_intensity_at(123.4), 0.0, "off → no glow regardless of time");
232
233 let mut on = DeckFx::OFF;
234 on.glow = true;
235 for k in 0..200 {
236 let t = k as f64 * 0.05;
237 let v = on.glow_intensity_at(t);
238 assert!((0.0..=1.0).contains(&v), "glow intensity in [0,1], got {v} at t={t}");
239 }
240 }
241
242 #[test]
243 fn clear_palette_drops_override() {
244 let mut fx = DeckFx::OFF;
245 fx.set_palette(3);
246 assert!(fx.theme().is_some());
247 fx.clear_palette();
248 assert_eq!(fx.palette(), None);
249 assert!(fx.theme().is_none());
250 }
251
252 #[test]
253 fn deck_raven_launches_off_target_and_perches_after_flight() {
254 use crate::effects::RAVEN_FLIGHT_SECS;
255 let target = Rect::from_min_size(pos2(300.0, 200.0), egui::vec2(180.0, 24.0));
256 let mut raven = DeckRaven::new(target, &Theme::default());
257 let lp = launch_point(target);
259 assert!(lp.x < target.left() && lp.y < target.top(), "launches off-target");
260 assert!(!raven.is_perched());
261 raven.sprite.advance(RAVEN_FLIGHT_SECS);
263 assert!(raven.is_perched(), "perched after the flight duration");
264 let perch = pos2(target.center().x, target.top());
265 assert!((raven.sprite.pos() - perch).length() <= 2.0, "converges onto the perch");
266 }
267}