Skip to main content

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}