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}
42
43impl Default for DeckFx {
44 fn default() -> Self {
45 Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6 }
46 }
47}
48
49impl DeckFx {
50 pub const OFF: Self = Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6 };
52
53 pub fn palette(&self) -> Option<usize> {
55 self.palette
56 }
57
58 pub fn theme(&self) -> Option<Theme> {
60 self.palette.map(|i| Theme::ALL[i % Theme::ALL.len()]())
61 }
62
63 pub fn set_palette(&mut self, i: usize) {
65 self.palette = Some(i % Theme::ALL.len().max(1));
66 }
67
68 pub fn set_palette_named(&mut self, name: &str) -> Option<usize> {
72 let i = palette_index(name)?;
73 self.palette = Some(i);
74 Some(i)
75 }
76
77 pub fn clear_palette(&mut self) {
79 self.palette = None;
80 }
81
82 pub fn cycle_palette(&mut self) -> usize {
85 let next = next_palette(self.palette);
86 self.palette = Some(next);
87 next
88 }
89
90 pub fn glow_intensity_at(&self, time: f64) -> f32 {
93 if !self.glow {
94 return 0.0;
95 }
96 let raw = ((time * self.glow_speed as f64).sin() as f32) * 0.5 + 0.5;
97 easing::ease_in_out_cubic(raw.clamp(0.0, 1.0))
98 }
99}
100
101pub fn next_palette(current: Option<usize>) -> usize {
104 let len = Theme::ALL.len().max(1);
105 match current {
106 Some(i) => (i + 1) % len,
107 None => 0,
108 }
109}
110
111pub fn palette_index(name: &str) -> Option<usize> {
114 let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
115 let want = norm(name);
116 Theme::ALL.iter().position(|ctor| norm(ctor().name) == want)
117}
118
119#[derive(Clone)]
123pub struct DeckRaven {
124 pub(crate) sprite: RavenSprite,
125 pub(crate) target: Rect,
126}
127
128impl DeckRaven {
129 pub fn new(target: Rect, theme: &Theme) -> Self {
133 let start = launch_point(target);
134 let sprite = RavenSprite::new()
135 .from(start)
136 .color(raven_body(theme))
137 .scale(1.2)
138 .fly_to(target);
139 Self { sprite, target }
140 }
141
142 pub fn from(mut self, start: Pos2) -> Self {
144 self.sprite = self.sprite.from(start).fly_to(self.target);
145 self
146 }
147
148 pub fn is_perched(&self) -> bool {
150 self.sprite.is_perched()
151 }
152}
153
154pub fn launch_point(target: Rect) -> Pos2 {
157 pos2(target.left() - 80.0, target.top() - 130.0)
158}
159
160fn raven_body(theme: &Theme) -> egui::Color32 {
163 let bg = theme.bg;
164 let lift = |c: u8| c.saturating_add(10).max(14);
166 egui::Color32::from_rgb(lift(bg.r()), lift(bg.g()), lift(bg.b()))
167}
168
169pub(crate) fn paint_active_glow(painter: &egui::Painter, rect: Rect, theme: &Theme, fx: &DeckFx, time: f64) {
172 let intensity = fx.glow_intensity_at(time);
173 if intensity <= 0.0 {
174 return;
175 }
176 glow_rect(painter, rect, theme.glow, intensity, fx.glow_layers);
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn cycle_wraps_through_every_palette_and_returns() {
185 let mut fx = DeckFx::OFF;
186 assert_eq!(fx.palette(), None);
187 let mut seen = Vec::new();
189 for _ in 0..Theme::ALL.len() {
190 seen.push(fx.cycle_palette());
191 }
192 assert_eq!(seen, (0..Theme::ALL.len()).collect::<Vec<_>>());
193 assert_eq!(fx.cycle_palette(), 0);
195 }
196
197 #[test]
198 fn next_palette_is_pure_and_wraps() {
199 assert_eq!(next_palette(None), 0);
200 assert_eq!(next_palette(Some(0)), 1 % Theme::ALL.len());
201 let last = Theme::ALL.len() - 1;
202 assert_eq!(next_palette(Some(last)), 0, "wraps at the end");
203 }
204
205 #[test]
206 fn set_palette_named_resolves_fuzzy_names() {
207 let mut fx = DeckFx::OFF;
208 let i = fx.set_palette_named("Nordic Aurora").expect("known palette");
209 assert_eq!(fx.theme().map(|t| t.name), Some("nordic-aurora"));
210 assert_eq!(palette_index("nordic_aurora"), Some(i));
211 assert!(fx.set_palette_named("nonesuch").is_none(), "unknown leaves selection");
212 assert_eq!(fx.palette(), Some(i));
214 }
215
216 #[test]
217 fn set_palette_wraps_into_range() {
218 let mut fx = DeckFx::OFF;
219 fx.set_palette(Theme::ALL.len() + 2);
220 assert_eq!(fx.palette(), Some(2 % Theme::ALL.len()));
221 }
222
223 #[test]
224 fn glow_intensity_is_zero_when_off_and_bounded_when_on() {
225 let off = DeckFx::OFF;
226 assert_eq!(off.glow_intensity_at(0.0), 0.0);
227 assert_eq!(off.glow_intensity_at(123.4), 0.0, "off → no glow regardless of time");
228
229 let mut on = DeckFx::OFF;
230 on.glow = true;
231 for k in 0..200 {
232 let t = k as f64 * 0.05;
233 let v = on.glow_intensity_at(t);
234 assert!((0.0..=1.0).contains(&v), "glow intensity in [0,1], got {v} at t={t}");
235 }
236 }
237
238 #[test]
239 fn clear_palette_drops_override() {
240 let mut fx = DeckFx::OFF;
241 fx.set_palette(3);
242 assert!(fx.theme().is_some());
243 fx.clear_palette();
244 assert_eq!(fx.palette(), None);
245 assert!(fx.theme().is_none());
246 }
247
248 #[test]
249 fn deck_raven_launches_off_target_and_perches_after_flight() {
250 use crate::effects::RAVEN_FLIGHT_SECS;
251 let target = Rect::from_min_size(pos2(300.0, 200.0), egui::vec2(180.0, 24.0));
252 let mut raven = DeckRaven::new(target, &Theme::default());
253 let lp = launch_point(target);
255 assert!(lp.x < target.left() && lp.y < target.top(), "launches off-target");
256 assert!(!raven.is_perched());
257 raven.sprite.advance(RAVEN_FLIGHT_SECS);
259 assert!(raven.is_perched(), "perched after the flight duration");
260 let perch = pos2(target.center().x, target.top());
261 assert!((raven.sprite.pos() - perch).length() <= 2.0, "converges onto the perch");
262 }
263}