facett-core 0.1.10

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **chrome** — the shared **glass / card** decoration (Tier-1 §T1.3): a rounded
//! frame with an [`effects::glow_rect`](crate::effects::glow_rect) edge over an
//! [`overlay::glass_tint`](crate::overlay::glass_tint) fill, all gated by the
//! active [`EffectsPolicy`](crate::look::EffectsPolicy). One call gives any panel
//! the "frosted card" look that degrades gracefully: a soft glass card under
//! `Full`/`Reduced`, a crisp opaque panel under `None`/Device.
//!
//! Pure egui painter work (glow backend, no GPU shader) — real backdrop blur is the
//! later wgpu step (`overlay`'s `Frosted` keystone). Deterministic: no state, no
//! clock; the same `(rect, theme, policy, style)` snapshots identically.
//!
//! Layering matters: the **fill** must sit *behind* the card's content while the
//! **edge** (glow + border) sits on top. [`card`] paints both immediately (for an
//! empty card, or when content is drawn after); a host that draws content *into*
//! the card reserves a slot for [`fill_shape`] first, then calls [`edge`] after
//! (this is what [`FacetDeck::ui`](crate::FacetDeck) does).

use egui::{Color32, CornerRadius, Painter, Rect, Shape, Stroke, StrokeKind};

use crate::Theme;
use crate::effects::glow_rect;
use crate::look::EffectsPolicy;
use crate::overlay::glass_tint;

/// Look of a glass [`card`]: corner radius, glass-fill strength, and the edge glow.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ChromeStyle {
    /// Corner radius of the card, px.
    pub radius: f32,
    /// Glass-fill alpha over the panel colour (`0..=255`) when transparency is
    /// allowed. `0` paints no fill (just an edge).
    pub tint_alpha: u8,
    /// Edge-glow intensity `∈[0,1]` handed to [`glow_rect`]. `0` = no glow.
    pub glow_intensity: f32,
    /// Edge-glow layer count (more = softer/heavier).
    pub glow_layers: u32,
    /// Border stroke width, px (`0` = no border).
    pub border_width: f32,
}

impl Default for ChromeStyle {
    /// A subtle card: 8px radius, a light glass tint, a soft 5-layer glow, 1px border.
    fn default() -> Self {
        Self { radius: 8.0, tint_alpha: 30, glow_intensity: 0.5, glow_layers: 5, border_width: 1.0 }
    }
}

impl ChromeStyle {
    /// Soften the chrome for an [`EffectsPolicy`]: `Full` keeps the full look,
    /// `Reduced` drops the decorative glow (keeps the glass tint + border), `None`
    /// strips both (the opaque-card fallback is handled in [`fill_shape`]/[`edge`]).
    pub fn for_policy(mut self, policy: EffectsPolicy) -> Self {
        match policy {
            EffectsPolicy::Full => {}
            EffectsPolicy::Reduced => self.glow_intensity = 0.0,
            EffectsPolicy::None => {
                self.glow_intensity = 0.0;
                self.tint_alpha = 0;
            }
        }
        self
    }
}

/// Perceived-luminance test (Rec. 601) — picks the stronger glass edge on light
/// backgrounds (the `on_light` hint of [`glass_tint`]).
fn is_light(c: Color32) -> bool {
    0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32 > 140.0
}

/// The card **fill** shape (drawn *behind* content): a rounded glass tint over the
/// theme's panel colour when [`policy`](EffectsPolicy) allows transparency, else a
/// crisp opaque panel fill. Return it so the caller can drop it into a painter slot
/// reserved before the content (so it stays behind), or just `painter.add` it.
pub fn fill_shape(rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) -> Shape {
    let radius = CornerRadius::same(style.radius as u8);
    let fill = if policy.allows_transparency() && style.tint_alpha > 0 {
        // Translucent "glass" over the host — the all-backends degrade of `Frosted`
        // (true backdrop blur is the later wgpu keystone).
        glass_tint(theme.panel_bg, style.tint_alpha, is_light(theme.panel_bg))
    } else {
        // Opaque card (Device / `None`, or a no-tint style).
        theme.panel_bg
    };
    Shape::rect_filled(rect, radius, fill)
}

