// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Shadow primitives: outer drop shadows, inner inset shadows, and the //! blend modes used to composite them. //! //! # Units //! //! [`Shadow::blur`] and [`InsetShadow::blur`] store the **CSS blur radius**, //! not the SVG `stdDeviation`. The relationship is `blur = 2 × stdDeviation`, //! which is what browsers compute for `box-shadow: … blur …`. The shader //! integrates against `sigma`, so it applies `sigma = blur / 2` internally //! (see [`Shadow::sigma`]). //! //! # Order //! //! A theme's `shadows` list is stored **back-to-front**, mirroring SVG's //! `feBlend` stacking order. The first entry is painted first (lowest layer), //! the last entry is painted last (topmost). This is the inverse of CSS //! `box-shadow` string order. Documented here so the renderer loop (`for //! shadow in shadows { ... }`) produces the visually correct result without //! reversing. use crate::types::Color; // ─── Blend modes ───────────────────────────────────────────────────────────── /// How a shadow composites against the layers below it. /// /// All modes assume **premultiplied** colour and alpha. The GPU pipeline is /// expected to be premul-correct; the software pipeline must premultiply /// before applying these formulas. #[ derive( Debug, Clone, Copy, PartialEq, Eq ) ] pub enum BlendMode { /// Standard `src-over`: `result = src + dst × (1 − src.a)`. Normal, /// CSS `plus-lighter`: `result = min(1, src + dst)`, channel-wise on /// premultiplied values. Adds light; never darkens. PlusLighter, /// Overlay (multiply on dark base, screen on light base). Preserves the /// base's luminance while pushing local contrast. Overlay, /// Multiplicative blend: `result = src × dst`. Only darkens. Multiply, /// Screen blend: `result = 1 − (1 − src) × (1 − dst)`. Only lightens. Screen, } impl Default for BlendMode { fn default() -> Self { BlendMode::Normal } } // ─── Outer shadow ──────────────────────────────────────────────────────────── /// An outer drop shadow cast by a shape. /// /// Modelled after CSS `box-shadow`: an offset, a blur radius, an optional /// spread that dilates (positive) or erodes (negative) the silhouette before /// blurring, a colour, and a blend mode. #[ derive( Debug, Clone, Copy, PartialEq ) ] pub struct Shadow { /// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward. pub offset: [f32; 2], /// CSS blur radius in pixels (2 × SVG `stdDeviation`). pub blur: f32, /// Spread in CSS pixels. Positive values dilate the silhouette before /// blurring; negative values erode it. Usually `0.0`. pub spread: f32, /// Shadow colour, including alpha. pub color: Color, /// Compositing mode. Most drop shadows use [`BlendMode::Normal`]. pub blend: BlendMode, } impl Shadow { /// Gaussian sigma derived from the CSS blur radius. /// /// The shader integrates Gaussian kernels against `sigma`, but the public /// field stores the CSS blur radius for parity with CSS `box-shadow`. /// The relationship is `sigma = blur / 2`. pub fn sigma( &self ) -> f32 { self.blur * 0.5 } } // ─── Inner shadow ──────────────────────────────────────────────────────────── /// An inset shadow: a shadow painted **inside** the shape's silhouette, as /// opposed to the outer drop shadow cast behind it. /// /// Structurally identical to [`Shadow`]; kept as a separate type so the /// renderer and theme JSON can't accidentally treat an inset as an outer /// (and vice versa) — the dispatch is at the type level. #[ derive( Debug, Clone, Copy, PartialEq ) ] pub struct InsetShadow { /// `[dx, dy]` offset in CSS pixels. Positive `dy` is downward. pub offset: [f32; 2], /// CSS blur radius in pixels (2 × SVG `stdDeviation`). pub blur: f32, /// Spread in CSS pixels. pub spread: f32, /// Shadow colour, including alpha. pub color: Color, /// Compositing mode against the layers below. Insets routinely use /// non-`Normal` modes (`PlusLighter` for highlights, `Overlay` for rim). pub blend: BlendMode, } impl InsetShadow { /// Gaussian sigma derived from the CSS blur radius. See [`Shadow::sigma`]. pub fn sigma( &self ) -> f32 { self.blur * 0.5 } } // ─── Shadow reference ──────────────────────────────────────────────────────── /// How a [`crate::theme::Surface`] refers to its outer shadow stack: either /// by name (reused across several surfaces — the common case for elevation /// tokens) or inline (one-off, uncommon). #[ derive( Debug, Clone, PartialEq ) ] pub enum ShadowsRef { /// Reference to another slot in the theme, by id. Named( String ), /// The shadow list, carried inline. Used when the surface is exotic /// enough that the stack isn't worth a dedicated slot. Inline( Vec ), } // ─── Tests ─────────────────────────────────────────────────────────────────── #[ cfg( test ) ] mod tests { use super::*; #[ test ] fn sigma_is_half_of_css_blur() { // CSS blur 4 → SVG stdDev 2. The shader needs sigma == stdDev == 2. let s = Shadow { offset: [ 0.0, 2.0 ], blur: 4.0, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.04 ), blend: BlendMode::Normal, }; assert_eq!( s.sigma(), 2.0 ); } #[ test ] fn inset_sigma_follows_same_convention() { let i = InsetShadow { offset: [ -3.6, -3.6 ], blur: 13.5, spread: 0.0, color: Color::hex( 0x55, 0x55, 0x55 ), blend: BlendMode::PlusLighter, }; assert!( ( i.sigma() - 6.75 ).abs() < 1e-6 ); } #[ test ] fn default_blend_mode_is_normal() { assert_eq!( BlendMode::default(), BlendMode::Normal ); } #[ test ] fn shadows_ref_distinguishes_named_and_inline() { let n = ShadowsRef::Named( "shadows-2".to_string() ); let i = ShadowsRef::Inline( vec! [ Shadow { offset: [ 0.0, 4.0 ], blur: 10.0, spread: 0.0, color: Color::rgba( 0.0, 0.0, 0.0, 0.08 ), blend: BlendMode::Normal, }, ]); match n { ShadowsRef::Named( ref s ) => assert_eq!( s, "shadows-2" ), _ => panic!() } match i { ShadowsRef::Inline( v ) => assert_eq!( v.len(), 1 ), _ => panic!() } } }