Skip to main content

frust_material/
card.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments)
2// Upstream: https://github.com/paadevelopments/material_3_expressive
3//   lib/components/cards/ (`m3e_cards.dart`, `enums/m3e_card_variant.dart`,
4//   `styles/m3e_card_theme.dart`)
5// This rework adds the reference's hover/press interaction treatment
6// (`M3ETappable`, ported once and shared crate-wide as `crate::interaction`)
7// on top of the v1 static-elevation port; `onLongPress` and the
8// customization escape hatches (`border`/`color`/`elevation`/`width`/
9// `mouseCursor`/`semanticLabel`/`onStateChanged` overrides) stay unported —
10// see the module docs' Not ported section.
11
12//! The M3 `Card` container: elevated / filled / outlined variants, wrapping a
13//! single [`frust::authoring::AnyView`] child (the same single-child `ChildPod`
14//! wrapper shape as [`frust::Padding`]).
15//!
16//! # Variants
17//!
18//! All three variants share the medium shape (12dp, `shape.medium`) and
19//! `CARD_PADDING` (16dp) content inset — the standard M3 card spec
20//! (m3.material.io/components/cards/specs):
21//!
22//! * **Elevated**: container `surfaceContainerLow`, M3 elevation level 1
23//!   (1dp) at rest, level 2 (3dp) while hovered — see Hover elevation lift
24//!   below — painted via [`frust::authoring::PaintScene::draw_shadow`].
25//! * **Filled**: container `surfaceContainerHighest`, elevation 0 (no
26//!   shadow, at rest or hovered).
27//! * **Outlined**: container `surface`, a 1dp stroke in `outlineVariant`
28//!   (the enabled-state stroke color — `outline` is reserved for a disabled
29//!   card, applied only while an interactive card is disabled, see Disabled
30//!   state below), no shadow.
31//!
32//! # Outlined stroke primitive choice
33//!
34//! No stroked-rounded-rect primitive exists on [`frust::authoring::PaintScene`]
35//! (only `stroke_line` and the `stroke_path`/`fill_path` pair). Rather than
36//! approximate the rounded stroke with four `stroke_line` calls (visibly
37//! square corners), this module builds a real rounded-rect outline via
38//! `kurbo::RoundedRect` (a [`kurbo::Shape`], so `.to_path(tolerance)` yields a
39//! `BezPath`) and paints it with [`frust::authoring::PaintScene::stroke_path`] —
40//! establishing the precedent for any future widget needing a stroked rounded
41//! shape.
42//!
43//! # Interactivity
44//!
45//! [`CardView::on_press`] makes the whole card surface one interactive
46//! target — mirroring [`frust::Button`] (fire-on-up-inside; the card itself
47//! owns capture and paints the shared M3E state-layer overlay via
48//! [`crate::interaction::InteractionState`], the crate's unified
49//! hover/focus/pressed/dragged substrate, tinted `on_surface`), rather than
50//! forwarding events into the child. Without `on_press` the card is a
51//! transparent, non-interactive wrapper that routes pointer events straight
52//! to its child (mirroring [`frust::Padding`]) — [`CardView::enabled`] and
53//! [`CardView::haptic`] are then no-ops, since there is no interactive
54//! surface for either to act on (mirroring the reference's own
55//! `M3ETappable`-only scope for both).
56//!
57//! # Hover elevation lift
58//!
59//! An interactive elevated card raises from M3 elevation level 1 (1dp) to
60//! level 2 (3dp) while hovered (`M3ECardTheme.elevation(variant, {hovered})`)
61//! — a *non-interactive* elevated card (no [`CardView::on_press`]) never
62//! hovers at all and stays pinned at level 1, matching the reference's own
63//! `_buildCard`, which never wraps a non-interactive card in `M3ETappable`
64//! (so its `M3EInteractionState` — and therefore `hovered` — is permanently
65//! the default `const M3EInteractionState()`, i.e. every flag `false`).
66//!
67//! Unlike this crate's [`super::fab`] and [`super::button`], whose own
68//! hover-elevation steps are instant, per-frame binary switches (a
69//! documented simplification of the reference's `AnimatedContainer`-driven
70//! shadow tween, verified against both modules' own doc comments), this
71//! module plays the reference's tween for real: [`CardWidget::elevation_anim`]
72//! springs the *shadow geometry* (blur/y-offset — see
73//! [`resolve_elevation_endpoints`]) between the level-1 and level-2 endpoints
74//! whenever the live hover read flips, mirroring `switch`'s `anim_target`
75//! idiom (a fresh [`frust::AnimationController::fling`] starts lazily in
76//! `paint` whenever the live value disagrees with the target the last fling
77//! aimed at). The driving spring is
78//! [`crate::interaction::PressSpringId::DefaultEffects`] — this module's
79//! first real (non-test) consumer of that seam, per its own doc: "this task
80//! ships the shared state and resolved-values plumbing ... not a runtime."
81//! A shadow-geometry change is a magnitude/effects transition, not a
82//! spatial one (`crate::tokens::motion_scheme()`'s own spatial-vs-effects
83//! split), and `DefaultEffects` is critically damped (`damping_ratio: 1.0`),
84//! so the lift never overshoots into an unnaturally large or negative
85//! shadow. [`ELEVATION_RETARGET_VELOCITY`] mirrors `switch::RELEASE_VELOCITY`
86//! exactly: a near-zero signed nudge that only picks the fling's direction
87//! (`fling` targets `1.0` for a non-negative velocity, `0.0` otherwise) and
88//! leaves the spring's own stiffness/damping to shape the whole motion — a
89//! hover flip is a discrete state change, not a directional user gesture
90//! like [`super::fab`]'s press/release kick.
91//!
92//! Shadow *color* never changes between the two endpoints (both M3 elevation
93//! levels share the same `0.3` `color_alpha`, per
94//! `crate::tokens::metrics::elevation_level`) — only the geometry is
95//! interpolated; see [`resolve_shadow_color`].
96//!
97//! # Disabled state
98//!
99//! [`CardView::enabled`] (default `true`) is this crate's own extension —
100//! the reference `M3ECard` widget itself carries no `enabled` field (only
101//! the `M3ETappable` primitive it wraps does, gating `_isInteractive`
102//! alongside `onPressed`/`onLongPress`). Rather than literally mirroring
103//! that formula (which would make a disabled interactive card
104//! indistinguishable from a plain non-interactive one — no dimmed styling,
105//! no accesskit disabled flag), this module follows this crate's own
106//! established `enabled` convention instead (`button`/`radio`/`switch`/
107//! `icon_button`/`toggle_button`/`text_field`): an interactive card that is
108//! disabled **keeps** its `Role::Button` semantics (reporting
109//! `Node::set_disabled()` instead of `Action::Click` — see
110//! [`CardWidget::semantics`]) and its container/outline visually dim to
111//! `on_surface` at [`crate::interaction::DISABLED_CONTAINER_OPACITY`] (12%,
112//! mirroring `button::core::resolve_colors`'s identical disabled treatment)
113//! — but it claims no hover, springs no elevation lift, paints no state
114//! layer, and fires no press/haptic. **Hover is enabled-gated** in both the
115//! event pass and paint's self-correction (`self.interactive &&
116//! self.enabled && ...`) — the radio-hover-regression fix (never react to
117//! hover while disabled) applied here from the start rather than
118//! retrofitted. A disabled interactive card's own
119//! `Widget::event` early-returns `Ignored` for every pointer phase (mirrors
120//! `button::core::ButtonWidget::event`'s identical disabled early-return) —
121//! it does **not** forward to its child; a disabled card block is fully
122//! inert, not a pass-through.
123//!
124//! Elevation itself carries no disabled branch (`M3ECardTheme.elevation`
125//! takes no `enabled` parameter) — a disabled elevated card still rests at
126//! level 1, simply never lifting to level 2 since it can never hover.
127//!
128//! # Haptics
129//!
130//! [`CardView::haptic`] ports `M3ECard.haptic` (default
131//! [`crate::interaction::HapticSignal::None`], passed straight through to
132//! `M3ETappable`): fired via [`crate::interaction::MaterialHaptics::fire`]
133//! immediately before `on_press`, mirroring `button::core`'s identical
134//! ordering. `None` is a documented no-op, elided rather than routed through
135//! the process-global hook.
136//!
137//! # Not ported
138//!
139//! `onLongPress` — no gesture primitive for it exists anywhere in this
140//! crate yet (`button`'s own module docs record the same v1 scope line: "not
141//! ported in v1: no tooltip host in the catalog, and neither long-press nor
142//! a hover callback has a reference-visual attached to it"). The reference's
143//! customization escape hatches (`clipBehavior`, a `borderRadius`/`color`/
144//! `elevation`/`border`/`width` override, `surfaceKey`, `mouseCursor`,
145//! `semanticLabel`, `animationDuration`/`animationCurve` overrides,
146//! `onStateChanged`) are all out of this task's scope (hover-lift/press/
147//! disabled treatment) and stay unported.
148//!
149//! # Semantics
150//!
151//! An interactive card contributes a [`Role::Button`] container node (its
152//! child's own semantics become the button's accesskit children — a card has
153//! no single-line text label of its own to flatten into the node, unlike
154//! `Button`), reporting `Node::set_disabled()` while disabled instead of
155//! `Action::Click` (see Disabled state above). A non-interactive card
156//! contributes a [`Role::GenericContainer`] ("group") node instead of
157//! transparently forwarding like `Padding` — a deliberate choice to keep a
158//! card's content grouped as one semantic unit even when it isn't clickable.
159
160use std::rc::Rc;
161use std::time::Duration;
162
163use frust::Theme;
164use frust::authoring::{Action, Role};
165use frust::authoring::{
166    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
167    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
168};
169use frust::{AnimationController, Tween};
170use kurbo::{Point, RoundedRect, Shape, Size};
171use peniko::{Brush, Color};
172
173use crate::interaction::{
174    DISABLED_CONTAINER_OPACITY, HapticSignal, InteractionState, MaterialHaptics, PressSpringId,
175};
176
177use super::press::presses;
178
179/// Content padding on all four edges, in logical px (M3 card spec).
180const CARD_PADDING: f64 = 16.0;
181/// Corner radius (unthemed fallback; a theme resolves this from
182/// `shape.medium`, a 12dp token).
183const RADIUS: f64 = 12.0;
184/// Outline stroke width, in logical px (M3 card spec: hairline 1dp).
185const STROKE_WIDTH: f64 = 1.0;
186/// Flattening tolerance for the outlined variant's rounded-rect stroke path
187/// (see the module docs' primitive-choice note) — a visually-lossless value
188/// for on-screen corner radii.
189const STROKE_TOLERANCE: f64 = 0.1;
190
191/// Unthemed-fallback elevated-card container fill (a theme resolves this from
192/// `colors.surface_container_low`).
193const ELEVATED_CONTAINER: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
194/// Unthemed-fallback filled-card container fill (a theme resolves this from
195/// `colors.surface_container_highest`).
196const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
197/// Unthemed-fallback outlined-card container fill (a theme resolves this from
198/// `colors.surface`).
199const OUTLINED_CONTAINER: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
200/// Unthemed-fallback outline stroke color (a theme resolves this from
201/// `colors.outline_variant` — the enabled-state stroke).
202const OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
203/// Unthemed-fallback state-layer content color for an interactive card (a
204/// theme resolves this from `colors.on_surface`).
205const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
206
207/// Unthemed-fallback shadow y-offset at rest (M3 elevation level 1),
208/// matching `crate::tokens::elevation().level1`'s `y_offset` exactly
209/// (`dp / 2.0 + 1.0` at `dp = 1.0`).
210const FALLBACK_SHADOW_Y_OFFSET: f64 = 1.5;
211/// Unthemed-fallback shadow blur std-dev at rest (M3 elevation level 1),
212/// matching `crate::tokens::elevation().level1`.
213const FALLBACK_SHADOW_BLUR: f64 = 1.0;
214/// Unthemed-fallback shadow y-offset while hovered (M3 elevation level 2),
215/// matching `crate::tokens::elevation().level2`'s `y_offset` exactly
216/// (`dp / 2.0 + 1.0` at `dp = 3.0`).
217const FALLBACK_HOVER_SHADOW_Y_OFFSET: f64 = 2.5;
218/// Unthemed-fallback shadow blur std-dev while hovered (M3 elevation level
219/// 2), matching `crate::tokens::elevation().level2`.
220const FALLBACK_HOVER_SHADOW_BLUR: f64 = 3.0;
221/// Unthemed-fallback shadow color (opaque black at
222/// `crate::tokens::elevation()`'s `0.3` alpha — identical at every level, so
223/// this one constant covers both the rest and hovered endpoints).
224const FALLBACK_SHADOW_COLOR: Color = Color::new([0.0, 0.0, 0.0, 0.3]);
225
226/// A near-zero signed velocity that only picks
227/// [`frust::AnimationController::fling`]'s direction, leaving the spring
228/// itself to shape the motion — mirrors `switch::RELEASE_VELOCITY` exactly.
229/// See the [module docs](self)' Hover elevation lift section for why a
230/// hover flip uses this rather than [`super::fab`]'s directional kick.
231const ELEVATION_RETARGET_VELOCITY: f64 = 1e-3;
232
233/// The M3 card container variant. See the [module docs](self).
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235pub enum CardVariant {
236    Elevated,
237    Filled,
238    Outlined,
239}
240
241/// Return `color` with its alpha channel replaced by `alpha` (mirrors
242/// [`super::state_layer`]'s helper of the same shape).
243fn with_alpha(color: Color, alpha: f32) -> Color {
244    let c = color.components;
245    Color::new([c[0], c[1], c[2], alpha])
246}
247
248/// The resolved container fill for `variant`. `disabled` (an interactive
249/// card with [`CardView::enabled`]`(false)`) overrides every variant to a
250/// dimmed `on_surface` wash — see the [module docs](self)' Disabled state
251/// section. Themed (enabled): `surface_container_low` (elevated) /
252/// `surface_container_highest` (filled) / `surface` (outlined). Unthemed
253/// (enabled): [`ELEVATED_CONTAINER`]/[`FILLED_CONTAINER`]/
254/// [`OUTLINED_CONTAINER`] exactly.
255fn resolve_container(theme: Option<&Theme>, variant: CardVariant, disabled: bool) -> Color {
256    if disabled {
257        return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
258    }
259    match theme {
260        Some(theme) => {
261            let scheme = theme.scheme();
262            match variant {
263                CardVariant::Elevated => scheme.surface_container_low,
264                CardVariant::Filled => scheme.surface_container_highest,
265                CardVariant::Outlined => scheme.surface,
266            }
267        }
268        None => match variant {
269            CardVariant::Elevated => ELEVATED_CONTAINER,
270            CardVariant::Filled => FILLED_CONTAINER,
271            CardVariant::Outlined => OUTLINED_CONTAINER,
272        },
273    }
274}
275
276/// The resolved corner radius. Themed: `shape.medium`. Unthemed: [`RADIUS`]
277/// exactly.
278fn resolve_radius(theme: Option<&Theme>) -> f64 {
279    match theme {
280        Some(theme) => theme.shape.medium,
281        None => RADIUS,
282    }
283}
284
285/// The resolved outline stroke color (outlined variant only). `disabled`
286/// dims to the same `on_surface` wash [`resolve_container`] uses — see the
287/// [module docs](self)' Disabled state section. Themed (enabled):
288/// `colors.outline_variant`. Unthemed (enabled): [`OUTLINE_VARIANT`]
289/// exactly.
290fn resolve_outline(theme: Option<&Theme>, disabled: bool) -> Color {
291    if disabled {
292        return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
293    }
294    match theme {
295        Some(theme) => theme.scheme().outline_variant,
296        None => OUTLINE_VARIANT,
297    }
298}
299
300/// The resolved state-layer content color for an interactive card. Themed:
301/// `colors.on_surface`. Unthemed: [`ON_SURFACE`] exactly.
302fn resolve_content_color(theme: Option<&Theme>) -> Color {
303    match theme {
304        Some(theme) => theme.scheme().on_surface,
305        None => ON_SURFACE,
306    }
307}
308
309/// The `(blur_std_dev, y_offset)` shadow-geometry endpoints the elevated
310/// variant's hover-lift spring lerps between: resting (M3 elevation level 1)
311/// and fully hovered (level 2). See the [module docs](self)' Hover elevation
312/// lift section for why only geometry (not color) is interpolated. Themed:
313/// `theme.elevation.{level1,level2}`'s own `ShadowSpec`. Unthemed: the
314/// `FALLBACK_*`/`FALLBACK_HOVER_*` constants exactly (dp 1.0/3.0, matching
315/// `crate::tokens::elevation()`'s table).
316fn resolve_elevation_endpoints(theme: Option<&Theme>) -> ((f64, f64), (f64, f64)) {
317    match theme {
318        Some(theme) => {
319            let rest = theme.elevation.level1.shadow(theme.brightness);
320            let hover = theme.elevation.level2.shadow(theme.brightness);
321            (
322                (rest.blur_std_dev, rest.y_offset),
323                (hover.blur_std_dev, hover.y_offset),
324            )
325        }
326        None => (
327            (FALLBACK_SHADOW_BLUR, FALLBACK_SHADOW_Y_OFFSET),
328            (FALLBACK_HOVER_SHADOW_BLUR, FALLBACK_HOVER_SHADOW_Y_OFFSET),
329        ),
330    }
331}
332
333/// The elevated variant's shadow color — identical at rest and hovered (see
334/// [`resolve_elevation_endpoints`]'s doc). Themed: `colors.shadow` at
335/// `theme.elevation.level1`'s `color_alpha`. Unthemed: [`FALLBACK_SHADOW_COLOR`]
336/// exactly.
337fn resolve_shadow_color(theme: Option<&Theme>) -> Color {
338    match theme {
339        Some(theme) => {
340            let alpha = theme.elevation.level1.shadow(theme.brightness).color_alpha;
341            with_alpha(theme.scheme().shadow, alpha)
342        }
343        None => FALLBACK_SHADOW_COLOR,
344    }
345}
346
347fn inside(pos: Point, size: Size) -> bool {
348    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
349}
350
351/// A view-held, typed press callback (erased on build).
352type OnPress<State> = Rc<dyn Fn(&mut State)>;
353
354/// A declarative M3 card wrapping a single child. See the [module docs](self).
355pub struct CardView<State: 'static> {
356    variant: CardVariant,
357    child: AnyView<State>,
358    on_press: Option<OnPress<State>>,
359    enabled: bool,
360    haptic: HapticSignal,
361}
362
363/// Wrap `child` in a card of the given `variant`. Chain [`CardView::on_press`]
364/// to make the whole surface interactive.
365pub fn card<State: 'static, V: View<State>>(variant: CardVariant, child: V) -> CardView<State> {
366    CardView {
367        variant,
368        child: any(child),
369        on_press: None,
370        enabled: true,
371        haptic: HapticSignal::None,
372    }
373}
374
375/// Wrap `child` in an elevated card (`surfaceContainerLow`, M3 elevation
376/// level 1 at rest / level 2 hovered).
377pub fn elevated_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
378    card(CardVariant::Elevated, child)
379}
380
381/// Wrap `child` in a filled card (`surfaceContainerHighest`, no elevation).
382pub fn filled_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
383    card(CardVariant::Filled, child)
384}
385
386/// Wrap `child` in an outlined card (`surface` fill, `outlineVariant` stroke).
387pub fn outlined_card<State: 'static, V: View<State>>(child: V) -> CardView<State> {
388    card(CardVariant::Outlined, child)
389}
390
391impl<State: 'static> CardView<State> {
392    /// Make the whole card surface interactive, firing `on_press` on release
393    /// inside its bounds (see the module docs' Interactivity section).
394    pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
395        self.on_press = Some(Rc::new(on_press));
396        self
397    }
398
399    /// Gate the interactive treatment (hover elevation lift, state-layer
400    /// hover/press tint, and firing [`CardView::on_press`]/
401    /// [`CardView::haptic`] at all) without dropping back to a
402    /// non-interactive, transparently-forwarding card. Defaults to `true`.
403    /// Only meaningful once [`CardView::on_press`] is chained — a no-op on a
404    /// plain wrapper card. See the [module docs](self)' Disabled state
405    /// section for the full behavior and how it differs from the reference.
406    pub fn enabled(mut self, enabled: bool) -> Self {
407        self.enabled = enabled;
408        self
409    }
410
411    /// Set the haptic fired immediately before [`CardView::on_press`] on a
412    /// successful release (`M3ECard.haptic`). Defaults to
413    /// [`HapticSignal::None`] (no feedback). A no-op on a non-interactive or
414    /// disabled card.
415    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
416        self.haptic = haptic;
417        self
418    }
419}
420
421/// The retained widget for a [`CardView`].
422pub struct CardWidget {
423    variant: CardVariant,
424    child: ChildPod,
425    interactive: bool,
426    enabled: bool,
427    haptic: HapticSignal,
428    /// Armed by a `Down` (alongside `capture_pointer`), cleared on
429    /// `Up`/`Cancel`/loss of interactivity or enablement.
430    captured: bool,
431    state: InteractionState,
432    /// Drives the elevated variant's hover-lift shadow-geometry lerp —
433    /// `0.0` at rest (M3 elevation level 1) .. `1.0` fully hovered (level
434    /// 2). See the [module docs](self)' Hover elevation lift section.
435    /// Unused (stays at its build-time rest value) for the filled/outlined
436    /// variants, which never elevate.
437    elevation_anim: AnimationController,
438    /// The `hovered` value [`CardWidget::elevation_anim`]'s current fling is
439    /// driving toward — compared against the live hover read each paint to
440    /// decide whether a fresh fling needs to start (mirrors `switch`'s
441    /// `anim_target` idiom).
442    elevation_target: bool,
443    on_press: Option<frust::authoring::ErasedCallback>,
444}
445
446impl<State: 'static> View<State> for CardView<State> {
447    type Element = CardWidget;
448
449    fn build(&self, ctx: &mut BuildCtx<'_>) -> CardWidget {
450        CardWidget {
451            variant: self.variant,
452            child: frust::authoring::build_child(&self.child, ctx),
453            interactive: self.on_press.is_some(),
454            enabled: self.enabled,
455            haptic: self.haptic,
456            captured: false,
457            state: InteractionState::new(),
458            elevation_anim: AnimationController::new(Duration::ZERO),
459            elevation_target: false,
460            on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
461        }
462    }
463
464    fn rebuild(
465        &self,
466        prev: &Self,
467        element: &mut CardWidget,
468        ctx: &mut BuildCtx<'_>,
469    ) -> ChangeFlags {
470        let mut flags = ChangeFlags::NONE;
471        if prev.variant != self.variant {
472            element.variant = self.variant;
473            flags |= ChangeFlags::PAINT;
474        }
475        let now_interactive = self.on_press.is_some();
476        if element.interactive != now_interactive {
477            element.interactive = now_interactive;
478            // Losing the interactive surface mid-gesture must not leave a
479            // dangling capture, press, or hover behind.
480            if !now_interactive {
481                element.captured = false;
482                element.state.set_pressed(false);
483                element.state.set_hovered(false);
484            }
485            flags |= ChangeFlags::PAINT;
486        }
487        if prev.enabled != self.enabled {
488            element.enabled = self.enabled;
489            if !self.enabled && element.interactive {
490                // A card disabled mid-gesture keeps neither the press nor
491                // the hover it was holding — mirrors `switch`/`radio`'s
492                // identical disabled-mid-interaction clear.
493                element.captured = false;
494                element.state.set_pressed(false);
495                element.state.set_hovered(false);
496            }
497            if element.interactive {
498                flags |= ChangeFlags::PAINT;
499            }
500        }
501        element.haptic = self.haptic;
502        element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
503        flags |= frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
504        flags
505    }
506
507    fn teardown(&self, element: &mut CardWidget, ctx: &mut BuildCtx<'_>) {
508        frust::authoring::teardown_child(&self.child, &mut element.child, ctx);
509    }
510}
511
512impl Widget for CardWidget {
513    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
514        let inset = CARD_PADDING * 2.0;
515        let inner_max = Size::new(
516            (bc.max().width - inset).max(0.0),
517            (bc.max().height - inset).max(0.0),
518        );
519        let inner_min = Size::new(
520            (bc.min().width - inset).max(0.0),
521            (bc.min().height - inset).max(0.0),
522        );
523        let child_size = self
524            .child
525            .layout_child(ctx, &BoxConstraints::new(inner_min, inner_max));
526        self.child
527            .set_origin(Point::new(CARD_PADDING, CARD_PADDING));
528        bc.constrain(Size::new(
529            child_size.width + inset,
530            child_size.height + inset,
531        ))
532    }
533
534    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
535        // Every theme read happens here, before `ctx` is taken mutably below
536        // (`request_frame`) — mirrors `button::ButtonWidget::paint`'s own
537        // documented ordering.
538        let theme = Theme::from_paint_ctx(ctx);
539        let disabled = self.interactive && !self.enabled;
540        let container = resolve_container(theme, self.variant, disabled);
541        let radius = resolve_radius(theme);
542        // Authoritative hover read, self-correcting the latched flag —
543        // inert while non-interactive or disabled (`docs/CODE_STANDARDS.md`'s
544        // Interaction Semantics; the radio-hover-regression fix: never react
545        // to hover while disabled).
546        let hovered = self.interactive && self.enabled && ctx.is_hovered();
547        let (elevation_rest, elevation_hover) = resolve_elevation_endpoints(theme);
548        let shadow_color = resolve_shadow_color(theme);
549        let outline =
550            (self.variant == CardVariant::Outlined).then(|| resolve_outline(theme, disabled));
551        // `theme`'s last use: resolved here, before `ctx` is taken mutably
552        // below (`request_frame`) — mirrors `button::ButtonWidget::paint`'s
553        // own documented ordering.
554        let content_color = resolve_content_color(theme);
555
556        self.state.set_hovered(hovered);
557
558        let o = ctx.origin();
559        let size = ctx.size();
560
561        if self.variant == CardVariant::Elevated {
562            if hovered != self.elevation_target {
563                let velocity = if hovered {
564                    ELEVATION_RETARGET_VELOCITY
565                } else {
566                    -ELEVATION_RETARGET_VELOCITY
567                };
568                self.elevation_anim
569                    .fling(velocity, PressSpringId::DefaultEffects.resolve());
570                self.elevation_target = hovered;
571            }
572            if self.elevation_anim.advance(ctx.frame_time()) {
573                ctx.request_frame();
574            }
575            let t = self.elevation_anim.value_clamped();
576            let blur = Tween::new(elevation_rest.0, elevation_hover.0).lerp(t);
577            let y_offset = Tween::new(elevation_rest.1, elevation_hover.1).lerp(t);
578            scene.draw_shadow(
579                Point::new(o.x, o.y + y_offset),
580                size,
581                radius,
582                blur,
583                shadow_color,
584            );
585        }
586
587        scene.fill_rounded_rect(o, size, radius, container);
588
589        if let Some(outline) = outline {
590            // Inset by half the stroke width so the 1dp line paints fully
591            // inside the card's own bounds (a stroke is centered on its path).
592            let half = STROKE_WIDTH / 2.0;
593            let rr = RoundedRect::new(
594                half,
595                half,
596                size.width - half,
597                size.height - half,
598                (radius - half).max(0.0),
599            );
600            let path = rr.to_path(STROKE_TOLERANCE);
601            scene.stroke_path(o, &path, STROKE_WIDTH, &Brush::Solid(outline));
602        }
603
604        if self.interactive {
605            let opacity = self.state.resolve_opacity();
606            if opacity > 0.0 {
607                scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
608            }
609        }
610
611        self.child.paint_child(ctx, scene);
612    }
613
614    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
615        if !self.interactive {
616            return frust::authoring::route_event_single(&mut self.child, ctx, event);
617        }
618        // Non-pointer events (Key, Ime, focus-routed) must be forwarded to
619        // the child, even when interactive. Only pointer events drive the
620        // interactive card's own capture/press/hover behavior.
621        let InputEvent::Pointer(p) = event else {
622            return frust::authoring::route_event_single(&mut self.child, ctx, event);
623        };
624        if !self.enabled {
625            // A disabled interactive card arms nothing and claims no hover —
626            // mirrors `button::core::ButtonWidget::event`'s disabled
627            // early-return, and the radio-hover-regression fix (never react
628            // to hover while disabled). It does not forward to its child
629            // either: a disabled card block is fully inert, not a
630            // pass-through (see the module docs' Disabled state section).
631            return EventResult::Ignored;
632        }
633        let on_press = self
634            .on_press
635            .as_mut()
636            .expect("on_press is set whenever interactive is true");
637        match p.phase {
638            PointerPhase::Down => {
639                if !presses(p) {
640                    return EventResult::Ignored;
641                }
642                self.state.set_pressed(true);
643                self.captured = true;
644                ctx.capture_pointer();
645                ctx.request_redraw();
646                EventResult::Handled
647            }
648            PointerPhase::Move => {
649                if !self.captured {
650                    // No capture: this is the hover pass — claim, latch, and
651                    // let paint self-correct (`docs/CODE_STANDARDS.md`'s
652                    // three-part hover contract; see the [module docs](self)'
653                    // Hover elevation lift section).
654                    let over = inside(p.position, ctx.size());
655                    if over {
656                        ctx.claim_hover();
657                    }
658                    if self.state.set_hovered(over) {
659                        ctx.request_redraw();
660                    }
661                    return EventResult::Ignored;
662                }
663                let inside_now = inside(p.position, ctx.size());
664                if self.state.set_pressed(inside_now) {
665                    ctx.request_redraw();
666                }
667                EventResult::Handled
668            }
669            PointerPhase::Up => {
670                if !self.captured {
671                    return EventResult::Ignored;
672                }
673                if inside(p.position, ctx.size()) {
674                    if self.haptic != HapticSignal::None {
675                        MaterialHaptics::fire(self.haptic);
676                    }
677                    (on_press)(ctx);
678                }
679                self.state.set_pressed(false);
680                self.captured = false;
681                ctx.request_redraw();
682                EventResult::Handled
683            }
684            PointerPhase::Cancel => {
685                if !self.captured {
686                    return EventResult::Ignored;
687                }
688                self.state.set_pressed(false);
689                self.captured = false;
690                ctx.request_redraw();
691                EventResult::Handled
692            }
693        }
694    }
695
696    fn semantics(&self, ctx: &mut SemanticsCtx) {
697        if self.interactive {
698            ctx.push_container(
699                Role::Button,
700                |node| {
701                    if self.enabled {
702                        node.add_action(Action::Click);
703                    } else {
704                        node.set_disabled();
705                    }
706                },
707                |ctx| {
708                    self.child.semantics_child(ctx);
709                },
710            );
711        } else {
712            ctx.push_container(
713                Role::GenericContainer,
714                |_| {},
715                |ctx| {
716                    self.child.semantics_child(ctx);
717                },
718            );
719        }
720    }
721
722    frust::authoring::visit_children!(child);
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use frust::FrameTime;
729    use frust_widgets::test_support::leaf_any;
730    use std::any::Any;
731
732    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
733        InputEvent::Pointer(frust::authoring::PointerEvent {
734            phase,
735            position: Point::new(x, y),
736            button: frust::authoring::PointerButton::Primary,
737        })
738    }
739
740    fn ft_secs(s: f64) -> FrameTime {
741        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
742    }
743
744    /// Records each rounded rect's `(origin, size, radius, color)`, each
745    /// shadow's `(origin, size, radius, std_dev, color)`, and each stroked
746    /// path's `(origin, width, color)`.
747    #[derive(Default)]
748    struct Recorder {
749        rrects: Vec<(Point, Size, f64, Color)>,
750        shadows: Vec<(Point, Size, f64, f64, Color)>,
751        strokes: Vec<(Point, f64, Color)>,
752    }
753
754    impl PaintScene for Recorder {
755        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
756        fn draw_text(&mut self, _o: Point, _t: &str) {}
757        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
758            self.rrects.push((o, s, radius, color));
759        }
760        fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
761            self.shadows.push((o, s, radius, std_dev, color));
762        }
763        fn stroke_path(
764            &mut self,
765            origin: Point,
766            _path: &kurbo::BezPath,
767            width: f64,
768            brush: &Brush,
769        ) {
770            if let Brush::Solid(color) = brush {
771                self.strokes.push((origin, width, *color));
772            }
773        }
774    }
775
776    fn build<S: 'static>(view: &CardView<S>) -> CardWidget {
777        let mut counter = 0u64;
778        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
779    }
780
781    fn paint(w: &mut CardWidget, size: Size, theme: Option<&Theme>) -> Recorder {
782        let mut rec = Recorder::default();
783        let mut ctx = match theme {
784            Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
785            None => PaintCtx::new(Point::ZERO, size),
786        };
787        w.paint(&mut ctx, &mut rec);
788        rec
789    }
790
791    #[test]
792    fn layout_insets_child_by_card_padding() {
793        let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
794        let mut w = build(&view);
795        let mut lctx = LayoutCtx::new();
796        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
797        assert_eq!(size, Size::new(40.0 + 32.0, 20.0 + 32.0));
798        assert_eq!(w.child.origin(), Point::new(CARD_PADDING, CARD_PADDING));
799    }
800
801    #[test]
802    fn elevated_unthemed_paints_container_and_shadow() {
803        let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
804        let mut w = build(&view);
805        let rec = paint(&mut w, Size::new(100.0, 60.0), None);
806        assert_eq!(rec.rrects[0].3, ELEVATED_CONTAINER);
807        assert_eq!(rec.rrects[0].2, RADIUS);
808        assert_eq!(rec.shadows.len(), 1);
809        assert_eq!(rec.shadows[0].3, FALLBACK_SHADOW_BLUR);
810        assert!(rec.strokes.is_empty(), "elevated card has no stroke");
811    }
812
813    #[test]
814    fn filled_unthemed_paints_container_with_no_shadow() {
815        let view: CardView<()> = filled_card(leaf_any(40.0, 20.0));
816        let mut w = build(&view);
817        let rec = paint(&mut w, Size::new(100.0, 60.0), None);
818        assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
819        assert!(rec.shadows.is_empty(), "filled card paints no shadow");
820        assert!(rec.strokes.is_empty(), "filled card has no stroke");
821    }
822
823    #[test]
824    fn outlined_unthemed_paints_container_and_stroke_with_no_shadow() {
825        let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
826        let mut w = build(&view);
827        let rec = paint(&mut w, Size::new(100.0, 60.0), None);
828        assert_eq!(rec.rrects[0].3, OUTLINED_CONTAINER);
829        assert!(rec.shadows.is_empty(), "outlined card paints no shadow");
830        assert_eq!(rec.strokes.len(), 1);
831        assert_eq!(rec.strokes[0].1, STROKE_WIDTH);
832        assert_eq!(rec.strokes[0].2, OUTLINE_VARIANT);
833    }
834
835    #[test]
836    fn themed_elevated_paint_shadow_is_unchanged_on_m3_light() {
837        // `ElevationLevel::shadow` carries a per-brightness split
838        // (`shadow_light`/`shadow_dark`), and the M3 v1 mapping duplicates
839        // the same value into both slots. This pins the elevated card's
840        // rendered (resting, unhovered) shadow on `Brightness::Light` to
841        // `theme.elevation.level1`'s shadow spec, byte-identical to the
842        // pre-migration single-field output.
843        let theme = crate::baseline();
844        assert_eq!(theme.brightness, frust::Brightness::Light);
845        let level1 = theme.elevation.level1;
846
847        let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
848        let mut w = build(&view);
849        let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
850
851        assert_eq!(rec.shadows.len(), 1);
852        assert_eq!(rec.shadows[0].3, level1.shadow_light.blur_std_dev);
853        assert_eq!(
854            rec.shadows[0].4.components[3],
855            level1.shadow_light.color_alpha
856        );
857        // The two brightness slots are identical for M3 (behavior-preserving
858        // duplication), so the accessor call resolves to the same values.
859        assert_eq!(
860            rec.shadows[0].3,
861            level1.shadow(theme.brightness).blur_std_dev
862        );
863    }
864
865    #[test]
866    fn themed_paint_resolves_r11_tokens() {
867        let theme = crate::baseline();
868        let scheme = theme.scheme();
869
870        let elevated: CardView<()> = elevated_card(leaf_any(40.0, 20.0));
871        let mut w = build(&elevated);
872        let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
873        assert_eq!(rec.rrects[0].3, scheme.surface_container_low);
874        assert_eq!(rec.rrects[0].2, theme.shape.medium);
875
876        let filled: CardView<()> = filled_card(leaf_any(40.0, 20.0));
877        let mut w = build(&filled);
878        let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
879        assert_eq!(rec.rrects[0].3, scheme.surface_container_highest);
880
881        let outlined: CardView<()> = outlined_card(leaf_any(40.0, 20.0));
882        let mut w = build(&outlined);
883        let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
884        assert_eq!(rec.rrects[0].3, scheme.surface);
885        assert_eq!(rec.strokes[0].2, scheme.outline_variant);
886    }
887
888    // --- Disabled interactive card: dimmed styling, no shadow lift ---
889
890    #[test]
891    fn disabled_interactive_card_dims_container_uniformly_across_variants() {
892        let theme = crate::baseline();
893        let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
894        for variant in [
895            CardVariant::Elevated,
896            CardVariant::Filled,
897            CardVariant::Outlined,
898        ] {
899            let view: CardView<()> = card(variant, leaf_any(40.0, 20.0))
900                .on_press(|_: &mut ()| {})
901                .enabled(false);
902            let mut w = build(&view);
903            let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
904            assert_eq!(
905                rec.rrects[0].3, expected,
906                "{variant:?} container dims while disabled"
907            );
908        }
909    }
910
911    #[test]
912    fn disabled_interactive_outlined_card_dims_its_outline_too() {
913        let theme = crate::baseline();
914        let expected = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
915        let view: CardView<()> = outlined_card(leaf_any(40.0, 20.0))
916            .on_press(|_: &mut ()| {})
917            .enabled(false);
918        let mut w = build(&view);
919        let rec = paint(&mut w, Size::new(100.0, 60.0), Some(&theme));
920        assert_eq!(rec.strokes[0].2, expected);
921    }
922
923    #[test]
924    fn disabled_interactive_elevated_card_still_rests_at_level1_unlifted() {
925        let view: CardView<()> = elevated_card(leaf_any(40.0, 20.0))
926            .on_press(|_: &mut ()| {})
927            .enabled(false);
928        let mut w = build(&view);
929        let rec = paint(&mut w, Size::new(100.0, 60.0), None);
930        assert_eq!(rec.shadows.len(), 1);
931        assert_eq!(
932            rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
933            "never lifts: a disabled card never hovers"
934        );
935        assert!(
936            rec.rrects.get(1).is_none(),
937            "no state-layer overlay while disabled"
938        );
939    }
940
941    // --- Non-interactive: transparent event routing to the child ---
942
943    #[derive(Default)]
944    struct Counter {
945        presses: u32,
946    }
947
948    fn dispatch<S: 'static>(w: &mut CardWidget, state: &mut S, event: &InputEvent) -> EventResult {
949        let state_any: &mut dyn Any = state;
950        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, 200.0));
951        w.event(&mut ctx, event)
952    }
953
954    #[test]
955    fn non_interactive_card_forwards_events_to_its_child() {
956        let view: CardView<Counter> =
957            elevated_card(frust::button::<Counter, _>("go", |s: &mut Counter| {
958                s.presses += 1
959            }));
960        let mut w = build(&view);
961        let mut tcx = frust::authoring::text::TextContext::new();
962        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
963        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
964
965        let origin = w.child.origin();
966        let mut state = Counter::default();
967        dispatch(
968            &mut w,
969            &mut state,
970            &ev(PointerPhase::Down, origin.x + 2.0, origin.y + 2.0),
971        );
972        dispatch(
973            &mut w,
974            &mut state,
975            &ev(PointerPhase::Up, origin.x + 2.0, origin.y + 2.0),
976        );
977        assert_eq!(state.presses, 1, "the nested button fires through the card");
978    }
979
980    // --- Interactive: whole-surface capture ---
981
982    /// A minimal `State`-generic content stand-in (`test_support::leaf_any`
983    /// only produces an `AnyView<()>`; the interactive tests below need a
984    /// generic `State`, so a plain [`frust::text`] run stands in for
985    /// the child content — irrelevant to firing behavior).
986    fn content_stub<S: 'static>() -> AnyView<S> {
987        any::<S, _>(frust::text("content"))
988    }
989
990    #[test]
991    fn interactive_card_fires_on_up_inside_without_forwarding_to_child() {
992        let view: CardView<Counter> =
993            filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
994        let mut w = build(&view);
995        let mut state = Counter::default();
996        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
997        assert!(w.captured);
998        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
999        assert_eq!(state.presses, 1);
1000    }
1001
1002    #[test]
1003    fn interactive_card_up_outside_does_not_fire() {
1004        let view: CardView<Counter> =
1005            filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
1006        let mut w = build(&view);
1007        let mut state = Counter::default();
1008        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1009        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 500.0));
1010        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 500.0));
1011        assert_eq!(state.presses, 0);
1012    }
1013
1014    #[test]
1015    fn interactive_card_cancel_clears_armed_state() {
1016        let view: CardView<Counter> =
1017            filled_card(content_stub::<Counter>()).on_press(|s: &mut Counter| s.presses += 1);
1018        let mut w = build(&view);
1019        let mut state = Counter::default();
1020        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
1021        assert!(w.captured);
1022        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 5.0));
1023        assert!(!w.captured);
1024        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1025        assert_eq!(state.presses, 0);
1026    }
1027
1028    #[test]
1029    fn disabled_interactive_card_ignores_every_pointer_phase() {
1030        let view: CardView<Counter> = filled_card(content_stub::<Counter>())
1031            .on_press(|s: &mut Counter| s.presses += 1)
1032            .enabled(false);
1033        let mut w = build(&view);
1034        let mut state = Counter::default();
1035        assert_eq!(
1036            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0)),
1037            EventResult::Ignored
1038        );
1039        assert!(!w.captured, "a disabled card never captures");
1040        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
1041        assert_eq!(state.presses, 0, "and never fires");
1042    }
1043
1044    // --- Interactive with focus-routed child events ---
1045
1046    /// A minimal widget that consumes focus and handles Key events for testing.
1047    struct FocusConsumer;
1048
1049    impl FocusConsumer {
1050        fn new() -> Self {
1051            FocusConsumer
1052        }
1053    }
1054
1055    struct FocusConsumerWidget;
1056
1057    impl View<Counter> for FocusConsumer {
1058        type Element = FocusConsumerWidget;
1059        fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
1060            FocusConsumerWidget
1061        }
1062        fn rebuild(
1063            &self,
1064            _prev: &Self,
1065            _element: &mut FocusConsumerWidget,
1066            _ctx: &mut BuildCtx<'_>,
1067        ) -> frust::authoring::ChangeFlags {
1068            frust::authoring::ChangeFlags::NONE
1069        }
1070    }
1071
1072    impl Widget for FocusConsumerWidget {
1073        fn layout(&mut self, _ctx: &mut frust::authoring::LayoutCtx, bc: &BoxConstraints) -> Size {
1074            bc.max()
1075        }
1076        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1077        fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1078            // Handle any key event (focus-routed events reach here only if the
1079            // pod is focused, so a Key event here proves the routing worked).
1080            if let InputEvent::Key(_) = event {
1081                return EventResult::Handled;
1082            }
1083            EventResult::Ignored
1084        }
1085    }
1086
1087    #[test]
1088    fn interactive_card_forwards_key_events_to_focused_child() {
1089        let view: CardView<Counter> =
1090            filled_card(FocusConsumer::new()).on_press(|s: &mut Counter| s.presses += 1);
1091        let mut w = build(&view);
1092        let mut state = Counter::default();
1093
1094        // Manually set the child as focused to simulate a prior focus state.
1095        // (In real usage, the child would be focused by a pointer-down event,
1096        // but here we're directly testing the event-routing path.)
1097        w.child.set_focused(true);
1098
1099        // Send a Key event — it should be forwarded to the focused child.
1100        let key_event = InputEvent::Key(frust::authoring::KeyEvent {
1101            key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
1102            modifiers: frust::authoring::Modifiers::default(),
1103            repeat: false,
1104        });
1105        let result = dispatch(&mut w, &mut state, &key_event);
1106
1107        // Verify the key event was handled (forwarded to and handled by child).
1108        assert_eq!(
1109            result,
1110            EventResult::Handled,
1111            "key event must be forwarded to child"
1112        );
1113
1114        // The card press should not have fired (the card only fires on pointer Up).
1115        assert_eq!(
1116            state.presses, 0,
1117            "card press callback does not fire on key event"
1118        );
1119    }
1120
1121    #[test]
1122    fn semantics_interactive_card_is_a_button_container_forwarding_child() {
1123        // A leaf without its own `semantics()` override (e.g. `test_support`'s
1124        // `Leaf`) contributes no node — use a `Text` child, which does, so the
1125        // "forwards the child's semantics" assertion is meaningful.
1126        fn logic(_s: &mut ()) -> CardView<()> {
1127            filled_card(frust::text("body")).on_press(|_s: &mut ()| {})
1128        }
1129        let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1130        let mut state = ();
1131        root.rebuild(&mut logic, &mut state);
1132        let mut tcx = frust::authoring::text::TextContext::new();
1133        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1134        let update = root.semantics();
1135        let (_, node) = update
1136            .nodes
1137            .iter()
1138            .find(|(_, n)| n.role() == Role::Button)
1139            .expect("interactive card contributes a Role::Button container node");
1140        assert!(
1141            !node.children().is_empty(),
1142            "the child is a semantics child"
1143        );
1144        assert!(!node.is_disabled());
1145    }
1146
1147    #[test]
1148    fn semantics_disabled_interactive_card_reports_disabled_and_no_click_action() {
1149        fn logic(_s: &mut ()) -> CardView<()> {
1150            filled_card(frust::text("body"))
1151                .on_press(|_s: &mut ()| {})
1152                .enabled(false)
1153        }
1154        let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1155        let mut state = ();
1156        root.rebuild(&mut logic, &mut state);
1157        let mut tcx = frust::authoring::text::TextContext::new();
1158        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1159        let update = root.semantics();
1160        let (_, node) = update
1161            .nodes
1162            .iter()
1163            .find(|(_, n)| n.role() == Role::Button)
1164            .expect("a disabled interactive card still contributes a Role::Button node");
1165        assert!(node.is_disabled());
1166    }
1167
1168    #[test]
1169    fn semantics_non_interactive_card_is_a_generic_container() {
1170        fn logic(_s: &mut ()) -> CardView<()> {
1171            filled_card(frust::text("body"))
1172        }
1173        let mut root: frust_core::RenderRoot<(), CardView<()>> = frust_core::RenderRoot::new();
1174        let mut state = ();
1175        root.rebuild(&mut logic, &mut state);
1176        let mut tcx = frust::authoring::text::TextContext::new();
1177        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
1178        let update = root.semantics();
1179        let (_, node) = update
1180            .nodes
1181            .iter()
1182            .find(|(_, n)| n.role() == Role::GenericContainer)
1183            .expect("non-interactive card contributes a Role::GenericContainer node");
1184        assert!(
1185            !node.children().is_empty(),
1186            "the child is a semantics child"
1187        );
1188    }
1189
1190    // --- Hover elevation lift + press state layer (through a real RenderRoot) ---
1191    //
1192    // Hover and focus are *authoritative reads* off `PaintCtx`, seeded by the
1193    // pod chain, so a bare `PaintCtx` can never fake them — only a real tree
1194    // can (the pattern `list_item.rs`'s own hover harness uses, and
1195    // `button::core`'s own `Harness` mirrors). These tests dispatch through a
1196    // real `frust_core::RenderRoot`.
1197
1198    const HARNESS_WINDOW: Size = Size::new(300.0, 200.0);
1199
1200    struct Harness {
1201        root: frust_core::RenderRoot<u32, CardView<u32>>,
1202        state: u32,
1203        tcx: frust::authoring::text::TextContext,
1204        variant: CardVariant,
1205        enabled: bool,
1206    }
1207
1208    impl Harness {
1209        fn new(variant: CardVariant, enabled: bool) -> Self {
1210            let mut h = Self {
1211                root: frust_core::RenderRoot::new(),
1212                state: 0,
1213                tcx: frust::authoring::text::TextContext::new(),
1214                variant,
1215                enabled,
1216            };
1217            h.sync();
1218            h
1219        }
1220
1221        fn sync(&mut self) {
1222            let (variant, enabled) = (self.variant, self.enabled);
1223            let mut app = move |_: &mut u32| {
1224                card::<u32, _>(variant, content_stub::<u32>())
1225                    .on_press(|s: &mut u32| *s += 1)
1226                    .enabled(enabled)
1227            };
1228            self.root.rebuild(&mut app, &mut self.state);
1229            self.root
1230                .layout_with_text(HARNESS_WINDOW, &mut self.tcx as &mut dyn Any);
1231        }
1232
1233        fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1234            self.root.event(
1235                &mut self.state,
1236                &InputEvent::Pointer(frust::authoring::PointerEvent {
1237                    phase,
1238                    position: Point::new(x, y),
1239                    button: frust::authoring::PointerButton::Primary,
1240                }),
1241            );
1242        }
1243
1244        fn paint_at(&mut self, ft: FrameTime) -> Recorder {
1245            let mut rec = Recorder::default();
1246            self.root.paint(&mut rec, ft);
1247            rec
1248        }
1249
1250        /// The alpha of the state-layer overlay painted this frame, if any
1251        /// (the container fill is always the first rect).
1252        fn overlay_alpha(&mut self, ft: FrameTime) -> Option<f32> {
1253            let rec = self.paint_at(ft);
1254            rec.rrects
1255                .get(1)
1256                .map(|(_, _, _, color)| color.components[3])
1257        }
1258    }
1259
1260    #[test]
1261    fn hover_lifts_the_elevated_card_shadow_from_level1_to_level2_and_back() {
1262        let mut h = Harness::new(CardVariant::Elevated, true);
1263        let rest = h.paint_at(FrameTime::ZERO);
1264        assert_eq!(rest.shadows[0].3, FALLBACK_SHADOW_BLUR, "rests at level1");
1265        assert_eq!(rest.shadows[0].0, Point::new(0.0, FALLBACK_SHADOW_Y_OFFSET));
1266
1267        h.dispatch(PointerPhase::Move, 10.0, 10.0);
1268        // First paint after the fling starts only seeds the spring's clock
1269        // (`AnimationController::advance`'s own documented first-call
1270        // contract).
1271        h.paint_at(ft_secs(0.0));
1272        let settled = h.paint_at(ft_secs(1.0));
1273        assert_eq!(
1274            settled.shadows[0].3, FALLBACK_HOVER_SHADOW_BLUR,
1275            "settles at level2's blur once the hover-lift spring settles"
1276        );
1277        assert_eq!(
1278            settled.shadows[0].0,
1279            Point::new(0.0, FALLBACK_HOVER_SHADOW_Y_OFFSET)
1280        );
1281
1282        h.dispatch(PointerPhase::Move, 290.0, 190.0);
1283        h.paint_at(ft_secs(1.0));
1284        let dropped = h.paint_at(ft_secs(2.0));
1285        assert_eq!(
1286            dropped.shadows[0].3, FALLBACK_SHADOW_BLUR,
1287            "drops back to level1 once unhovered"
1288        );
1289    }
1290
1291    #[test]
1292    fn hover_never_lifts_a_disabled_interactive_card() {
1293        let mut h = Harness::new(CardVariant::Elevated, false);
1294        h.dispatch(PointerPhase::Move, 10.0, 10.0);
1295        let rec = h.paint_at(ft_secs(1.0));
1296        assert_eq!(
1297            rec.shadows[0].3, FALLBACK_SHADOW_BLUR,
1298            "a disabled card never reacts to hover (the radio-hover-regression fix)"
1299        );
1300        h.dispatch(PointerPhase::Down, 10.0, 10.0);
1301        h.dispatch(PointerPhase::Up, 10.0, 10.0);
1302        assert_eq!(h.state, 0, "and never fires either");
1303    }
1304
1305    #[test]
1306    fn hovering_paints_the_hover_state_layer_and_pressing_outranks_it() {
1307        let mut h = Harness::new(CardVariant::Filled, true);
1308        assert_eq!(h.overlay_alpha(FrameTime::ZERO), None, "no overlay at rest");
1309
1310        h.dispatch(PointerPhase::Move, 10.0, 10.0);
1311        assert_eq!(
1312            h.overlay_alpha(ft_secs(0.0)),
1313            Some(crate::interaction::HOVER_OPACITY),
1314            "the hovered card tints at the M3E hover opacity"
1315        );
1316
1317        h.dispatch(PointerPhase::Down, 10.0, 10.0);
1318        assert_eq!(
1319            h.overlay_alpha(ft_secs(0.0)),
1320            Some(frust::authoring::PRESSED_OPACITY),
1321            "pressed outranks hover in the precedence order"
1322        );
1323
1324        h.dispatch(PointerPhase::Up, 10.0, 10.0);
1325        assert_eq!(h.state, 1, "and the release fired the callback");
1326    }
1327
1328    #[test]
1329    fn disabled_card_never_tints_and_never_fires() {
1330        let mut h = Harness::new(CardVariant::Filled, false);
1331        h.dispatch(PointerPhase::Move, 10.0, 10.0);
1332        assert_eq!(
1333            h.overlay_alpha(ft_secs(0.0)),
1334            None,
1335            "a disabled card claims no hover"
1336        );
1337        h.dispatch(PointerPhase::Down, 10.0, 10.0);
1338        h.dispatch(PointerPhase::Up, 10.0, 10.0);
1339        assert_eq!(h.state, 0);
1340    }
1341}