facett-core 0.1.6

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Composability & overlays** (§5) + **glass / masks** (§20) — render any facett
//! component into an arbitrary sub-`Rect`/layer of any other (a dataframe pinned
//! over a map, a HUD over video), clipped by a **mask**, with optional glass
//! gated by [`EffectsPolicy`](crate::look::EffectsPolicy).
//!
//! egui has **no native backdrop blur** (only `Shadow` blurs), so `Frosted`
//! degrades to `Tint` off-wgpu (SURF-2). All masks here are `Rect`/`RoundedRect`
//! via clipping (always available); arbitrary `Path`-stencil masks are the
//! wgpu-only extension (SURF-3, M3).

use egui::{Color32, CornerRadius, LayerId, Order, Rect, Ui, UiBuilder};

use crate::look::{EffectsPolicy, SurfaceSpec};

/// A clip mask for a partial overlay (SURF-3). `RoundedRect` uses egui clip +
/// corner radius; both are available on every backend.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mask {
    Rect(Rect),
    RoundedRect { rect: Rect, radius: u8 },
}

impl Mask {
    pub fn rect(self) -> Rect {
        match self {
            Mask::Rect(r) | Mask::RoundedRect { rect: r, .. } => r,
        }
    }
    pub fn radius(self) -> u8 {
        match self {
            Mask::Rect(_) => 0,
            Mask::RoundedRect { radius, .. } => radius,
        }
    }
}

/// How to layer a guest over a host (CMP-3): which `Order`, and the surface
/// (opacity/tint/glass) to apply, resolved under the active [`EffectsPolicy`].
#[derive(Clone, Copy, Debug)]
pub struct OverlaySpec {
    pub mask: Mask,
    pub order: Order,
    pub surface: SurfaceSpec,
}

impl OverlaySpec {
    /// A plain opaque overlay clipped to `rect` on the foreground layer.
    pub fn new(rect: Rect) -> Self {
        Self { mask: Mask::Rect(rect), order: Order::Foreground, surface: SurfaceSpec::Opaque }
    }
    pub fn rounded(rect: Rect, radius: u8) -> Self {
        Self { mask: Mask::RoundedRect { rect, radius }, order: Order::Foreground, surface: SurfaceSpec::Opaque }
    }
    pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
        self.surface = s;
        self
    }
    pub fn with_order(mut self, o: Order) -> Self {
        self.order = o;
        self
    }
}

/// **Render a guest into a masked sub-region + layer of the host** (CMP-1/2/3/5).
/// The guest closure draws into a `Ui` clipped to the mask, on a dedicated layer
/// so it stays interactive (input goes to the top layer at a point). Glass/tint
/// is painted *behind* the guest, degraded per `EffectsPolicy`.
///
/// Returns the guest's `Response` so the host can detect interaction.
pub fn overlay<R>(
    host: &mut Ui,
    spec: OverlaySpec,
    effects: EffectsPolicy,
    id_salt: impl std::hash::Hash,
    guest: impl FnOnce(&mut Ui) -> R,
) -> R {
    let rect = spec.mask.rect();
    let radius = CornerRadius::same(spec.mask.radius());
    let layer = LayerId::new(spec.order, host.id().with(("facett_overlay", &id_salt_str(&id_salt))));

    // Paint the glass/tint backing first (under the guest), resolved for effects.
    let resolved = spec.surface.resolve(effects);
    if let Some(tint) = resolved.tint_color() {
        let painter = host.ctx().layer_painter(layer);
        painter.rect_filled(rect, radius, tint);
    }

    // Build a child Ui on the overlay layer, clipped to the mask rect.
    let mut result = None;
    host.scope_builder(UiBuilder::new().layer_id(layer).max_rect(rect), |ui| {
        ui.set_clip_rect(rect);
        // Apply per-subtree opacity when the surface asks for it (SURF-1).
        if let SurfaceSpec::Opacity(o) = resolved {
            ui.set_opacity(o);
        }
        result = Some(guest(ui));
    });
    result.expect("guest closure ran")
}

/// Bring an overlay layer to the very top (CMP-3, `ctx.move_to_top`).
pub fn raise(ui: &Ui, order: Order, id_salt: impl std::hash::Hash) {
    let layer = LayerId::new(order, ui.id().with(("facett_overlay", &id_salt_str(&id_salt))));
    ui.ctx().move_to_top(layer);
}

fn id_salt_str(h: &impl std::hash::Hash) -> String {
    use std::hash::Hasher as _;
    let mut s = std::collections::hash_map::DefaultHasher::new();
    h.hash(&mut s);
    format!("{:x}", s.finish())
}

/// A tinted-glass colour helper: a translucent fill from a base colour at `alpha`,
/// strengthened on light backgrounds (SURF-4 hint). Pure so it snapshots.
pub fn glass_tint(base: Color32, alpha: u8, on_light: bool) -> Color32 {
    let a = if on_light { alpha.saturating_add(30) } else { alpha };
    Color32::from_rgba_unmultiplied(base.r(), base.g(), base.b(), a)
}

#[cfg(test)]
mod tests {
    use egui::{Color32, pos2, vec2};

    use super::*;
    use crate::look::SurfaceSpec;

    #[test]
    fn glass_degrades_to_tint_off_wgpu_and_opaque_under_none() {
        let frosted = SurfaceSpec::Frosted { blur_radius: 8.0, tint: [10, 12, 26, 180] };
        // Off-wgpu (Reduced allows transparency, not blur) → Tint.
        assert_eq!(frosted.resolve(EffectsPolicy::Reduced), SurfaceSpec::Tint([10, 12, 26, 180]));
        // Device (None) → Opaque, no glass at all (§23 / SURF-5).
        assert_eq!(frosted.resolve(EffectsPolicy::None), SurfaceSpec::Opaque);
    }

    #[test]
    fn glass_tint_is_stronger_on_light() {
        let base = Color32::from_rgb(10, 12, 26);
        let dark = glass_tint(base, 160, false);
        let light = glass_tint(base, 160, true);
        assert!(light.a() > dark.a(), "light backgrounds need stronger glass edges (SURF-4)");
    }

    #[test]
    #[allow(deprecated)]
    fn overlay_guest_renders_into_the_masked_region() {
        // Headless: render a host, overlay a guest clipped to a sub-rect, assert the
        // guest drew (vertices) and the host stays picturable in isolation (CMP-5).
        let ctx = egui::Context::default();
        crate::look::Theme::windows_dark().apply(&ctx);
        let mut guest_ran = false;
        let input = egui::RawInput {
            screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
            ..Default::default()
        };
        let out = ctx.run(input, |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                ui.label("host map");
                let sub = Rect::from_min_size(pos2(400.0, 100.0), vec2(300.0, 200.0));
                let spec = OverlaySpec::rounded(sub, 8).with_surface(SurfaceSpec::Tint([10, 12, 26, 180]));
                overlay(ui, spec, EffectsPolicy::Full, "df", |ui| {
                    ui.label("guest dataframe");
                    guest_ran = true;
                });
            });
        });
        assert!(guest_ran, "guest closure ran");
        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, "host + overlaid guest tessellate to a non-empty frame (picturable)");
    }
}