frust_widgets/container.rs
1//! `Container`/`colored_box`: the baseline decorated box every app currently
2//! hand-rolls its own copy of (`examples/huddle`'s `FillBox`/`FilledBox`,
3//! muxr's three panels + `term_box`, `examples/glyph-catalog` and
4//! `examples/playground`'s `AppBackground`).
5//!
6//! One retained widget ([`ContainerWidget`]) backs two constructor front
7//! doors — a single-child wrapper and a childless leaf — because the knob set
8//! and paint discipline are identical; only whether a child is attached
9//! differs.
10//!
11//! - [`container`] — the **single-child wrapper** family (replaces
12//! `FilledBox`, a panel, `term_box`): wraps `child`, hugging it exactly (the
13//! container's own size is always the child's size — decoration never grows
14//! the box) unless `.size_centered(...)` overrides that (see "Sizing + a
15//! child" below). Chain `.fill(...)`/`.radius(...)`/`.border(...)`/
16//! `.border_style(...)`/`.glow(...)`.
17//! - [`colored_box`] — the **childless leaf/background** family (replaces
18//! `FillBox`, `AppBackground`): no content, so it needs `.expand()` (fill
19//! the available space — the full-bleed background case) or `.size(w, h)`
20//! (a fixed swatch, the `FillBox` case) to have any extent beyond the
21//! incoming minimum constraint.
22//!
23//! `.child(...)` switches either front door into the wrapper family
24//! (`colored_box().child(x)` and `container(x)` build identical widgets), so
25//! there is exactly one type ([`ContainerView`]) either way.
26//!
27//! # Padding composition
28//!
29//! `Container` carries **no** padding knob. Compose it with the existing
30//! [`crate::Padding`] instead: `container(Padding(insets, child))` insets
31//! the child before decoration measures it, so the fill/border/radius still
32//! trace the *outer* (padded) box exactly, matching `material::card`'s own
33//! content-inset shape without duplicating `Padding`'s inset math on this
34//! type. A childless `colored_box()` has no child for a knob to inset in the
35//! first place. This mirrors the framework's general stance
36//! (`docs/CODE_STANDARDS.md`): a container widget owns one concern, and
37//! composes with its siblings rather than accreting their knobs.
38//!
39//! # Sizing + a child
40//!
41//! `.expand()`/`.size(...)` only take effect while the container is
42//! childless — once `.child(...)` attaches content, layout always hugs it
43//! (the [`container`]/`FilledBox` case), the same way `Padding`/`SizedBox`'s
44//! own child-hugging paths behave. [`ContainerView::size_centered`] is the one
45//! exception, added for the `Panel::fixed` shape: it forces the container to a
46//! fixed `(width, height)` **with a child attached**, loosening the child's own
47//! constraint to that box (mirroring [`crate::Align`]'s `bc.loosen()` child
48//! pass) and centering it in the free space — `SizedBox`'s general "grow past
49//! the child" case for an arbitrary alignment stays deferred; this is the one
50//! fixed-size-plus-centered-child shape this module picks up. See
51//! `docs/LIMITATIONS.md`/the arc's own task record for the remaining v1 knob
52//! boundary (still deferred: left-accent, bottom-rule, bleed — compose those
53//! as app-side layering over a plain `Container` instead).
54//!
55//! ## `.expand()` under an unbounded axis
56//!
57//! A childless `.expand()`ed container fills the incoming max **per axis**,
58//! not as a single "fill everything" gesture: a bounded axis fills to that
59//! bound as expected, but an *unbounded* axis (`max == f64::INFINITY`) —
60//! the shape `Flex`'s inflexible-child intrinsic-probe pass, `ScrollView`'s
61//! content layout, and `ListView`'s row layout all hand a child when
62//! measuring its natural extent along that axis — **collapses to
63//! `bc.min()`** on that axis instead of growing without bound.
64//! [`frust_core::BoxConstraints::constrain`]'s clamp cannot be relied on to
65//! cap this case: `x.clamp(min, f64::INFINITY)` never lowers `x`, so any
66//! finite "large enough" sentinel size would leak through unclamped and
67//! report a fictitious intrinsic size to the caller (a `Flex` probing an
68//! `.expand()`ed inflexible child would allocate room for it as if it were
69//! ~10,000,000px wide). Collapsing to `bc.min()` — typically `0` under a
70//! loose probe — is the sane floor: an unbounded expand has no real
71//! "available space" to fill, so it reports the smallest size the incoming
72//! constraint still allows, exactly like [`crate::sized::SizedBox`]'s
73//! childless-spacer default. See [`ContainerWidget::layout`]'s
74//! `Sizing::Expand` arm and `tests/container_layout.rs`'s
75//! `childless_expand_collapses_to_min_under_an_unbounded_axis`/
76//! `colored_box_expand_inside_a_flex_intrinsic_pass_does_not_report_the_old_sentinel`.
77//!
78//! With a child attached, `.expand()` has no effect at all (layout always
79//! hugs the child regardless of `sizing`, per the section above) — so the
80//! child's own measured size is what a `Flex`/`ScrollView`/`ListView`
81//! intrinsic probe already sees in that case; there is no separate
82//! expand-with-child collapse rule to apply.
83//!
84//! # Paint discipline
85//!
86//! Paint order is glow, then fill, then border, then the child — an
87//! elevation-style shadow reads from *beneath* the shape it casts, mirroring
88//! `material::card`'s `Elevated` variant's `draw_shadow`-then-`fill` order.
89//! The border then strokes *inside* the container's own bounds — inset by
90//! half the stroke width, since a stroke is centered on its path — mirroring
91//! [`crate::button::ButtonWidget`]'s and `material::card`'s outlined variant's
92//! identical discipline. Fill uses
93//! [`frust_core::PaintScene::fill_rounded_rect_radii`] (per-corner, with the
94//! uniform case its `From<f64>` case of [`CornerRadii`]); the border strokes a
95//! `kurbo::RoundedRect` built from the *same* per-corner radii (inset by half
96//! the stroke width per corner) so a per-corner fill and its border trace
97//! identical corners — see [`ContainerView::radius`]. A container with none of
98//! `.fill`/`.border`/`.glow` set paints nothing of its own — the paint pass is
99//! a pure pass-through to the child in that case.
100//!
101//! # Semantics
102//!
103//! Purely decorative: [`Widget::semantics`] forwards the child's nodes
104//! (`ChildPod::semantics_child`) and contributes none for the box itself —
105//! unlike `material::card`, which wraps its child in a `Role::GenericContainer`
106//! group node. A childless container has nothing to forward.
107
108use frust_core::{
109 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, DashPattern, EventCtx,
110 EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
111};
112use kurbo::{Point, RoundedRect, Shape, Size};
113use peniko::{Brush, Color};
114
115/// Flattening tolerance for the border's rounded-rect stroke path (mirrors
116/// `material::card`'s / `Button`'s identical precedent).
117const BORDER_TOLERANCE: f64 = 0.1;
118
119/// A border's stroke style, set via [`ContainerView::border_style`]. Solid by
120/// default; [`ContainerView::border`] alone (with no `.border_style` call)
121/// keeps the pre-existing solid-only behavior unchanged.
122#[derive(Clone, Copy, Debug, PartialEq, Default)]
123pub enum BorderStyle {
124 /// A continuous stroke — [`frust_core::PaintScene::stroke_path`].
125 #[default]
126 Solid,
127 /// A dashed stroke — [`frust_core::PaintScene::stroke_path_dashed`],
128 /// which breaks the same rounded-rect path this module builds for
129 /// [`BorderStyle::Solid`] into the pattern's on/off runs.
130 Dashed(DashPattern),
131}
132
133/// How a childless [`ContainerView`] sizes itself, or (via
134/// [`Sizing::FixedCentered`] only) a `.size_centered`-forced size with a child
135/// attached — see the module docs' "Sizing + a child" section.
136#[derive(Clone, Copy, Debug, PartialEq)]
137enum Sizing {
138 /// Collapse to the incoming minimum constraint — the default, matching
139 /// `SizedBox`'s childless-spacer precedent.
140 Hug,
141 /// Fill the incoming max constraint on both axes (the `AppBackground`
142 /// case) — per-axis, so a bounded axis fills to its max while an
143 /// unbounded one (`max == f64::INFINITY`) collapses to `bc.min()` on
144 /// that axis instead of growing without limit. See
145 /// [`ContainerWidget::layout`]'s `Sizing::Expand` arm.
146 Expand,
147 /// A fixed `(width, height)`, clamped into the incoming constraints (the
148 /// `FillBox`/swatch case). With a child attached, layout still hugs the
149 /// child instead (see the module docs) — this variant only takes effect
150 /// childless.
151 Fixed(f64, f64),
152 /// A fixed `(width, height)`, clamped into the incoming constraints, with
153 /// a child centered inside the free space — [`ContainerView::size_centered`].
154 /// Unlike [`Sizing::Fixed`], this variant takes effect *with* a child
155 /// attached (the `Panel::fixed` case); childless it behaves like `Fixed`.
156 FixedCentered(f64, f64),
157}
158
159/// A declarative decorated box. See the [module docs](self).
160pub struct ContainerView<State: 'static> {
161 child: Option<AnyView<State>>,
162 fill: Option<Color>,
163 radius: CornerRadii,
164 border: Option<(Color, f64)>,
165 border_style: BorderStyle,
166 /// Ambient glow/shadow: `(color, std_dev, spread)` — see
167 /// [`ContainerView::glow`].
168 glow: Option<(Color, f64, f64)>,
169 sizing: Sizing,
170}
171
172/// Wrap `child` in a [`ContainerView`] — the single-child wrapper family. See
173/// the [module docs](self).
174pub fn container<State: 'static, V: View<State>>(child: V) -> ContainerView<State> {
175 ContainerView {
176 child: Some(any(child)),
177 fill: None,
178 radius: CornerRadii::default(),
179 border: None,
180 border_style: BorderStyle::default(),
181 glow: None,
182 sizing: Sizing::Hug,
183 }
184}
185
186/// A childless [`ContainerView`] — the leaf/background family. See the
187/// [module docs](self).
188pub fn colored_box<State: 'static>() -> ContainerView<State> {
189 ContainerView {
190 child: None,
191 fill: None,
192 radius: CornerRadii::default(),
193 border: None,
194 border_style: BorderStyle::default(),
195 glow: None,
196 sizing: Sizing::Hug,
197 }
198}
199
200impl<State: 'static> ContainerView<State> {
201 /// Attach (or replace) the single child, switching this container into
202 /// the wrapper family — the `.child(...)` counterpart to [`container`]'s
203 /// direct-argument form. See the [module docs](self).
204 pub fn child<V: View<State>>(mut self, child: V) -> Self {
205 self.child = Some(any(child));
206 self
207 }
208
209 /// Paint a solid fill behind the child (or, childless, behind whatever
210 /// extent `.expand()`/`.size(...)` gives it). No fill by default.
211 pub fn fill(mut self, color: Color) -> Self {
212 self.fill = Some(color);
213 self
214 }
215
216 /// Corner radius for the fill and border, in logical px. Accepts
217 /// `impl Into<`[`CornerRadii`]`>` rather than a second `.radius_corners(...)`
218 /// method: [`CornerRadii`] already implements `From<f64>` for the uniform
219 /// case, so the existing `.radius(8.0)` call keeps working exactly as
220 /// before while `.radius(CornerRadii::new(tl, tr, br, bl))` picks up the
221 /// per-corner case (a bottom-anchored sheet with only its top corners
222 /// rounded, a segmented control's end caps) for free — one method, no
223 /// ergonomics lost either way. Square corners
224 /// (`CornerRadii::default()`) by default.
225 pub fn radius(mut self, radius: impl Into<CornerRadii>) -> Self {
226 self.radius = radius.into();
227 self
228 }
229
230 /// Paint a `width`-px border in `color`, stroked fully inside the
231 /// container's own bounds — see the module docs' "Paint discipline"
232 /// section. No border by default. Solid unless overridden with
233 /// [`ContainerView::border_style`].
234 pub fn border(mut self, color: Color, width: f64) -> Self {
235 self.border = Some((color, width));
236 self
237 }
238
239 /// Set the border's stroke style — [`BorderStyle::Solid`] (the default,
240 /// so this call is only needed for [`BorderStyle::Dashed`]) or
241 /// [`BorderStyle::Dashed`] with an explicit [`DashPattern`]. Has no
242 /// effect without a `.border(...)` call — there is no stroke to style.
243 pub fn border_style(mut self, style: BorderStyle) -> Self {
244 self.border_style = style;
245 self
246 }
247
248 /// Paint a gaussian-blurred ambient glow/shadow beneath the fill and
249 /// border, via [`frust_core::PaintScene::draw_shadow`] — an
250 /// elevation-style knob, not a directional drop shadow (see below). No
251 /// glow by default.
252 ///
253 /// # Mapping to CSS `box-shadow` terms
254 ///
255 /// `.glow(color, std_dev, spread)` maps loosely onto
256 /// `box-shadow: 0 0 <blur> <spread> <color>` (an un-offset, centered
257 /// shadow — see the note on offset below):
258 ///
259 /// - `color` — the shadow's color, alpha included (CSS `<color>`).
260 /// - `std_dev` — the Gaussian's standard deviation. CSS's `blur-radius` is
261 /// *twice* the standard deviation (CSS Backgrounds and Borders 3
262 /// § 7.2.1), so a blur token translates in as `std_dev = blur / 2.0` —
263 /// the same halving `frust_material::card`/`frust_shadcn::style::draw_shadow`
264 /// already apply at their own call sites.
265 /// - `spread` — grows (positive) or shrinks (negative) the shadow's rect
266 /// symmetrically on every side *before* blurring. `draw_shadow` itself
267 /// has no spread parameter (`frust_shadcn::style::draw_shadow`'s own doc
268 /// drops it for the same reason), so this module computes the inflated
269 /// rect directly — `origin` shifts by `-spread` on each axis, `size`
270 /// grows by `2.0 * spread`, clamped to non-negative before reaching
271 /// `draw_shadow`.
272 /// - `border-radius` — a per-corner [`ContainerView::radius`] lowers
273 /// through [`CornerRadii::largest`] for this call (`draw_shadow` takes a
274 /// single `f64` radius, the same fallback
275 /// [`Command::BlurredRoundedRect`](frust_scene::Command::BlurredRoundedRect)'s
276 /// own doc names for a per-corner shadow caster) — but never
277 /// *unclamped*: kurbo silently caps a `RoundedRect`'s own corner radius
278 /// at half its shorter side, so the *fill's* true rendered corner is
279 /// `radius.largest().min(size.min_side() / 2.0)`, not the raw builder
280 /// value. Feeding an unclamped radius (e.g. a `.radius(f64::MAX)`-style
281 /// pill/circle idiom) straight into the blurred-rect primitive on a
282 /// small box is exactly the CSS `box-shadow` `spread` + `border-radius`
283 /// interaction: per CSS Backgrounds and Borders 3 § 7.2.1, spreading a
284 /// shadow grows its corner radii by the spread distance too (clamped at
285 /// 0), so this module adds `spread` on top of the *clamped* fill
286 /// radius — `shadow_radius = clamped_fill_radius + spread.max(0.0)` —
287 /// rather than to the raw builder value or to a radius re-clamped
288 /// against the already-inflated shadow rect. This is what keeps a
289 /// circular fill's halo circular instead of degenerating into the
290 /// oversized dark ring a naive `radius.largest()` passthrough painted
291 /// around a small pulsing-dot circle.
292 /// - offset-x/offset-y are **not modeled**: `.glow` is a centered ambient
293 /// glow, not a directional drop shadow like `material::card`'s
294 /// `Elevated` variant (which offsets `origin.y` by the shadow rung's
295 /// `y_offset`). A caller wanting a directional shadow composes an
296 /// explicit `PaintScene::draw_shadow` call instead, following that
297 /// precedent.
298 pub fn glow(mut self, color: Color, std_dev: f64, spread: f64) -> Self {
299 self.glow = Some((color, std_dev, spread));
300 self
301 }
302
303 /// Fill the available space on both axes (the incoming constraint's
304 /// max) — the `AppBackground` full-bleed-background case. Childless
305 /// only; see the module docs' "Sizing + a child" section.
306 pub fn expand(mut self) -> Self {
307 self.sizing = Sizing::Expand;
308 self
309 }
310
311 /// Force a fixed `(width, height)`, clamped into the incoming
312 /// constraints — the `FillBox`/swatch case. Childless only (a child stays
313 /// hugged tight); use [`ContainerView::size_centered`] for a fixed size
314 /// with a centered child. See the module docs' "Sizing + a child" section.
315 pub fn size(mut self, width: f64, height: f64) -> Self {
316 self.sizing = Sizing::Fixed(width, height);
317 self
318 }
319
320 /// Force a fixed `(width, height)`, clamped into the incoming
321 /// constraints, **with an attached child centered inside the free
322 /// space** — the `Panel::fixed` case (a settings panel/dialog/card that
323 /// wants a fixed footprint but lets its content size itself rather than
324 /// being stretched to fill it). Unlike [`ContainerView::size`], this
325 /// loosens the child's own constraint to `(width, height)` (mirroring
326 /// [`crate::Align`]'s child pass) instead of hugging it tight, then
327 /// centers the child in whatever free space remains — see the module
328 /// docs' "Sizing + a child" section. Childless, this behaves exactly like
329 /// [`ContainerView::size`].
330 pub fn size_centered(mut self, width: f64, height: f64) -> Self {
331 self.sizing = Sizing::FixedCentered(width, height);
332 self
333 }
334}
335
336/// The retained widget for a [`ContainerView`]. See the [module docs](self).
337pub struct ContainerWidget {
338 child: Option<ChildPod>,
339 fill: Option<Color>,
340 radius: CornerRadii,
341 border: Option<(Color, f64)>,
342 border_style: BorderStyle,
343 glow: Option<(Color, f64, f64)>,
344 sizing: Sizing,
345}
346
347impl<State: 'static> View<State> for ContainerView<State> {
348 type Element = ContainerWidget;
349
350 fn build(&self, ctx: &mut BuildCtx<'_>) -> ContainerWidget {
351 ContainerWidget {
352 child: self
353 .child
354 .as_ref()
355 .map(|view| crate::authoring::build_child(view, ctx)),
356 fill: self.fill,
357 radius: self.radius,
358 border: self.border,
359 border_style: self.border_style,
360 glow: self.glow,
361 sizing: self.sizing,
362 }
363 }
364
365 fn rebuild(
366 &self,
367 prev: &Self,
368 element: &mut ContainerWidget,
369 ctx: &mut BuildCtx<'_>,
370 ) -> ChangeFlags {
371 let mut flags = ChangeFlags::NONE;
372 if prev.fill != self.fill
373 || prev.radius != self.radius
374 || prev.border != self.border
375 || prev.border_style != self.border_style
376 || prev.glow != self.glow
377 {
378 element.fill = self.fill;
379 element.radius = self.radius;
380 element.border = self.border;
381 element.border_style = self.border_style;
382 element.glow = self.glow;
383 flags |= ChangeFlags::PAINT;
384 }
385 if prev.sizing != self.sizing {
386 element.sizing = self.sizing;
387 flags |= ChangeFlags::LAYOUT;
388 }
389 match (&prev.child, &self.child, &mut element.child) {
390 (Some(prev_view), Some(next_view), Some(pod)) => {
391 flags |= crate::authoring::rebuild_child(prev_view, next_view, pod, ctx);
392 }
393 (None, Some(next_view), _) => {
394 element.child = Some(crate::authoring::build_child(next_view, ctx));
395 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
396 }
397 (Some(prev_view), None, Some(pod)) => {
398 crate::authoring::teardown_child(prev_view, pod, ctx);
399 element.child = None;
400 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
401 }
402 _ => {}
403 }
404 flags
405 }
406
407 fn teardown(&self, element: &mut ContainerWidget, ctx: &mut BuildCtx<'_>) {
408 if let (Some(view), Some(pod)) = (&self.child, &mut element.child) {
409 crate::authoring::teardown_child(view, pod, ctx);
410 }
411 }
412}
413
414/// Insets each of `radii`'s four corners by `inset` (e.g. half a border's
415/// stroke width), clamped to non-negative — the per-corner generalization of
416/// the uniform `(self.radius - half).max(0.0)` inset the border path always
417/// applied, so a per-corner fill and its border stroke agree on every corner.
418fn inset_radii(radii: CornerRadii, inset: f64) -> CornerRadii {
419 CornerRadii::new(
420 (radii.top_left - inset).max(0.0),
421 (radii.top_right - inset).max(0.0),
422 (radii.bottom_right - inset).max(0.0),
423 (radii.bottom_left - inset).max(0.0),
424 )
425}
426
427/// One axis of a childless `.expand()`ed container's intrinsic size: `max`
428/// when it is a real bound, else `min` — the fallback for an unbounded axis
429/// (`max == f64::INFINITY`), where `BoxConstraints::constrain`'s clamp is a
430/// no-op (`x.clamp(min, INFINITY)` never lowers `x`) and can't be relied on
431/// to cap an arbitrary sentinel the way it caps a genuinely bounded axis. See
432/// [`Sizing::Expand`]'s doc for why `min` (not e.g. zero) is the right floor.
433fn collapse_or_fill(max: f64, min: f64) -> f64 {
434 if max.is_finite() { max } else { min }
435}
436
437impl Widget for ContainerWidget {
438 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
439 match &mut self.child {
440 Some(pod) => {
441 if let Sizing::FixedCentered(w, h) = self.sizing {
442 // Force the fixed size, then loosen the child's own
443 // constraint to it (mirrors `Align`'s `bc.loosen()` child
444 // pass) and center the child in the free space — the
445 // `Panel::fixed` case (see the module docs).
446 let own_size = bc.constrain(Size::new(w, h));
447 let child_size = pod.layout_child(ctx, &BoxConstraints::loose(own_size));
448 let x = ((own_size.width - child_size.width) / 2.0).max(0.0);
449 let y = ((own_size.height - child_size.height) / 2.0).max(0.0);
450 pod.set_origin(Point::new(x, y));
451 own_size
452 } else {
453 // Always hug the child exactly — decoration never grows
454 // the box (see the module docs' "Sizing + a child"
455 // section).
456 let child_size = pod.layout_child(ctx, bc);
457 pod.set_origin(Point::ZERO);
458 bc.constrain(child_size)
459 }
460 }
461 None => {
462 let intrinsic = match self.sizing {
463 Sizing::Hug => bc.min(),
464 // Fill the incoming max, per axis — except an unbounded
465 // axis (`max == INFINITY`, the intrinsic-probe shape
466 // `Flex`'s inflexible-child pass/`ScrollView`/`ListView`
467 // hand a childless expanding box), which collapses to
468 // `bc.min()` on that axis instead of reporting an
469 // arbitrary sentinel. `bc.constrain` below is then a
470 // no-op on a bounded axis (the chosen value already sits
471 // at `bc.max()`) and only does real clamping work when
472 // `bc.min() > 0` on the collapsed axis.
473 Sizing::Expand => Size::new(
474 collapse_or_fill(bc.max().width, bc.min().width),
475 collapse_or_fill(bc.max().height, bc.min().height),
476 ),
477 Sizing::Fixed(w, h) | Sizing::FixedCentered(w, h) => Size::new(w, h),
478 };
479 bc.constrain(intrinsic)
480 }
481 }
482 }
483
484 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
485 let origin = ctx.origin();
486 let size = ctx.size();
487 // Glow paints first — an elevation-style shadow reads from beneath
488 // the shape it casts (see the module docs' "Paint discipline"
489 // section).
490 if let Some((color, std_dev, spread)) = self.glow {
491 let shadow_origin = Point::new(origin.x - spread, origin.y - spread);
492 let shadow_size = Size::new(
493 (size.width + 2.0 * spread).max(0.0),
494 (size.height + 2.0 * spread).max(0.0),
495 );
496 // Clamp to the *fill's own* rendered corner — kurbo already caps
497 // a `RoundedRect`'s radius at half its shorter side, so an
498 // unclamped `radius.largest()` (e.g. a `.radius(999.0)`
499 // pill/circle idiom) fed straight into the blurred-rect
500 // primitive on a small box paints a dark blob far larger than
501 // the shape it's supposed to halo (see `.glow`'s doc). `spread`
502 // then adds onto this *clamped* radius, not the raw builder
503 // value and not a radius re-clamped against the already-inflated
504 // `shadow_size` — the CSS `box-shadow` spread+border-radius rule
505 // this mirrors grows the caster's own corner, so a circular fill
506 // keeps a circular halo instead of the shadow's roundness being
507 // capped by its own inflated box.
508 let clamped_fill_radius = self.radius.largest().min(size.width.min(size.height) / 2.0);
509 let shadow_radius = clamped_fill_radius + spread.max(0.0);
510 scene.draw_shadow(shadow_origin, shadow_size, shadow_radius, std_dev, color);
511 }
512 if let Some(fill) = self.fill {
513 scene.fill_rounded_rect_radii(origin, size, self.radius, fill);
514 }
515 if let Some((color, width)) = self.border {
516 // Inset by half the stroke width so the border paints fully
517 // inside the container's own bounds (a stroke is centered on its
518 // path) — mirrors `Button`'s/`material::card`'s outlined-variant
519 // precedent, generalized per corner via `inset_radii` so the
520 // border agrees with a per-corner fill.
521 let half = width / 2.0;
522 let radii = inset_radii(self.radius, half);
523 let rr = RoundedRect::new(
524 half,
525 half,
526 size.width - half,
527 size.height - half,
528 (
529 radii.top_left,
530 radii.top_right,
531 radii.bottom_right,
532 radii.bottom_left,
533 ),
534 );
535 let path = rr.to_path(BORDER_TOLERANCE);
536 match self.border_style {
537 BorderStyle::Solid => scene.stroke_path(origin, &path, width, &Brush::Solid(color)),
538 BorderStyle::Dashed(dash) => {
539 scene.stroke_path_dashed(origin, &path, width, dash, &Brush::Solid(color))
540 }
541 }
542 }
543 if let Some(pod) = &mut self.child {
544 pod.paint_child(ctx, scene);
545 }
546 }
547
548 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
549 match &mut self.child {
550 Some(pod) => crate::authoring::route_event_single(pod, ctx, event),
551 None => EventResult::Ignored,
552 }
553 }
554
555 fn semantics(&self, ctx: &mut SemanticsCtx) {
556 // Purely decorative: forward the child's nodes, contribute none for
557 // the box itself (see the module docs' "Semantics" section).
558 if let Some(pod) = &self.child {
559 pod.semantics_child(ctx);
560 }
561 }
562
563 crate::authoring::visit_children!(child);
564}