facett-core 0.1.10

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Shared native-feel paint helpers** — route the active [`Theme`](super::Theme)'s
//! [`NativeFeel`] cues through a component's paint path with **one call each**, so
//! every component crate follows the platform preset (macOS vs Windows 11 vs
//! Neutral) without copy-pasting paint logic:
//!
//! - [`reveal_on_hover`] — the Windows 11 cursor-follow **reveal highlight** on hover.
//! - [`apply_focus_ring`] — the keyboard-**focus** indicator (soft accent ring on
//!   macOS, crisp focus rectangle on Windows).
//! - [`elevation_shadow`] — a **drop-shadow** under raised surfaces, scaled by the
//!   preset's [`NativeFeel::elevation`].
//!
//! The cue → primitive mapping (which platform wants what) lives in
//! [`platform`](super::platform); this module is the *routing* layer that reads the
//! published [`NativeFeel`] + palette and drives the existing
//! [`effects`](crate::effects) primitives.
//!
//! Determinism (FC-1/FC-7): every helper is a pure function of `(NativeFeel,
//! palette, rect, pointer)` — **no egui-memory state, no wall-clock**. The `*_params`
//! functions expose the concrete paint parameters as **data** so a headless test can
//! assert that mac vs windows presets paint differently.

use egui::{Color32, Rect, Stroke, StrokeKind, Ui, Vec2, vec2};

use super::{FocusRing, NativeFeel};
use crate::effects::RevealHighlight;

const NATIVE_ID: &str = "facett_native_feel";

/// Publish the active [`NativeFeel`] on the context so components resolve the same
/// platform cues without holding a copy of the theme — the same pattern as
/// [`publish_keymap`](super::publish_keymap)/[`publish_effects`](super::publish_effects).
/// Called by [`crate::look::Theme::apply`].
pub fn publish_native(ctx: &egui::Context, feel: NativeFeel) {
    ctx.data_mut(|d| d.insert_temp(egui::Id::new(NATIVE_ID), feel));
}

/// The active [`NativeFeel`] on the ui's context, or the auto-detected default
/// (`Platform::detect().native_feel()`) if no theme has published one. The single
/// resolution point every component's native-feel paint reads.
///
/// **Readable-data side effect (COH):** every call records the platform it handed
/// out into the [`probe`] thread-local, so a headless coherence test can read back
/// *which platform preset a component actually consumed* during its paint — the
/// native-feel counterpart to [`crate::theme::probe`].
pub fn native_feel(ui: &Ui) -> NativeFeel {
    let feel = ui.data(|d| d.get_temp::<NativeFeel>(egui::Id::new(NATIVE_ID))).unwrap_or_default();
    probe::record(feel.platform);
    feel
}

/// **NativeFeel-consumption probe** — the machine-readable witness that a component
/// consumed the active platform preset through [`native_feel`]. A coherence test
/// [`reset`](probe::reset)s it, renders one component under a given [`Theme`](super::Theme),
/// then reads [`consumed`](probe::consumed) to assert the component painted with the
/// platform it was given. Thread-local (parallel test cells don't interfere).
pub mod probe {
    use std::cell::Cell;

    use crate::look::Platform;

    thread_local! {
        static CONSUMED: Cell<Option<Platform>> = const { Cell::new(None) };
    }

    /// Record the platform a [`native_feel`](super::native_feel) call handed out.
    pub fn record(p: Platform) {
        CONSUMED.with(|c| c.set(Some(p)));
    }

    /// Clear the probe before rendering a component.
    pub fn reset() {
        CONSUMED.with(|c| c.set(None));
    }

    /// The platform the last-rendered component consumed, or `None` if it never
    /// read the [`NativeFeel`](super::NativeFeel).
    pub fn consumed() -> Option<Platform> {
        CONSUMED.with(|c| c.get())
    }
}

// ── reveal highlight (Windows 11 hover cue) ──────────────────────────────────

/// Paint the Windows 11 **reveal highlight** on `rect` when the active preset asks
/// for it ([`NativeFeel::reveal_highlight`], on for the Windows preset only). Reads
/// the live pointer + the theme `glow`/`accent` role from `ui`; a no-op on
/// macOS/Neutral/Device and when the pointer is away. One call, per hovered control.
pub fn reveal_on_hover(ui: &Ui, rect: Rect, corner_radius: f32) {
    let feel = native_feel(ui);
    if !feel.reveal_highlight {
        return;
    }
    let pal = crate::theme(ui);
    let reveal = RevealHighlight::new(pal.glow).with_intensity(0.8);
    reveal.paint_in(ui, rect, corner_radius, true);
}

// ── focus ring / rectangle (keyboard-focus cue) ──────────────────────────────

/// The concrete, **testable** paint parameters a [`FocusRing`] resolves to for a
/// focused `rect` — the DATA a coherence test asserts differs mac↔windows.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FocusRingVisual {
    /// The (expanded) rect the ring/rect is stroked around.
    pub rect: Rect,
    /// Stroke colour + width.
    pub stroke: Stroke,
    /// Soft accent glow halo (macOS) vs a single crisp stroke (Windows).
    pub glow: bool,
}

/// Resolve a [`FocusRing`] to its concrete paint parameters for `rect`, tinting
/// with `accent` when [`FocusRing::accent_tinted`] (else a high-contrast outline).
/// Pure — the testable core of [`apply_focus_ring`].
pub fn focus_ring_visual(ring: &FocusRing, rect: Rect, accent: Color32, outline: Color32) -> FocusRingVisual {
    let color = if ring.accent_tinted { accent } else { outline };
    FocusRingVisual {
        rect: rect.expand(ring.expansion),
        stroke: Stroke::new(ring.width, color),
        glow: ring.glow,
    }
}

