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!() }
	}
}