Skip to main content

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}