/// The card **edge** (drawn *on top*): the [`glow_rect`] bloom (when the policy
/// allows decorative effects) plus a crisp rounded border. Call after the content.
pub fn edge(painter: &Painter, rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) {
    // Soft outer glow — only when transparency/decorative effects are on.
    if policy.allows_transparency() && style.glow_intensity > 0.0 && style.glow_layers > 0 {
        glow_rect(painter, rect, theme.glow, style.glow_intensity, style.glow_layers);
    }
    // Crisp rounded border, always (it reads as a card even under `None`).
    if style.border_width > 0.0 {
        painter.rect_stroke(
            rect,
            CornerRadius::same(style.radius as u8),
            Stroke::new(style.border_width, theme.panel_stroke),
            StrokeKind::Inside,
        );
    }
}

/// Paint a complete glass **card** at `rect` on `ui`'s painter — fill then edge,
/// both immediately. Use this for an empty card, or when the card's content is
/// drawn *after* this call (so the fill stays behind it). For content drawn into a
/// scope, reserve a slot for [`fill_shape`] first and call [`edge`] after instead.
pub fn card(ui: &egui::Ui, rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) {
    let painter = ui.painter();
    painter.add(fill_shape(rect, theme, policy, style));
    edge(painter, rect, theme, policy, style);
}

#[cfg(test)]
mod tests {
    use super::*;
    use egui::pos2;

    fn rect() -> Rect {
        Rect::from_min_max(pos2(10.0, 10.0), pos2(210.0, 110.0))
    }

    #[test]
    fn fill_is_translucent_under_full_and_opaque_under_none() {
        let th = Theme::deep_space();
        let style = ChromeStyle::default();
        let fill_color = |policy: EffectsPolicy| -> Color32 {
            match fill_shape(rect(), &th, policy, style.for_policy(policy)) {
                Shape::Rect(r) => r.fill,
                _ => panic!("expected a rect fill shape"),
            }
        };
        // Full → glass tint (translucent). None → the raw panel colour (no glass
        // reduction), as opaque as the theme's own panel and stronger than the tint.
        let full = fill_color(EffectsPolicy::Full);
        let none = fill_color(EffectsPolicy::None);
        assert!(full.a() < 255, "glass fill is translucent under Full: a={}", full.a());
        assert_eq!(none, th.panel_bg, "None paints the raw panel colour (no tint)");
        assert!(none.a() > full.a(), "opaque card is stronger than the glass tint");
    }

    #[test]
    fn for_policy_strips_glow_then_tint() {
        let s = ChromeStyle::default();
        assert!(s.for_policy(EffectsPolicy::Full).glow_intensity > 0.0);
        assert_eq!(s.for_policy(EffectsPolicy::Reduced).glow_intensity, 0.0);
        assert!(s.for_policy(EffectsPolicy::Reduced).tint_alpha > 0, "Reduced keeps glass");
        let none = s.for_policy(EffectsPolicy::None);
        assert_eq!(none.glow_intensity, 0.0);
        assert_eq!(none.tint_alpha, 0, "None strips the glass tint too");
    }

    #[test]
    fn card_tessellates_a_non_empty_frame() {
        // Headless: painting a card yields real geometry (picturable).
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            screen_rect: Some(Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0))),
            ..Default::default()
        };
        let out = ctx.run(input, |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                card(ui, rect(), &Theme::deep_space(), EffectsPolicy::Full, ChromeStyle::default());
            });
        });
        let prims = ctx.tessellate(out.shapes, out.pixels_per_point);
        let verts: usize = prims
            .iter()
            .map(|p| match &p.primitive {
                egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
                _ => 0,
            })
            .sum();
        assert!(verts > 0, "card paints a non-empty frame");
    }
}