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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
//! Layered drop-shadow helper for elevated surfaces.
//!
//! Composes two [`Shadow`]s underneath a rounded rect:
//! - `outer` — the wide soft halo (typically `theme.shape.shadow_*`).
//! - `inner` — the sharp short-blur rim that gives the surface a clearly
//! "lifted" edge instead of a vague glow (typically the matching
//! `theme.shape.shadow_inner_*`).
//!
//! The `inner` token's geometry (`offset_y`, `blur`, `color.rgb`) is used
//! verbatim. Only `color.a` is modulated: the painted alpha is
//! `density × inner.color.a()`, with `density ∈ [0.0, 1.0]` provided by
//! the per-component `shadow_density` field. This keeps every visual
//! knob in the theme while letting individual surfaces dial intensity.
//!
//! Common density presets:
//! - `1.0` — tooltips (full inner-rim alpha, punchy "lift").
//! - `~0.5` — cards, popovers, menus (moderate).
//! - `0.0` — disable inner rim entirely (single-layer outer only).
//!
//! ## Attached side
//!
//! Popovers, menus and combo-box dropdowns sit *attached* to the widget
//! that opened them. On the side that touches the trigger, drawing a
//! halo would visually cut the surface off from its anchor. Pass an
//! [`AttachedSide`] to suppress shadow on that side.
//!
//! ```ignore
//! // Typical usage inside a custom widget's paint() method:
//! use teksilo_widgets::shadow::{paint_layered_shadow, DENSITY_SURFACE};
//! paint_layered_shadow(
//! canvas, bounds, radius,
//! &ctx.theme.shape.shadow_sm,
//! &ctx.theme.shape.shadow_inner_sm,
//! DENSITY_SURFACE,
//! None,
//! );
//! ```
use teksilo_canvas::{Canvas, Rect};
use teksilo_tokens::{Color, CornerRadius, Shadow};
/// Inner-rim alpha multiplier for tooltips — full intensity for maximum lift.
pub const DENSITY_TOOLTIP: f32 = 1.0;
/// Inner-rim alpha multiplier for cards, popovers, and menus — moderate lift.
pub const DENSITY_SURFACE: f32 = 0.5;
/// Inner-rim alpha multiplier for snackbars and dialogs — subtle lift.
pub const DENSITY_DIALOG: f32 = 0.3;
/// Sub-perceptual alpha cutoff: below this no human eye registers the
/// difference even on a fresh CRT, and the GPU cost is the same as a
/// fully-opaque draw. Used to short-circuit invisible shadow draws.
const SUB_PERCEPTUAL: f32 = 1.0 / 255.0;
/// Which geometric edge of the surface is attached to its trigger and
/// should have shadow drawing suppressed on that side. Geometric (Top
/// / Bottom / Left / Right), not RTL-aware — callers working in
/// Leading/Trailing terms must resolve to a geometric side using the
/// active layout direction before calling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachedSide {
/// Suppress the shadow halo on the top edge (e.g. a dropdown opening downward).
Top,
/// Suppress the shadow halo on the bottom edge (e.g. a popover opening upward).
Bottom,
/// Suppress the shadow halo on the left edge.
Left,
/// Suppress the shadow halo on the right edge.
Right,
}
/// Paint a two-layer drop shadow behind a rounded rect.
///
/// The `outer` shadow is drawn unchanged. If `density × inner.color.a()`
/// is above the sub-perceptual threshold (1/255), the `inner` shadow is
/// drawn on top with its alpha scaled by `density`. This gives a "lift"
/// look — a wide soft halo with a sharp close rim.
///
/// When `attached` is `Some(side)`, both shadow draws are clipped so
/// the penumbra on that side is hidden — matching the visual where
/// the surface is attached to its anchor (popover under its trigger,
/// dropdown under its combo box, etc.).
///
/// If both layers would be sub-perceptual (e.g. theme has zero alphas
/// or `density` of 0), this function returns without emitting any draw
/// commands.
///
/// ```ignore
/// // In a widget's paint() method:
/// use teksilo_widgets::shadow::{paint_layered_shadow, AttachedSide, DENSITY_SURFACE};
/// paint_layered_shadow(
/// canvas, bounds, radius,
/// &ctx.theme.shape.shadow_sm, &ctx.theme.shape.shadow_inner_sm,
/// DENSITY_SURFACE, None,
/// );
/// ```
pub fn paint_layered_shadow(
canvas: &mut Canvas,
bounds: Rect,
radius: CornerRadius,
outer: &Shadow,
inner: &Shadow,
density: f32,
attached: Option<AttachedSide>,
) {
let density = density.clamp(0.0, 1.0);
let inner_alpha = density * inner.color.a();
let outer_visible = outer.color.a() >= SUB_PERCEPTUAL;
let inner_visible = inner_alpha >= SUB_PERCEPTUAL;
if !outer_visible && !inner_visible {
return;
}
let clip = attached.map(|s| suppress_clip(bounds, outer, inner, s));
if let Some(c) = clip {
canvas.set_clip(c);
}
if outer_visible {
canvas.draw_shadow(bounds, radius, outer);
}
if inner_visible {
let scaled_inner = Shadow {
color: Color::new(
inner.color.r(),
inner.color.g(),
inner.color.b(),
inner_alpha.min(1.0),
),
..*inner
};
canvas.draw_shadow(bounds, radius, &scaled_inner);
}
if clip.is_some() {
canvas.clear_clip();
}
}
/// Build a clip rect that includes everything around `bounds` that
/// shadow could reach EXCEPT the attached side. Each non-attached
/// side uses the directional shadow extent for that axis (a `Top`
/// suppression doesn't need slack on the X axis from `offset_y`, and
/// vice versa), so the scissor stays as tight as possible while still
/// admitting the full penumbra on every drawn side.
fn suppress_clip(bounds: Rect, outer: &Shadow, inner: &Shadow, side: AttachedSide) -> Rect {
// +1 dp gives the shader's anti-aliased Gaussian falloff a clean
// edge to fade against; without it the cut can show a 1 px banding
// line along the suppressed edge.
let extent_left = max_extent(outer, inner, Direction::Left) + 1.0;
let extent_right = max_extent(outer, inner, Direction::Right) + 1.0;
let extent_top = max_extent(outer, inner, Direction::Up) + 1.0;
let extent_bottom = max_extent(outer, inner, Direction::Down) + 1.0;
let l = bounds.x - extent_left;
let r = bounds.x + bounds.width + extent_right;
let t = bounds.y - extent_top;
let b = bounds.y + bounds.height + extent_bottom;
match side {
AttachedSide::Top => Rect::new(l, bounds.y, r - l, b - bounds.y),
AttachedSide::Bottom => {
let bot = bounds.y + bounds.height;
Rect::new(l, t, r - l, bot - t)
}
AttachedSide::Left => Rect::new(bounds.x, t, r - bounds.x, b - t),
AttachedSide::Right => {
let right = bounds.x + bounds.width;
Rect::new(l, t, right - l, b - t)
}
}
}
#[derive(Clone, Copy)]
enum Direction {
Up,
Down,
Left,
Right,
}
/// How far a single shadow's penumbra reaches past `bounds` in one
/// direction. The shadow's `offset_*` shifts the quad as a whole, so
/// a positive `offset_y` *reduces* the upward reach and *increases*
/// the downward one (and similarly for X).
fn shadow_extent_in(shadow: &Shadow, dir: Direction) -> f32 {
let blur_spread = shadow.blur + shadow.spread;
match dir {
Direction::Up => (blur_spread - shadow.offset_y).max(0.0),
Direction::Down => (blur_spread + shadow.offset_y).max(0.0),
Direction::Left => (blur_spread - shadow.offset_x).max(0.0),
Direction::Right => (blur_spread + shadow.offset_x).max(0.0),
}
}
fn max_extent(a: &Shadow, b: &Shadow, dir: Direction) -> f32 {
shadow_extent_in(a, dir).max(shadow_extent_in(b, dir))
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_canvas::{DrawCommand, RenderFrame};
fn capture_frame<F: FnOnce(&mut Canvas)>(f: F) -> RenderFrame {
let mut canvas = Canvas::new();
f(&mut canvas);
canvas.into_render_frame()
}
fn shadow_count(frame: &RenderFrame) -> usize {
frame
.draw_order
.iter()
.filter(|c| matches!(c, DrawCommand::Shadow(_)))
.count()
}
fn clip_count(frame: &RenderFrame) -> usize {
frame
.draw_order
.iter()
.filter(|c| matches!(c, DrawCommand::SetClip(_)))
.count()
}
#[test]
fn full_density_emits_two_shadows_no_clip() {
let theme = teksilo_core::presets::intui::light();
let frame = capture_frame(|c| {
paint_layered_shadow(
c,
Rect::new(10.0, 10.0, 100.0, 50.0),
CornerRadius::uniform(8.0),
&theme.shape.shadow_xs,
&theme.shape.shadow_inner_xs,
1.0,
None,
);
});
assert_eq!(shadow_count(&frame), 2, "outer + inner expected");
assert_eq!(clip_count(&frame), 0, "no suppression ⇒ no clip");
}
#[test]
fn zero_density_skips_inner() {
let theme = teksilo_core::presets::intui::light();
let frame = capture_frame(|c| {
paint_layered_shadow(
c,
Rect::new(0.0, 0.0, 50.0, 50.0),
CornerRadius::uniform(4.0),
&theme.shape.shadow_xs,
&theme.shape.shadow_inner_xs,
0.0,
None,
);
});
assert_eq!(shadow_count(&frame), 1, "only outer drawn at density=0");
}
#[test]
fn attached_side_emits_clip() {
let theme = teksilo_core::presets::intui::light();
for side in [
AttachedSide::Top,
AttachedSide::Bottom,
AttachedSide::Left,
AttachedSide::Right,
] {
let frame = capture_frame(|c| {
paint_layered_shadow(
c,
Rect::new(10.0, 10.0, 100.0, 50.0),
CornerRadius::uniform(8.0),
&theme.shape.shadow_xs,
&theme.shape.shadow_inner_xs,
1.0,
Some(side),
);
});
assert_eq!(clip_count(&frame), 1, "{:?} should emit one SetClip", side);
assert!(
frame
.draw_order
.iter()
.any(|c| matches!(c, DrawCommand::ClearClip)),
"{:?} should emit a matching ClearClip",
side,
);
}
}
#[test]
fn top_clip_excludes_top_penumbra_only() {
// A shadow with offset=0 reaches `blur` past every side. A Top
// suppression must keep the body in the clip and exclude the
// region above it.
let theme = teksilo_core::presets::intui::light();
let bounds = Rect::new(10.0, 100.0, 80.0, 40.0);
let frame = capture_frame(|c| {
paint_layered_shadow(
c,
bounds,
CornerRadius::uniform(4.0),
&theme.shape.shadow_xs,
&theme.shape.shadow_inner_xs,
1.0,
Some(AttachedSide::Top),
);
});
let clip = frame
.draw_order
.iter()
.find_map(|c| match c {
DrawCommand::SetClip(r) => Some(*r),
_ => None,
})
.expect("clip command present");
assert!(
(clip.y - bounds.y).abs() < 0.001,
"Top clip must start at body's top edge, got {:?}",
clip,
);
assert!(
clip.y + clip.height >= bounds.y + bounds.height,
"clip must include body bottom"
);
assert!(clip.x <= bounds.x, "clip must include body left side");
assert!(
clip.x + clip.width >= bounds.x + bounds.width,
"clip must include body right side"
);
}
#[test]
fn fully_invisible_shadow_emits_nothing() {
// Both alphas zero ⇒ no draw commands at all.
let zero = Shadow {
color: Color::new(0.0, 0.0, 0.0, 0.0),
..Default::default()
};
let frame = capture_frame(|c| {
paint_layered_shadow(
c,
Rect::new(0.0, 0.0, 50.0, 50.0),
CornerRadius::uniform(4.0),
&zero,
&zero,
1.0,
Some(AttachedSide::Top),
);
});
assert_eq!(shadow_count(&frame), 0);
assert_eq!(clip_count(&frame), 0, "no clip needed when nothing draws");
}
#[test]
fn directional_extent_respects_offset() {
// Offset y = +blur + 1: shadow shifts down enough that nothing
// pokes past the top edge. shadow_extent_in(Up) should clamp
// to zero. shadow_extent_in(Down) should be 2*blur + 1.
let s = Shadow {
blur: 10.0,
spread: 0.0,
offset_y: 11.0,
color: Color::new(0.0, 0.0, 0.0, 0.5),
..Default::default()
};
assert_eq!(shadow_extent_in(&s, Direction::Up), 0.0);
assert!((shadow_extent_in(&s, Direction::Down) - 21.0).abs() < 0.001);
assert!((shadow_extent_in(&s, Direction::Left) - 10.0).abs() < 0.001);
assert!((shadow_extent_in(&s, Direction::Right) - 10.0).abs() < 0.001);
}
}