Skip to main content

facett_core/
chrome.rs

1//! **chrome** — the shared **glass / card** decoration (Tier-1 §T1.3): a rounded
2//! frame with an [`effects::glow_rect`](crate::effects::glow_rect) edge over an
3//! [`overlay::glass_tint`](crate::overlay::glass_tint) fill, all gated by the
4//! active [`EffectsPolicy`](crate::look::EffectsPolicy). One call gives any panel
5//! the "frosted card" look that degrades gracefully: a soft glass card under
6//! `Full`/`Reduced`, a crisp opaque panel under `None`/Device.
7//!
8//! Pure egui painter work (glow backend, no GPU shader) — real backdrop blur is the
9//! later wgpu step (`overlay`'s `Frosted` keystone). Deterministic: no state, no
10//! clock; the same `(rect, theme, policy, style)` snapshots identically.
11//!
12//! Layering matters: the **fill** must sit *behind* the card's content while the
13//! **edge** (glow + border) sits on top. [`card`] paints both immediately (for an
14//! empty card, or when content is drawn after); a host that draws content *into*
15//! the card reserves a slot for [`fill_shape`] first, then calls [`edge`] after
16//! (this is what [`FacetDeck::ui`](crate::FacetDeck) does).
17
18use egui::{Color32, CornerRadius, Painter, Rect, Shape, Stroke, StrokeKind};
19
20use crate::Theme;
21use crate::effects::glow_rect;
22use crate::look::EffectsPolicy;
23use crate::overlay::glass_tint;
24
25/// Look of a glass [`card`]: corner radius, glass-fill strength, and the edge glow.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct ChromeStyle {
28    /// Corner radius of the card, px.
29    pub radius: f32,
30    /// Glass-fill alpha over the panel colour (`0..=255`) when transparency is
31    /// allowed. `0` paints no fill (just an edge).
32    pub tint_alpha: u8,
33    /// Edge-glow intensity `∈[0,1]` handed to [`glow_rect`]. `0` = no glow.
34    pub glow_intensity: f32,
35    /// Edge-glow layer count (more = softer/heavier).
36    pub glow_layers: u32,
37    /// Border stroke width, px (`0` = no border).
38    pub border_width: f32,
39}
40
41impl Default for ChromeStyle {
42    /// A subtle card: 8px radius, a light glass tint, a soft 5-layer glow, 1px border.
43    fn default() -> Self {
44        Self { radius: 8.0, tint_alpha: 30, glow_intensity: 0.5, glow_layers: 5, border_width: 1.0 }
45    }
46}
47
48impl ChromeStyle {
49    /// Soften the chrome for an [`EffectsPolicy`]: `Full` keeps the full look,
50    /// `Reduced` drops the decorative glow (keeps the glass tint + border), `None`
51    /// strips both (the opaque-card fallback is handled in [`fill_shape`]/[`edge`]).
52    pub fn for_policy(mut self, policy: EffectsPolicy) -> Self {
53        match policy {
54            EffectsPolicy::Full => {}
55            EffectsPolicy::Reduced => self.glow_intensity = 0.0,
56            EffectsPolicy::None => {
57                self.glow_intensity = 0.0;
58                self.tint_alpha = 0;
59            }
60        }
61        self
62    }
63}
64
65/// Perceived-luminance test (Rec. 601) — picks the stronger glass edge on light
66/// backgrounds (the `on_light` hint of [`glass_tint`]).
67fn is_light(c: Color32) -> bool {
68    0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32 > 140.0
69}
70
71/// The card **fill** shape (drawn *behind* content): a rounded glass tint over the
72/// theme's panel colour when [`policy`](EffectsPolicy) allows transparency, else a
73/// crisp opaque panel fill. Return it so the caller can drop it into a painter slot
74/// reserved before the content (so it stays behind), or just `painter.add` it.
75pub fn fill_shape(rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) -> Shape {
76    let radius = CornerRadius::same(style.radius as u8);
77    let fill = if policy.allows_transparency() && style.tint_alpha > 0 {
78        // Translucent "glass" over the host — the all-backends degrade of `Frosted`
79        // (true backdrop blur is the later wgpu keystone).
80        glass_tint(theme.panel_bg, style.tint_alpha, is_light(theme.panel_bg))
81    } else {
82        // Opaque card (Device / `None`, or a no-tint style).
83        theme.panel_bg
84    };
85    Shape::rect_filled(rect, radius, fill)
86}
87
88/// The card **edge** (drawn *on top*): the [`glow_rect`] bloom (when the policy
89/// allows decorative effects) plus a crisp rounded border. Call after the content.
90pub fn edge(painter: &Painter, rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) {
91    // Soft outer glow — only when transparency/decorative effects are on.
92    if policy.allows_transparency() && style.glow_intensity > 0.0 && style.glow_layers > 0 {
93        glow_rect(painter, rect, theme.glow, style.glow_intensity, style.glow_layers);
94    }
95    // Crisp rounded border, always (it reads as a card even under `None`).
96    if style.border_width > 0.0 {
97        painter.rect_stroke(
98            rect,
99            CornerRadius::same(style.radius as u8),
100            Stroke::new(style.border_width, theme.panel_stroke),
101            StrokeKind::Inside,
102        );
103    }
104}
105
106/// Paint a complete glass **card** at `rect` on `ui`'s painter — fill then edge,
107/// both immediately. Use this for an empty card, or when the card's content is
108/// drawn *after* this call (so the fill stays behind it). For content drawn into a
109/// scope, reserve a slot for [`fill_shape`] first and call [`edge`] after instead.
110pub fn card(ui: &egui::Ui, rect: Rect, theme: &Theme, policy: EffectsPolicy, style: ChromeStyle) {
111    let painter = ui.painter();
112    painter.add(fill_shape(rect, theme, policy, style));
113    edge(painter, rect, theme, policy, style);
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use egui::pos2;
120
121    fn rect() -> Rect {
122        Rect::from_min_max(pos2(10.0, 10.0), pos2(210.0, 110.0))
123    }
124
125    #[test]
126    fn fill_is_translucent_under_full_and_opaque_under_none() {
127        let th = Theme::deep_space();
128        let style = ChromeStyle::default();
129        let fill_color = |policy: EffectsPolicy| -> Color32 {
130            match fill_shape(rect(), &th, policy, style.for_policy(policy)) {
131                Shape::Rect(r) => r.fill,
132                _ => panic!("expected a rect fill shape"),
133            }
134        };
135        // Full → glass tint (translucent). None → the raw panel colour (no glass
136        // reduction), as opaque as the theme's own panel and stronger than the tint.
137        let full = fill_color(EffectsPolicy::Full);
138        let none = fill_color(EffectsPolicy::None);
139        assert!(full.a() < 255, "glass fill is translucent under Full: a={}", full.a());
140        assert_eq!(none, th.panel_bg, "None paints the raw panel colour (no tint)");
141        assert!(none.a() > full.a(), "opaque card is stronger than the glass tint");
142    }
143
144    #[test]
145    fn for_policy_strips_glow_then_tint() {
146        let s = ChromeStyle::default();
147        assert!(s.for_policy(EffectsPolicy::Full).glow_intensity > 0.0);
148        assert_eq!(s.for_policy(EffectsPolicy::Reduced).glow_intensity, 0.0);
149        assert!(s.for_policy(EffectsPolicy::Reduced).tint_alpha > 0, "Reduced keeps glass");
150        let none = s.for_policy(EffectsPolicy::None);
151        assert_eq!(none.glow_intensity, 0.0);
152        assert_eq!(none.tint_alpha, 0, "None strips the glass tint too");
153    }
154
155    #[test]
156    fn card_tessellates_a_non_empty_frame() {
157        // Headless: painting a card yields real geometry (picturable).
158        let ctx = egui::Context::default();
159        let input = egui::RawInput {
160            screen_rect: Some(Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0))),
161            ..Default::default()
162        };
163        let out = ctx.run(input, |ctx| {
164            egui::CentralPanel::default().show(ctx, |ui| {
165                card(ui, rect(), &Theme::deep_space(), EffectsPolicy::Full, ChromeStyle::default());
166            });
167        });
168        let prims = ctx.tessellate(out.shapes, out.pixels_per_point);
169        let verts: usize = prims
170            .iter()
171            .map(|p| match &p.primitive {
172                egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
173                _ => 0,
174            })
175            .sum();
176        assert!(verts > 0, "card paints a non-empty frame");
177    }
178}