teksilo_widgets/shadow.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Layered drop-shadow helper for elevated surfaces.
5//!
6//! Composes two [`Shadow`]s underneath a rounded rect:
7//! - `outer` — the wide soft halo (typically `theme.shape.shadow_*`).
8//! - `inner` — the sharp short-blur rim that gives the surface a clearly
9//! "lifted" edge instead of a vague glow (typically the matching
10//! `theme.shape.shadow_inner_*`).
11//!
12//! The `inner` token's geometry (`offset_y`, `blur`, `color.rgb`) is used
13//! verbatim. Only `color.a` is modulated: the painted alpha is
14//! `density × inner.color.a()`, with `density ∈ [0.0, 1.0]` provided by
15//! the per-component `shadow_density` field. This keeps every visual
16//! knob in the theme while letting individual surfaces dial intensity.
17//!
18//! Common density presets:
19//! - `1.0` — tooltips (full inner-rim alpha, punchy "lift").
20//! - `~0.5` — cards, popovers, menus (moderate).
21//! - `0.0` — disable inner rim entirely (single-layer outer only).
22//!
23//! ## Attached side
24//!
25//! Popovers, menus and combo-box dropdowns sit *attached* to the widget
26//! that opened them. On the side that touches the trigger, drawing a
27//! halo would visually cut the surface off from its anchor. Pass an
28//! [`AttachedSide`] to suppress shadow on that side.
29//!
30//! ```ignore
31//! // Typical usage inside a custom widget's paint() method:
32//! use teksilo_widgets::shadow::{paint_layered_shadow, DENSITY_SURFACE};
33//! paint_layered_shadow(
34//! canvas, bounds, radius,
35//! &ctx.theme.shape.shadow_sm,
36//! &ctx.theme.shape.shadow_inner_sm,
37//! DENSITY_SURFACE,
38//! None,
39//! );
40//! ```
41
42use teksilo_canvas::{Canvas, Rect};
43use teksilo_tokens::{Color, CornerRadius, Shadow};
44
45/// Inner-rim alpha multiplier for tooltips — full intensity for maximum lift.
46pub const DENSITY_TOOLTIP: f32 = 1.0;
47/// Inner-rim alpha multiplier for cards, popovers, and menus — moderate lift.
48pub const DENSITY_SURFACE: f32 = 0.5;
49/// Inner-rim alpha multiplier for snackbars and dialogs — subtle lift.
50pub const DENSITY_DIALOG: f32 = 0.3;
51
52/// Sub-perceptual alpha cutoff: below this no human eye registers the
53/// difference even on a fresh CRT, and the GPU cost is the same as a
54/// fully-opaque draw. Used to short-circuit invisible shadow draws.
55const SUB_PERCEPTUAL: f32 = 1.0 / 255.0;
56
57/// Which geometric edge of the surface is attached to its trigger and
58/// should have shadow drawing suppressed on that side. Geometric (Top
59/// / Bottom / Left / Right), not RTL-aware — callers working in
60/// Leading/Trailing terms must resolve to a geometric side using the
61/// active layout direction before calling.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum AttachedSide {
64 /// Suppress the shadow halo on the top edge (e.g. a dropdown opening downward).
65 Top,
66 /// Suppress the shadow halo on the bottom edge (e.g. a popover opening upward).
67 Bottom,
68 /// Suppress the shadow halo on the left edge.
69 Left,
70 /// Suppress the shadow halo on the right edge.
71 Right,
72}
73
74/// Paint a two-layer drop shadow behind a rounded rect.
75///
76/// The `outer` shadow is drawn unchanged. If `density × inner.color.a()`
77/// is above the sub-perceptual threshold (1/255), the `inner` shadow is
78/// drawn on top with its alpha scaled by `density`. This gives a "lift"
79/// look — a wide soft halo with a sharp close rim.
80///
81/// When `attached` is `Some(side)`, both shadow draws are clipped so
82/// the penumbra on that side is hidden — matching the visual where
83/// the surface is attached to its anchor (popover under its trigger,
84/// dropdown under its combo box, etc.).
85///
86/// If both layers would be sub-perceptual (e.g. theme has zero alphas
87/// or `density` of 0), this function returns without emitting any draw
88/// commands.
89///
90/// ```ignore
91/// // In a widget's paint() method:
92/// use teksilo_widgets::shadow::{paint_layered_shadow, AttachedSide, DENSITY_SURFACE};
93/// paint_layered_shadow(
94/// canvas, bounds, radius,
95/// &ctx.theme.shape.shadow_sm, &ctx.theme.shape.shadow_inner_sm,
96/// DENSITY_SURFACE, None,
97/// );
98/// ```
99pub fn paint_layered_shadow(
100 canvas: &mut Canvas,
101 bounds: Rect,
102 radius: CornerRadius,
103 outer: &Shadow,
104 inner: &Shadow,
105 density: f32,
106 attached: Option<AttachedSide>,
107) {
108 let density = density.clamp(0.0, 1.0);
109 let inner_alpha = density * inner.color.a();
110 let outer_visible = outer.color.a() >= SUB_PERCEPTUAL;
111 let inner_visible = inner_alpha >= SUB_PERCEPTUAL;
112 if !outer_visible && !inner_visible {
113 return;
114 }
115
116 let clip = attached.map(|s| suppress_clip(bounds, outer, inner, s));
117 if let Some(c) = clip {
118 canvas.set_clip(c);
119 }
120
121 if outer_visible {
122 canvas.draw_shadow(bounds, radius, outer);
123 }
124 if inner_visible {
125 let scaled_inner = Shadow {
126 color: Color::new(
127 inner.color.r(),
128 inner.color.g(),
129 inner.color.b(),
130 inner_alpha.min(1.0),
131 ),
132 ..*inner
133 };
134 canvas.draw_shadow(bounds, radius, &scaled_inner);
135 }
136
137 if clip.is_some() {
138 canvas.clear_clip();
139 }
140}
141
142/// Build a clip rect that includes everything around `bounds` that
143/// shadow could reach EXCEPT the attached side. Each non-attached
144/// side uses the directional shadow extent for that axis (a `Top`
145/// suppression doesn't need slack on the X axis from `offset_y`, and
146/// vice versa), so the scissor stays as tight as possible while still
147/// admitting the full penumbra on every drawn side.
148fn suppress_clip(bounds: Rect, outer: &Shadow, inner: &Shadow, side: AttachedSide) -> Rect {
149 // +1 dp gives the shader's anti-aliased Gaussian falloff a clean
150 // edge to fade against; without it the cut can show a 1 px banding
151 // line along the suppressed edge.
152 let extent_left = max_extent(outer, inner, Direction::Left) + 1.0;
153 let extent_right = max_extent(outer, inner, Direction::Right) + 1.0;
154 let extent_top = max_extent(outer, inner, Direction::Up) + 1.0;
155 let extent_bottom = max_extent(outer, inner, Direction::Down) + 1.0;
156
157 let l = bounds.x - extent_left;
158 let r = bounds.x + bounds.width + extent_right;
159 let t = bounds.y - extent_top;
160 let b = bounds.y + bounds.height + extent_bottom;
161 match side {
162 AttachedSide::Top => Rect::new(l, bounds.y, r - l, b - bounds.y),
163 AttachedSide::Bottom => {
164 let bot = bounds.y + bounds.height;
165 Rect::new(l, t, r - l, bot - t)
166 }
167 AttachedSide::Left => Rect::new(bounds.x, t, r - bounds.x, b - t),
168 AttachedSide::Right => {
169 let right = bounds.x + bounds.width;
170 Rect::new(l, t, right - l, b - t)
171 }
172 }
173}
174
175#[derive(Clone, Copy)]
176enum Direction {
177 Up,
178 Down,
179 Left,
180 Right,
181}
182
183/// How far a single shadow's penumbra reaches past `bounds` in one
184/// direction. The shadow's `offset_*` shifts the quad as a whole, so
185/// a positive `offset_y` *reduces* the upward reach and *increases*
186/// the downward one (and similarly for X).
187fn shadow_extent_in(shadow: &Shadow, dir: Direction) -> f32 {
188 let blur_spread = shadow.blur + shadow.spread;
189 match dir {
190 Direction::Up => (blur_spread - shadow.offset_y).max(0.0),
191 Direction::Down => (blur_spread + shadow.offset_y).max(0.0),
192 Direction::Left => (blur_spread - shadow.offset_x).max(0.0),
193 Direction::Right => (blur_spread + shadow.offset_x).max(0.0),
194 }
195}
196
197fn max_extent(a: &Shadow, b: &Shadow, dir: Direction) -> f32 {
198 shadow_extent_in(a, dir).max(shadow_extent_in(b, dir))
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use teksilo_canvas::{DrawCommand, RenderFrame};
205
206 fn capture_frame<F: FnOnce(&mut Canvas)>(f: F) -> RenderFrame {
207 let mut canvas = Canvas::new();
208 f(&mut canvas);
209 canvas.into_render_frame()
210 }
211
212 fn shadow_count(frame: &RenderFrame) -> usize {
213 frame
214 .draw_order
215 .iter()
216 .filter(|c| matches!(c, DrawCommand::Shadow(_)))
217 .count()
218 }
219
220 fn clip_count(frame: &RenderFrame) -> usize {
221 frame
222 .draw_order
223 .iter()
224 .filter(|c| matches!(c, DrawCommand::SetClip(_)))
225 .count()
226 }
227
228 #[test]
229 fn full_density_emits_two_shadows_no_clip() {
230 let theme = teksilo_core::presets::intui::light();
231 let frame = capture_frame(|c| {
232 paint_layered_shadow(
233 c,
234 Rect::new(10.0, 10.0, 100.0, 50.0),
235 CornerRadius::uniform(8.0),
236 &theme.shape.shadow_xs,
237 &theme.shape.shadow_inner_xs,
238 1.0,
239 None,
240 );
241 });
242 assert_eq!(shadow_count(&frame), 2, "outer + inner expected");
243 assert_eq!(clip_count(&frame), 0, "no suppression ⇒ no clip");
244 }
245
246 #[test]
247 fn zero_density_skips_inner() {
248 let theme = teksilo_core::presets::intui::light();
249 let frame = capture_frame(|c| {
250 paint_layered_shadow(
251 c,
252 Rect::new(0.0, 0.0, 50.0, 50.0),
253 CornerRadius::uniform(4.0),
254 &theme.shape.shadow_xs,
255 &theme.shape.shadow_inner_xs,
256 0.0,
257 None,
258 );
259 });
260 assert_eq!(shadow_count(&frame), 1, "only outer drawn at density=0");
261 }
262
263 #[test]
264 fn attached_side_emits_clip() {
265 let theme = teksilo_core::presets::intui::light();
266 for side in [
267 AttachedSide::Top,
268 AttachedSide::Bottom,
269 AttachedSide::Left,
270 AttachedSide::Right,
271 ] {
272 let frame = capture_frame(|c| {
273 paint_layered_shadow(
274 c,
275 Rect::new(10.0, 10.0, 100.0, 50.0),
276 CornerRadius::uniform(8.0),
277 &theme.shape.shadow_xs,
278 &theme.shape.shadow_inner_xs,
279 1.0,
280 Some(side),
281 );
282 });
283 assert_eq!(clip_count(&frame), 1, "{:?} should emit one SetClip", side);
284 assert!(
285 frame
286 .draw_order
287 .iter()
288 .any(|c| matches!(c, DrawCommand::ClearClip)),
289 "{:?} should emit a matching ClearClip",
290 side,
291 );
292 }
293 }
294
295 #[test]
296 fn top_clip_excludes_top_penumbra_only() {
297 // A shadow with offset=0 reaches `blur` past every side. A Top
298 // suppression must keep the body in the clip and exclude the
299 // region above it.
300 let theme = teksilo_core::presets::intui::light();
301 let bounds = Rect::new(10.0, 100.0, 80.0, 40.0);
302 let frame = capture_frame(|c| {
303 paint_layered_shadow(
304 c,
305 bounds,
306 CornerRadius::uniform(4.0),
307 &theme.shape.shadow_xs,
308 &theme.shape.shadow_inner_xs,
309 1.0,
310 Some(AttachedSide::Top),
311 );
312 });
313 let clip = frame
314 .draw_order
315 .iter()
316 .find_map(|c| match c {
317 DrawCommand::SetClip(r) => Some(*r),
318 _ => None,
319 })
320 .expect("clip command present");
321 assert!(
322 (clip.y - bounds.y).abs() < 0.001,
323 "Top clip must start at body's top edge, got {:?}",
324 clip,
325 );
326 assert!(
327 clip.y + clip.height >= bounds.y + bounds.height,
328 "clip must include body bottom"
329 );
330 assert!(clip.x <= bounds.x, "clip must include body left side");
331 assert!(
332 clip.x + clip.width >= bounds.x + bounds.width,
333 "clip must include body right side"
334 );
335 }
336
337 #[test]
338 fn fully_invisible_shadow_emits_nothing() {
339 // Both alphas zero ⇒ no draw commands at all.
340 let zero = Shadow {
341 color: Color::new(0.0, 0.0, 0.0, 0.0),
342 ..Default::default()
343 };
344 let frame = capture_frame(|c| {
345 paint_layered_shadow(
346 c,
347 Rect::new(0.0, 0.0, 50.0, 50.0),
348 CornerRadius::uniform(4.0),
349 &zero,
350 &zero,
351 1.0,
352 Some(AttachedSide::Top),
353 );
354 });
355 assert_eq!(shadow_count(&frame), 0);
356 assert_eq!(clip_count(&frame), 0, "no clip needed when nothing draws");
357 }
358
359 #[test]
360 fn directional_extent_respects_offset() {
361 // Offset y = +blur + 1: shadow shifts down enough that nothing
362 // pokes past the top edge. shadow_extent_in(Up) should clamp
363 // to zero. shadow_extent_in(Down) should be 2*blur + 1.
364 let s = Shadow {
365 blur: 10.0,
366 spread: 0.0,
367 offset_y: 11.0,
368 color: Color::new(0.0, 0.0, 0.0, 0.5),
369 ..Default::default()
370 };
371 assert_eq!(shadow_extent_in(&s, Direction::Up), 0.0);
372 assert!((shadow_extent_in(&s, Direction::Down) - 21.0).abs() < 0.001);
373 assert!((shadow_extent_in(&s, Direction::Left) - 10.0).abs() < 0.001);
374 assert!((shadow_extent_in(&s, Direction::Right) - 10.0).abs() < 0.001);
375 }
376}