Skip to main content

facett_core/
overlay.rs

1//! **Composability & overlays** (§5) + **glass / masks** (§20) — render any facett
2//! component into an arbitrary sub-`Rect`/layer of any other (a dataframe pinned
3//! over a map, a HUD over video), clipped by a **mask**, with optional glass
4//! gated by [`EffectsPolicy`](crate::look::EffectsPolicy).
5//!
6//! egui has **no native backdrop blur** (only `Shadow` blurs), so `Frosted`
7//! degrades to `Tint` off-wgpu (SURF-2). All masks here are `Rect`/`RoundedRect`
8//! via clipping (always available); arbitrary `Path`-stencil masks are the
9//! wgpu-only extension (SURF-3, M3).
10
11use egui::{Color32, CornerRadius, LayerId, Order, Rect, Ui, UiBuilder};
12
13use crate::look::{EffectsPolicy, SurfaceSpec};
14
15/// A clip mask for a partial overlay (SURF-3). `RoundedRect` uses egui clip +
16/// corner radius; both are available on every backend.
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum Mask {
19    Rect(Rect),
20    RoundedRect { rect: Rect, radius: u8 },
21}
22
23impl Mask {
24    pub fn rect(self) -> Rect {
25        match self {
26            Mask::Rect(r) | Mask::RoundedRect { rect: r, .. } => r,
27        }
28    }
29    pub fn radius(self) -> u8 {
30        match self {
31            Mask::Rect(_) => 0,
32            Mask::RoundedRect { radius, .. } => radius,
33        }
34    }
35}
36
37/// How to layer a guest over a host (CMP-3): which `Order`, and the surface
38/// (opacity/tint/glass) to apply, resolved under the active [`EffectsPolicy`].
39#[derive(Clone, Copy, Debug)]
40pub struct OverlaySpec {
41    pub mask: Mask,
42    pub order: Order,
43    pub surface: SurfaceSpec,
44}
45
46impl OverlaySpec {
47    /// A plain opaque overlay clipped to `rect` on the foreground layer.
48    pub fn new(rect: Rect) -> Self {
49        Self { mask: Mask::Rect(rect), order: Order::Foreground, surface: SurfaceSpec::Opaque }
50    }
51    pub fn rounded(rect: Rect, radius: u8) -> Self {
52        Self { mask: Mask::RoundedRect { rect, radius }, order: Order::Foreground, surface: SurfaceSpec::Opaque }
53    }
54    pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
55        self.surface = s;
56        self
57    }
58    pub fn with_order(mut self, o: Order) -> Self {
59        self.order = o;
60        self
61    }
62}
63
64/// **Render a guest into a masked sub-region + layer of the host** (CMP-1/2/3/5).
65/// The guest closure draws into a `Ui` clipped to the mask, on a dedicated layer
66/// so it stays interactive (input goes to the top layer at a point). Glass/tint
67/// is painted *behind* the guest, degraded per `EffectsPolicy`.
68///
69/// Returns the guest's `Response` so the host can detect interaction.
70pub fn overlay<R>(
71    host: &mut Ui,
72    spec: OverlaySpec,
73    effects: EffectsPolicy,
74    id_salt: impl std::hash::Hash,
75    guest: impl FnOnce(&mut Ui) -> R,
76) -> R {
77    let rect = spec.mask.rect();
78    let radius = CornerRadius::same(spec.mask.radius());
79    let layer = LayerId::new(spec.order, host.id().with(("facett_overlay", &id_salt_str(&id_salt))));
80
81    // Paint the glass/tint backing first (under the guest), resolved for effects.
82    let resolved = spec.surface.resolve(effects);
83    if let Some(tint) = resolved.tint_color() {
84        let painter = host.ctx().layer_painter(layer);
85        painter.rect_filled(rect, radius, tint);
86    }
87
88    // Build a child Ui on the overlay layer, clipped to the mask rect.
89    let mut result = None;
90    host.scope_builder(UiBuilder::new().layer_id(layer).max_rect(rect), |ui| {
91        ui.set_clip_rect(rect);
92        // Apply per-subtree opacity when the surface asks for it (SURF-1).
93        if let SurfaceSpec::Opacity(o) = resolved {
94            ui.set_opacity(o);
95        }
96        result = Some(guest(ui));
97    });
98    result.expect("guest closure ran")
99}
100
101/// Bring an overlay layer to the very top (CMP-3, `ctx.move_to_top`).
102pub fn raise(ui: &Ui, order: Order, id_salt: impl std::hash::Hash) {
103    let layer = LayerId::new(order, ui.id().with(("facett_overlay", &id_salt_str(&id_salt))));
104    ui.ctx().move_to_top(layer);
105}
106
107fn id_salt_str(h: &impl std::hash::Hash) -> String {
108    use std::hash::Hasher as _;
109    let mut s = std::collections::hash_map::DefaultHasher::new();
110    h.hash(&mut s);
111    format!("{:x}", s.finish())
112}
113
114/// A tinted-glass colour helper: a translucent fill from a base colour at `alpha`,
115/// strengthened on light backgrounds (SURF-4 hint). Pure so it snapshots.
116pub fn glass_tint(base: Color32, alpha: u8, on_light: bool) -> Color32 {
117    let a = if on_light { alpha.saturating_add(30) } else { alpha };
118    Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), a)
119}
120
121#[cfg(test)]
122mod tests {
123    use egui::{Color32, pos2, vec2};
124
125    use super::*;
126    use crate::look::SurfaceSpec;
127
128    #[test]
129    fn glass_degrades_to_tint_off_wgpu_and_opaque_under_none() {
130        let frosted = SurfaceSpec::Frosted { blur_radius: 8.0, tint: [10, 12, 26, 180] };
131        // Off-wgpu (Reduced allows transparency, not blur) → Tint.
132        assert_eq!(frosted.resolve(EffectsPolicy::Reduced), SurfaceSpec::Tint([10, 12, 26, 180]));
133        // Device (None) → Opaque, no glass at all (§23 / SURF-5).
134        assert_eq!(frosted.resolve(EffectsPolicy::None), SurfaceSpec::Opaque);
135    }
136
137    #[test]
138    fn glass_tint_is_stronger_on_light() {
139        let base = Color32::from_rgb(10, 12, 26);
140        let dark = glass_tint(base, 160, false);
141        let light = glass_tint(base, 160, true);
142        assert!(light.a() > dark.a(), "light backgrounds need stronger glass edges (SURF-4)");
143    }
144
145    #[test]
146    #[allow(deprecated)]
147    fn overlay_guest_renders_into_the_masked_region() {
148        // Headless: render a host, overlay a guest clipped to a sub-rect, assert the
149        // guest drew (vertices) and the host stays picturable in isolation (CMP-5).
150        let ctx = egui::Context::default();
151        crate::look::Theme::windows_dark().apply(&ctx);
152        let mut guest_ran = false;
153        let input = egui::RawInput {
154            screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
155            ..Default::default()
156        };
157        let out = ctx.run(input, |ctx| {
158            egui::CentralPanel::default().show(ctx, |ui| {
159                ui.label("host map");
160                let sub = Rect::from_min_size(pos2(400.0, 100.0), vec2(300.0, 200.0));
161                let spec = OverlaySpec::rounded(sub, 8).with_surface(SurfaceSpec::Tint([10, 12, 26, 180]));
162                overlay(ui, spec, EffectsPolicy::Full, "df", |ui| {
163                    ui.label("guest dataframe");
164                    guest_ran = true;
165                });
166            });
167        });
168        assert!(guest_ran, "guest closure ran");
169        let prims = ctx.tessellate(out.shapes, out.pixels_per_point);
170        let verts: usize = prims
171            .iter()
172            .map(|p| match &p.primitive {
173                egui::epaint::Primitive::Mesh(m) => m.vertices.len(),
174                _ => 0,
175            })
176            .sum();
177        assert!(verts > 0, "host + overlaid guest tessellate to a non-empty frame (picturable)");
178    }
179}