ltk/theme/shadow.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 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!() }
}
}