First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

193
src/theme/shadow.rs Normal file
View 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!() }
}
}