facett-core/src/deckfx.rs
//! **Deck-level effects + theming** — wires the [`theme`](crate::theme) palettes
//! and the [`effects`](crate::effects) bloom/raven into the
//! [`FacetDeck`](crate::FacetDeck) as **opt-in, host-controllable** features.
//!
//! Three things a host gets, all off by default and cheap when off:
//!
//! 1. **Palette switching at runtime** — [`DeckFx::cycle_palette`] /
//! [`set_palette`](DeckFx::set_palette) advance an index into [`Theme::ALL`],
//! and [`FacetDeck::palette_picker`](crate::FacetDeck::palette_picker) draws a
//! one-line switcher the host can show anywhere. The deck applies the chosen
//! palette with [`set_theme`](crate::set_theme) each frame.
//! 2. **Glow on the active facet** — toggle [`DeckFx::glow`]; when on, the deck
//! blooms the active facet's content rect with [`effects::glow_rect`] using the
//! palette's `glow` colour, pulsing with [`easing::ease_in_out_cubic`].
//! 3. **Summon the raven onto a target rect** — [`FacetDeck::send_raven`] launches
//! a [`RavenSprite`] that flies in and **perches** on any rect a facet/host
//! hands it (e.g. a table row). The deck drives + paints it on a foreground
//! layer.
//!
//! The whole surface is additive: an existing deck keeps working unchanged until a
//! host opts in via [`FacetDeck::with_fx`] / [`fx_mut`](crate::FacetDeck::fx_mut).
use egui::{Pos2, Rect, pos2};
use crate::Theme;
use crate::effects::{RavenSprite, easing, glow_rect};
/// Opt-in deck effects config. `Default` is **everything off** (no palette
/// override, no glow), so a deck that never touches it pays nothing.
#[derive(Clone, Debug, PartialEq)]
pub struct DeckFx {
/// `Some(i)` overrides the deck theme with `Theme::ALL[i]` each frame; `None`
/// leaves whatever theme the host already set on the context untouched.
palette: Option<usize>,
/// Bloom the active facet's content rect.
pub glow: bool,
/// Glow pulse speed (radians/sec of the sine that drives intensity).
pub glow_speed: f32,
/// Bloom layer count handed to [`glow_rect`] (more = softer/heavier).
pub glow_layers: u32,
/// Wrap the active facet's content in the shared glass/card [`chrome`](crate::chrome)
/// (rounded glass fill + glow edge + border), gated by the active
/// [`EffectsPolicy`](crate::look::EffectsPolicy).
pub chrome: bool,
}
impl Default for DeckFx {
fn default() -> Self {
Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6, chrome: false }
}
}
impl DeckFx {
/// All off (same as `default`) — the explicit baseline.
pub const OFF: Self = Self { palette: None, glow: false, glow_speed: 2.4, glow_layers: 6, chrome: false };
/// The selected palette index, if the deck is overriding the theme.
pub fn palette(&self) -> Option<usize> {
self.palette
}
/// The selected [`Theme`], if a palette override is active.
pub fn theme(&self) -> Option<Theme> {
self.palette.map(|i| Theme::ALL[i % Theme::ALL.len()]())
}
/// Pin the palette to index `i` (wraps into range). Enables the override.
pub fn set_palette(&mut self, i: usize) {
self.palette = Some(i % Theme::ALL.len().max(1));
}
/// Pin the palette by name (case/`-`/`_`-insensitive, like
/// [`Theme::by_name`]). Returns the resolved index, or `None` if unknown
/// (leaving the current selection unchanged).
pub fn set_palette_named(&mut self, name: &str) -> Option<usize> {
let i = palette_index(name)?;
self.palette = Some(i);
Some(i)
}
/// Stop overriding the theme — the host's own `set_theme` wins again.
pub fn clear_palette(&mut self) {
self.palette = None;
}
/// Advance to the next palette in [`Theme::ALL`] (wrapping). Starts at index 0
/// if no override was active yet. Returns the new index.
pub fn cycle_palette(&mut self) -> usize {
let next = next_palette(self.palette);
self.palette = Some(next);
next
}
/// The glow intensity at wall-clock `time` (seconds) — a `[0,1]` pulse eased
/// with [`easing::ease_in_out_cubic`]. Pure so a test can pin the curve.
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))
}
}
/// Index of the next palette after `current` in [`Theme::ALL`], wrapping. `None`
/// (no current selection) maps to `0`. Pure — the unit-tested cycle core.
pub fn next_palette(current: Option<usize>) -> usize {
let len = Theme::ALL.len().max(1);
match current {
Some(i) => (i + 1) % len,
None => 0,
}
}
/// Resolve a palette **name** to its index in [`Theme::ALL`] (same fuzzy matching
/// as [`Theme::by_name`]). `None` if unknown. Pure.
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)
}
/// A raven launched through the deck, flying toward a perch rect. The deck owns
/// one of these at a time (a fresh [`send_raven`](crate::FacetDeck::send_raven)
/// replaces it) and drives it on a foreground layer.
#[derive(Clone)]
pub struct DeckRaven {
pub(crate) sprite: RavenSprite,
pub(crate) target: Rect,
}
impl DeckRaven {
/// Build a raven aimed at `target`, launched from a sensible off-target point
/// (up-and-left of the perch) and tinted with the theme. The host can pass a
/// custom launch point with [`from`](Self::from).
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 }
}
/// Override the launch point (re-aims the flight at the same target).
pub fn from(mut self, start: Pos2) -> Self {
self.sprite = self.sprite.from(start).fly_to(self.target);
self
}
/// True once the raven has landed on its perch.
pub fn is_perched(&self) -> bool {
self.sprite.is_perched()
}
}
/// Where a deck raven launches from for a given perch: up-and-left, so it swoops
/// down onto the row. Pure (testable without a context).
pub fn launch_point(target: Rect) -> Pos2 {
pos2(target.left() - 80.0, target.top() - 130.0)
}
/// The raven's body colour for a theme: a near-black that still reads against the
/// palette's background (slightly lifted off pure black on dark grounds).
fn raven_body(theme: &Theme) -> egui::Color32 {
let bg = theme.bg;
// Lift a touch above the background so the silhouette stays visible.
let lift = |c: u8| c.saturating_add(10).max(14);
egui::Color32::from_rgb(lift(bg.r()), lift(bg.g()), lift(bg.b()))
}
/// Internal: bloom the active facet's `rect` with the deck's glow settings.
/// Called by [`FacetDeck::ui`](crate::FacetDeck) when `fx.glow` is on.
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);
// First cycle lands on 0, then steps through to the end and wraps.
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<_>>());
// One more wraps back to 0.
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");
// selection unchanged after a failed lookup
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());
// Launch point is up-and-left of the perch.
let lp = launch_point(target);
assert!(lp.x < target.left() && lp.y < target.top(), "launches off-target");
assert!(!raven.is_perched());
// Drive the underlying sprite headlessly: it perches on the row's top edge.
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");
}
}