First commit. Version 0.1.0
This commit is contained in:
193
src/theme/shadow.rs
Normal file
193
src/theme/shadow.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
// 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!() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user