Skip to main content

facett_core/
deckfx.rs

1//! **Deck-level effects + theming** — wires the [`theme`](crate::theme) palettes
2//! and the [`effects`](crate::effects) bloom/raven into the
3//! [`FacetDeck`](crate::FacetDeck) as **opt-in, host-controllable** features.
4//!
5//! Three things a host gets, all off by default and cheap when off:
6//!
7//! 1. **Palette switching at runtime** — [`DeckFx::cycle_palette`] /
8//!    [`set_palette`](DeckFx::set_palette) advance an index into [`Theme::ALL`],
9//!    and [`FacetDeck::palette_picker`](crate::FacetDeck::palette_picker) draws a
10//!    one-line switcher the host can show anywhere. The deck applies the chosen
11//!    palette with [`set_theme`](crate::set_theme) each frame.
12//! 2. **Glow on the active facet** — toggle [`DeckFx::glow`]; when on, the deck
13//!    blooms the active facet's content rect with [`effects::glow_rect`] using the
14//!    palette's `glow` colour, pulsing with [`easing::ease_in_out_cubic`].
15//! 3. **Summon the raven onto a target rect** — [`FacetDeck::send_raven`] launches
16//!    a [`RavenSprite`] that flies in and **perches** on any rect a facet/host
17//!    hands it (e.g. a table row). The deck drives + paints it on a foreground
18//!    layer.
19//!
20//! The whole surface is additive: an existing deck keeps working unchanged until a
21//! host opts in via [`FacetDeck::with_fx`] / [`fx_mut`](crate::FacetDeck::fx_mut).
22
23use egui::{Pos2, Rect, pos2};
24
25use crate::Theme;
26use crate::effects::{RavenSprite, easing, glow_rect};
27
28/// Opt-in deck effects config. `Default` is **everything off** (no palette
29/// override, no glow), so a deck that never touches it pays nothing.
30#[derive(Clone, Debug, PartialEq)]
31pub struct DeckFx {
32    /// `Some(i)` overrides the deck theme with `Theme::ALL[i]` each frame; `None`
33    /// leaves whatever theme the host already set on the context untouched.
34    palette: Option<usize>,
35    /// Bloom the active facet's content rect.
36    pub glow: bool,
37    /// Glow pulse speed (radians/sec of the sine that drives intensity).
38    pub glow_speed: f32,
39    /// Bloom layer count handed to [`glow_rect`] (more = softer/heavier).
40    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    /// All off (same as `default`) — the explicit baseline.
51    pub const OFF: Self = Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6 };
52
53    /// The selected palette index, if the deck is overriding the theme.
54    pub fn palette(&self) -> Option<usize> {
55        self.palette
56    }
57
58    /// The selected [`Theme`], if a palette override is active.
59    pub fn theme(&self) -> Option<Theme> {
60        self.palette.map(|i| Theme::ALL[i % Theme::ALL.len()]())
61    }
62
63    /// Pin the palette to index `i` (wraps into range). Enables the override.
64    pub fn set_palette(&mut self, i: usize) {
65        self.palette = Some(i % Theme::ALL.len().max(1));
66    }
67
68    /// Pin the palette by name (case/`-`/`_`-insensitive, like
69    /// [`Theme::by_name`]). Returns the resolved index, or `None` if unknown
70    /// (leaving the current selection unchanged).
71    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    /// Stop overriding the theme — the host's own `set_theme` wins again.
78    pub fn clear_palette(&mut self) {
79        self.palette = None;
80    }
81
82    /// Advance to the next palette in [`Theme::ALL`] (wrapping). Starts at index 0
83    /// if no override was active yet. Returns the new index.
84    pub fn cycle_palette(&mut self) -> usize {
85        let next = next_palette(self.palette);
86        self.palette = Some(next);
87        next
88    }
89
90    /// The glow intensity at wall-clock `time` (seconds) — a `[0,1]` pulse eased
91    /// with [`easing::ease_in_out_cubic`]. Pure so a test can pin the curve.
92    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
101/// Index of the next palette after `current` in [`Theme::ALL`], wrapping. `None`
102/// (no current selection) maps to `0`. Pure — the unit-tested cycle core.
103pub 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
111/// Resolve a palette **name** to its index in [`Theme::ALL`] (same fuzzy matching
112/// as [`Theme::by_name`]). `None` if unknown. Pure.
113pub 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/// A raven launched through the deck, flying toward a perch rect. The deck owns
120/// one of these at a time (a fresh [`send_raven`](crate::FacetDeck::send_raven)
121/// replaces it) and drives it on a foreground layer.
122#[derive(Clone)]
123pub struct DeckRaven {
124    pub(crate) sprite: RavenSprite,
125    pub(crate) target: Rect,
126}
127
128impl DeckRaven {
129    /// Build a raven aimed at `target`, launched from a sensible off-target point
130    /// (up-and-left of the perch) and tinted with the theme. The host can pass a
131    /// custom launch point with [`from`](Self::from).
132    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    /// Override the launch point (re-aims the flight at the same target).
143    pub fn from(mut self, start: Pos2) -> Self {
144        self.sprite = self.sprite.from(start).fly_to(self.target);
145        self
146    }
147
148    /// True once the raven has landed on its perch.
149    pub fn is_perched(&self) -> bool {
150        self.sprite.is_perched()
151    }
152}
153
154/// Where a deck raven launches from for a given perch: up-and-left, so it swoops
155/// down onto the row. Pure (testable without a context).
156pub fn launch_point(target: Rect) -> Pos2 {
157    pos2(target.left() - 80.0, target.top() - 130.0)
158}
159
160/// The raven's body colour for a theme: a near-black that still reads against the
161/// palette's background (slightly lifted off pure black on dark grounds).
162fn raven_body(theme: &Theme) -> egui::Color32 {
163    let bg = theme.bg;
164    // Lift a touch above the background so the silhouette stays visible.
165    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
169/// Internal: bloom the active facet's `rect` with the deck's glow settings.
170/// Called by [`FacetDeck::ui`](crate::FacetDeck) when `fx.glow` is on.
171pub(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        // First cycle lands on 0, then steps through to the end and wraps.
188        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        // One more wraps back to 0.
194        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        // selection unchanged after a failed lookup
213        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        // Launch point is up-and-left of the perch.
254        let lp = launch_point(target);
255        assert!(lp.x < target.left() && lp.y < target.top(), "launches off-target");
256        assert!(!raven.is_perched());
257        // Drive the underlying sprite headlessly: it perches on the row's top edge.
258        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}