194 lines
6.6 KiB
Rust
194 lines
6.6 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! 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<Shadow> ),
|
||
}
|
||
|
||
// ─── 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!() }
|
||
}
|
||
}
|