/// Paint the keyboard-**focus** indicator around `rect` when `focused` — the active
/// preset's [`FocusRing`] (soft accent ring on macOS, crisp rectangle on Windows).
/// The crisp stroke always paints (focus is an accessibility signal); the soft glow
/// halo is added only when the theme allows decorative motion. Reads the accent +
/// outline roles from the published palette. No-op when `!focused`.
pub fn apply_focus_ring(ui: &Ui, rect: Rect, focused: bool, corner_radius: f32) {
    if !focused {
        return;
    }
    let feel = native_feel(ui);
    let pal = crate::theme(ui);
    let vis = focus_ring_visual(&feel.focus_ring, rect, pal.accent, pal.text);
    let painter = ui.painter();
    if vis.glow && crate::look::effects_policy(ui).allows_decorative_motion() {
        // macOS halo: a soft accent glow around the crisp ring.
        crate::effects::glow_rect(painter, vis.rect, vis.stroke.color, 0.8, feel.focus_ring.width.max(2.0) as u32);
    }
    painter.rect_stroke(vis.rect, corner_radius + feel.focus_ring.expansion, vis.stroke, StrokeKind::Outside);
}

// ── elevation (raised-surface drop shadow) ───────────────────────────────────

/// The concrete drop-shadow parameters an [`elevation`](NativeFeel::elevation)
/// strength resolves to — the DATA a coherence test asserts differs mac↔windows
/// (Windows 11 elevation is pronounced, macOS restrained).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ElevationShadow {
    /// Downward offset of the shadow, px.
    pub offset: Vec2,
    /// How far the shadow spreads past the surface, px.
    pub spread: f32,
    /// Peak shadow alpha at the surface edge.
    pub alpha: u8,
}

/// Resolve a [`NativeFeel::elevation`] strength `∈[0,1]` to concrete shadow params.
/// Pure — the testable core of [`elevation_shadow`]. Zero elevation ⇒ no shadow.
pub fn elevation_shadow_params(elevation: f32) -> ElevationShadow {
    let e = elevation.clamp(0.0, 1.0);
    ElevationShadow {
        offset: vec2(0.0, 1.0 + e * 6.0),
        spread: 2.0 + e * 10.0,
        alpha: (e * 130.0) as u8,
    }
}

/// Paint a **drop-shadow** under the raised surface `rect`, scaled by the active
/// preset's [`NativeFeel::elevation`] (pronounced on Windows, restrained on macOS).
/// Layered translucent rounded rects (glow backend — no GPU). Gated on the theme
/// allowing transparency, so Device stays flat. Call **before** filling the surface.
pub fn elevation_shadow(ui: &Ui, rect: Rect, corner_radius: f32) {
    let feel = native_feel(ui);
    if !crate::look::effects_policy(ui).allows_transparency() {
        return;
    }
    let sh = elevation_shadow_params(feel.elevation);
    if sh.alpha == 0 {
        return;
    }
    let painter = ui.painter();
    let base = rect.translate(sh.offset);
    let layers = 5u32;
    for i in 0..layers {
        let f = i as f32 / layers as f32; // 0 (tight) .. ~1 (outer)
        let grow = f * sh.spread;
        let a = ((1.0 - f) * sh.alpha as f32) as u8;
        if a == 0 {
            continue;
        }
        painter.rect_filled(base.expand(grow), corner_radius + grow, Color32::from_black_alpha(a));
    }
}

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

    use super::*;
    use crate::look::Platform;

    #[test]
    fn focus_ring_visual_differs_mac_vs_windows() {
        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
        let accent = Color32::from_rgb(80, 160, 255);
        let outline = Color32::GRAY;
        let mac = focus_ring_visual(&Platform::Mac.native_feel().focus_ring, rect, accent, outline);
        let win = focus_ring_visual(&Platform::Windows.native_feel().focus_ring, rect, accent, outline);
        // macOS: a soft, wider, glowing halo that expands further from the control.
        assert!(mac.glow && !win.glow, "mac ring glows; windows is a crisp rect");
        assert!(mac.stroke.width > win.stroke.width, "mac ring is wider ({} vs {})", mac.stroke.width, win.stroke.width);
        assert!(mac.rect.width() > win.rect.width(), "mac halo expands further than the windows hug");
        // Both tint with the accent (per the preset data).
        assert_eq!(mac.stroke.color, accent);
        assert_eq!(win.stroke.color, accent);
    }

    #[test]
    fn elevation_shadow_is_heavier_on_windows_than_mac() {
        let mac = elevation_shadow_params(Platform::Mac.native_feel().elevation); // 0.30
        let win = elevation_shadow_params(Platform::Windows.native_feel().elevation); // 0.70
        assert!(win.alpha > mac.alpha, "windows elevation is darker: {} vs {}", win.alpha, mac.alpha);
        assert!(win.offset.y > mac.offset.y, "windows shadow drops further");
        assert!(win.spread > mac.spread, "windows shadow spreads wider");
        // Zero elevation ⇒ no shadow at all (Device stays flat).
        assert_eq!(elevation_shadow_params(0.0).alpha, 0);
    }

    #[test]
    fn native_feel_publish_round_trips_through_ctx() {
        let ctx = egui::Context::default();
        publish_native(&ctx, NativeFeel::windows());
        #[allow(deprecated)]
        let _ = ctx.run(egui::RawInput::default(), |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                let feel = native_feel(ui);
                assert_eq!(feel.platform, Platform::Windows);
                assert!(feel.reveal_highlight, "windows preset routed through the ctx");
            });
        });
    }
}