Skip to main content

frust_material/
card_list.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/lists/ (`m3e_lists.dart`'s `M3ECardList`,
4//   `components/m3e_card_list_item.dart`'s `calculateCardPosition`/
5//   `calculateCardRadius`, `styles/m3e_list_theme.dart`'s
6//   `M3EListCardListTheme`, `enums/m3e_list_enums.dart`'s `M3ECardPosition`)
7// The `.builder` (lazy `ListView`) constructor, `M3ECardRadiusMotion`'s
8// radius spring, `onLongPress`, and the per-index `colorBuilder`/
9// `borderRadiusBuilder` escape hatches stay unported — see the module docs'
10// Not ported section.
11
12//! The M3E **card list**: a vertical stack of card-backed rows whose corner
13//! radii vary by position — the first and last cards keep a large *outer*
14//! radius on their outward edges, while every adjoining edge takes a small
15//! *inner* radius, so a run of cards reads as one grouped surface.
16//!
17//! # Corner matrix
18//!
19//! [`card_position`] classifies each index and [`card_radii`] resolves it into
20//! a [`frust::authoring::CornerRadii`] (`calculateCardPosition` /
21//! `calculateCardRadius`):
22//!
23//! | Position | top-left / top-right | bottom-right / bottom-left |
24//! |---|---|---|
25//! | [`CardPosition::Single`] | outer | outer |
26//! | [`CardPosition::First`] | outer | inner |
27//! | [`CardPosition::Middle`] | inner | inner |
28//! | [`CardPosition::Last`] | inner | outer |
29//!
30//! Defaults are the reference's own (`M3EListCardListTheme`'s
31//! `defaultOuterRadius`/`defaultInnerRadius`/`defaultGap`/
32//! `defaultItemPadding`): [`CARD_LIST_OUTER_RADIUS`] 24dp,
33//! [`CARD_LIST_INNER_RADIUS`] 4dp, [`CARD_LIST_GAP`] 4dp between cards, and
34//! [`CARD_LIST_ITEM_PADDING`] 12dp inside each one.
35//!
36//! # Children
37//!
38//! [`card_list`] takes arbitrary [`frust::authoring::AnyView`] children, the
39//! way upstream's `itemBuilder` does. [`card_list_items`] is the typed
40//! convenience for the common case — a list of [`super::list_item::ListItem`]
41//! rows, erased for you.
42//!
43//! A row needs no marking to be hosted: a [`super::list_item::ListItem`]
44//! paints no container of its own unless asked
45//! ([`super::list_item::ListItem::contained`]), which is exactly the state
46//! upstream's `M3EListItemScope` puts a hosted row in — the card below it is
47//! the only surface. Passing an explicitly `contained` row into this stack
48//! doubles the surfaces up and is the one combination to avoid.
49//!
50//! # Surfaces and states
51//!
52//! Each card paints the filled-card role (`colors.surface_container_highest`,
53//! `M3EListCardListTheme.backgroundColor`), or the selection fill
54//! (`colors.secondary_container`, the `m3eSelectionFill` a selection scope
55//! contributes upstream) for an index passed to [`CardListView::selected`]; an
56//! explicit [`CardListView::color`] replaces the unselected fill.
57//!
58//! [`CardListView::on_press`] makes the whole stack interactive with a
59//! per-index callback: the list owns capture (the same fire-on-up-inside
60//! contract [`mod@super::card`] and [`mod@super::list_item`] follow), tracks
61//! which card is armed, and paints that card's
62//! [`crate::interaction::InteractionState`] overlay at *its* radii, tinted
63//! `on_surface`. Hover follows the catalog's three-part contract
64//! (claim → latch → self-correct in paint) and is **enabled-gated**. Without
65//! `on_press` the list is a plain container that routes pointer events into
66//! its children, so a row's own [`super::list_item::ListItem::on_press`] (or a
67//! trailing control) stays live.
68//!
69//! [`CardListView::enabled`]`(false)` dims every card to `on_surface` at
70//! [`crate::interaction::DISABLED_CONTAINER_OPACITY`], claims no hover, paints
71//! no state layer, fires nothing, and — mirroring `card`'s own disabled
72//! early-return — routes no pointer event into its children either.
73//!
74//! # Semantics
75//!
76//! The stack contributes one [`Role::List`] container node whose accesskit
77//! children are the rows' own nodes (a hosted
78//! [`super::list_item::ListItem`] contributes its `Role::ListItem` node with
79//! *its* bounds). An interactive list carries [`Action::Click`] on that
80//! container node, and `Node::set_disabled()` instead while disabled:
81//! [`frust::authoring::SemanticsCtx`] exposes no seam for contributing a node
82//! at a *child's* bounds, so a synthetic per-card node would publish the whole
83//! stack's rect for every row — worse than one honest container node. An app
84//! that needs per-row actions in the accessibility tree puts `on_press` on
85//! each [`super::list_item::ListItem`] instead of on the list.
86//!
87//! # Not ported
88//!
89//! * `M3ECardList.builder` — the lazy `ListView.builder` variant (and its
90//!   `controller`/`physics`/`shrinkWrap`/`listPadding` knobs). This container
91//!   materializes every child, like the default `Column` constructor;
92//!   [`frust::list_view`] is the framework's lazy list.
93//! * `M3ECardRadiusMotion` — upstream springs a card's radii
94//!   (`expressiveSpatialDefault`) whenever its position changes. This port
95//!   applies the resolved radii directly, the same shape upstream's own
96//!   `snap: true` path takes: a card's position only changes on a structural
97//!   rebuild, and a per-card spring controller set is out of this module's
98//!   scope. Documented rather than silently dropped — see the same
99//!   simplification note [`mod@super::fab`] carries for its hover elevation.
100//! * `onLongPress` — no long-press gesture primitive exists in this crate yet
101//!   (the v1 scope line [`mod@super::button`] and [`mod@super::card`] both
102//!   draw).
103//! * The per-index `colorBuilder`/`borderRadiusBuilder`, `mouseCursor`,
104//!   `semanticLabelBuilder`, `emptyBuilder`, `variant`/`border` and
105//!   `margin`/`listPadding` escape hatches — the same customization surface
106//!   `card` leaves unported. [`CardListView::color`]/
107//!   [`CardListView::selected`] cover the fills those builders are reached
108//!   for in practice.
109
110use std::collections::BTreeSet;
111use std::rc::Rc;
112
113use frust::Theme;
114use frust::authoring::{Action, Role};
115use frust::authoring::{
116    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, ErasedArgCallback,
117    EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
118    View, Widget,
119};
120use kurbo::{Point, Size};
121use peniko::Color;
122
123use crate::interaction::{
124    DISABLED_CONTAINER_OPACITY, HapticSignal, InteractionState, MaterialHaptics,
125};
126use crate::list_item::ListItem;
127
128use super::press::presses;
129
130/// Outward corner radius of the first/last (and only) card
131/// (`M3EListCardListTheme.defaultOuterRadius`, 24dp).
132pub const CARD_LIST_OUTER_RADIUS: f64 = 24.0;
133/// Corner radius of every adjoining card edge
134/// (`M3EListCardListTheme.defaultInnerRadius`, 4dp).
135pub const CARD_LIST_INNER_RADIUS: f64 = 4.0;
136/// Vertical gap between adjacent cards
137/// (`M3EListCardListTheme.defaultGap`, 4dp).
138pub const CARD_LIST_GAP: f64 = 4.0;
139/// Content padding inside each card
140/// (`M3EListCardListTheme.defaultItemPadding`, `EdgeInsets.all(12)`).
141pub const CARD_LIST_ITEM_PADDING: f64 = 12.0;
142
143/// Unthemed-fallback card fill (a theme resolves this from
144/// `colors.surface_container_highest`).
145const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
146/// Unthemed-fallback selected-card fill (a theme resolves this from
147/// `colors.secondary_container`).
148const SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
149/// Unthemed-fallback state-layer content color (a theme resolves this from
150/// `colors.on_surface`).
151const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
152
153/// A card's place in the stack, which decides its corner radii
154/// (`M3ECardPosition`). See the [module docs](self)' corner matrix.
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum CardPosition {
157    /// The first card of a stack holding more than one.
158    First,
159    /// A card between the first and the last.
160    Middle,
161    /// The last card of a stack holding more than one.
162    Last,
163    /// The only card in the stack.
164    Single,
165}
166
167/// Classify index `index` of a `total`-card stack (`calculateCardPosition`).
168/// An empty stack has no positions to classify; `index >= total` is treated as
169/// the last card rather than panicking, since a caller can only reach it
170/// mid-rebuild.
171pub fn card_position(index: usize, total: usize) -> CardPosition {
172    if total <= 1 {
173        CardPosition::Single
174    } else if index == 0 {
175        CardPosition::First
176    } else if index + 1 >= total {
177        CardPosition::Last
178    } else {
179        CardPosition::Middle
180    }
181}
182
183/// The corner radii for `position` (`calculateCardRadius`) — see the
184/// [module docs](self)' corner matrix.
185pub fn card_radii(position: CardPosition, outer: f64, inner: f64) -> CornerRadii {
186    match position {
187        // `CornerRadii::new` is clockwise from the top-left.
188        CardPosition::Single => CornerRadii::uniform(outer),
189        CardPosition::First => CornerRadii::new(outer, outer, inner, inner),
190        CardPosition::Last => CornerRadii::new(inner, inner, outer, outer),
191        CardPosition::Middle => CornerRadii::uniform(inner),
192    }
193}
194
195/// A view-held, typed per-index press callback (erased on build).
196type OnPressIndex<State> = Rc<dyn Fn(&mut State, usize)>;
197
198/// A declarative M3E card list. See the [module docs](self).
199pub struct CardListView<State: 'static> {
200    items: Vec<AnyView<State>>,
201    outer_radius: f64,
202    inner_radius: f64,
203    gap: f64,
204    item_padding: f64,
205    color: Option<Color>,
206    selected: BTreeSet<usize>,
207    enabled: bool,
208    haptic: HapticSignal,
209    on_press: Option<OnPressIndex<State>>,
210}
211
212/// Stack `items` as a card list, each child painted inside its own card
213/// surface at its position's radii (upstream's arbitrary `itemBuilder`
214/// children).
215pub fn card_list<State: 'static>(
216    items: impl IntoIterator<Item = AnyView<State>>,
217) -> CardListView<State> {
218    CardListView {
219        items: items.into_iter().collect(),
220        outer_radius: CARD_LIST_OUTER_RADIUS,
221        inner_radius: CARD_LIST_INNER_RADIUS,
222        gap: CARD_LIST_GAP,
223        item_padding: CARD_LIST_ITEM_PADDING,
224        color: None,
225        selected: BTreeSet::new(),
226        enabled: true,
227        haptic: HapticSignal::None,
228        on_press: None,
229    }
230}
231
232/// Stack [`ListItem`] rows as a card list — the typed convenience over
233/// [`card_list`]. A row paints no container of its own by default, so the
234/// stack's card is the only surface (upstream's `M3EListItemScope` effect);
235/// see the [module docs](self)' Children section.
236pub fn card_list_items<State: 'static>(
237    items: impl IntoIterator<Item = ListItem<State>>,
238) -> CardListView<State> {
239    card_list(items.into_iter().map(frust::authoring::any))
240}
241
242impl<State: 'static> CardListView<State> {
243    /// Override the outward corner radius of the first/last (and only) card.
244    /// Defaults to [`CARD_LIST_OUTER_RADIUS`].
245    pub fn outer_radius(mut self, radius: f64) -> Self {
246        self.outer_radius = radius;
247        self
248    }
249
250    /// Override the radius of every adjoining card edge. Defaults to
251    /// [`CARD_LIST_INNER_RADIUS`].
252    pub fn inner_radius(mut self, radius: f64) -> Self {
253        self.inner_radius = radius;
254        self
255    }
256
257    /// Override the vertical gap between adjacent cards. Defaults to
258    /// [`CARD_LIST_GAP`].
259    pub fn gap(mut self, gap: f64) -> Self {
260        self.gap = gap;
261        self
262    }
263
264    /// Override the content padding inside each card. Defaults to
265    /// [`CARD_LIST_ITEM_PADDING`].
266    pub fn item_padding(mut self, padding: f64) -> Self {
267        self.item_padding = padding;
268        self
269    }
270
271    /// Replace the unselected card fill (`M3ECardList.color`). A selected
272    /// index still takes the selection fill.
273    pub fn color(mut self, color: Color) -> Self {
274        self.color = Some(color);
275        self
276    }
277
278    /// Mark these indices selected — each takes the selection fill
279    /// (`colors.secondary_container`).
280    pub fn selected(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
281        self.selected = indices.into_iter().collect();
282        self
283    }
284
285    /// Gate the interactive treatment (hover/press tint, firing
286    /// [`CardListView::on_press`]/[`CardListView::haptic`]) and dim every
287    /// card. Defaults to `true`; only meaningful once `on_press` is chained.
288    pub fn enabled(mut self, enabled: bool) -> Self {
289        self.enabled = enabled;
290        self
291    }
292
293    /// Set the haptic fired immediately before [`CardListView::on_press`] on a
294    /// successful release (`M3ECardList.haptic`). Defaults to
295    /// [`HapticSignal::None`] (no feedback).
296    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
297        self.haptic = haptic;
298        self
299    }
300
301    /// Make every card interactive, firing `on_press` with the released
302    /// card's index (`M3ECardList.onTap`). See the [module docs](self)'
303    /// Surfaces and states section.
304    pub fn on_press<F: Fn(&mut State, usize) + 'static>(mut self, on_press: F) -> Self {
305        self.on_press = Some(Rc::new(on_press));
306        self
307    }
308}
309
310/// The retained widget for a [`CardListView`].
311pub struct CardListWidget {
312    items: Vec<ChildPod>,
313    outer_radius: f64,
314    inner_radius: f64,
315    gap: f64,
316    item_padding: f64,
317    color: Option<Color>,
318    selected: BTreeSet<usize>,
319    interactive: bool,
320    enabled: bool,
321    haptic: HapticSignal,
322    /// Each card's `(y, height)` in local space, recorded during layout — the
323    /// paint rects and the event pass's hit test both read this.
324    cards: Vec<(f64, f64)>,
325    /// The laid-out stack width; the card rects span it fully
326    /// (`M3ECard(width: double.infinity)`).
327    width: f64,
328    /// The card index a `Down` armed (alongside `capture_pointer`), cleared on
329    /// `Up`/`Cancel`/loss of interactivity or enablement.
330    armed: Option<usize>,
331    /// The card currently painting a state-layer overlay, and its state.
332    active: Option<usize>,
333    state: InteractionState,
334    on_press: Option<ErasedArgCallback<usize>>,
335}
336
337/// Return `color` with its alpha channel replaced by `alpha` (mirrors
338/// [`super::card`]'s helper of the same shape).
339fn with_alpha(color: Color, alpha: f32) -> Color {
340    let c = color.components;
341    Color::new([c[0], c[1], c[2], alpha])
342}
343
344/// The resolved state-layer content color. Themed: `colors.on_surface`.
345/// Unthemed: [`ON_SURFACE`] exactly.
346fn resolve_content_color(theme: Option<&Theme>) -> Color {
347    match theme {
348        Some(theme) => theme.scheme().on_surface,
349        None => ON_SURFACE,
350    }
351}
352
353/// The resolved fill for one card. `disabled` overrides everything with the
354/// dimmed `on_surface` wash [`super::card`] uses; a selected index takes
355/// `colors.secondary_container`; an explicit `override_color` replaces the
356/// filled-card role `colors.surface_container_highest`.
357fn resolve_card_color(
358    theme: Option<&Theme>,
359    override_color: Option<Color>,
360    selected: bool,
361    disabled: bool,
362) -> Color {
363    if disabled {
364        return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
365    }
366    if selected {
367        return match theme {
368            Some(theme) => theme.scheme().secondary_container,
369            None => SELECTED_CONTAINER,
370        };
371    }
372    if let Some(color) = override_color {
373        return color;
374    }
375    match theme {
376        Some(theme) => theme.scheme().surface_container_highest,
377        None => FILLED_CONTAINER,
378    }
379}
380
381impl<State: 'static> View<State> for CardListView<State> {
382    type Element = CardListWidget;
383
384    fn build(&self, ctx: &mut BuildCtx<'_>) -> CardListWidget {
385        let items = self
386            .items
387            .iter()
388            .map(|view| frust::authoring::build_child(view, ctx))
389            .collect();
390        CardListWidget {
391            items,
392            outer_radius: self.outer_radius,
393            inner_radius: self.inner_radius,
394            gap: self.gap,
395            item_padding: self.item_padding,
396            color: self.color,
397            selected: self.selected.clone(),
398            interactive: self.on_press.is_some(),
399            enabled: self.enabled,
400            haptic: self.haptic,
401            cards: Vec::new(),
402            width: 0.0,
403            armed: None,
404            active: None,
405            state: InteractionState::new(),
406            on_press: self
407                .on_press
408                .as_ref()
409                .map(frust::authoring::erase_callback_arg),
410        }
411    }
412
413    fn rebuild(
414        &self,
415        prev: &Self,
416        element: &mut CardListWidget,
417        ctx: &mut BuildCtx<'_>,
418    ) -> ChangeFlags {
419        let mut flags = frust::authoring::rebuild_children(
420            &prev.items,
421            &self.items,
422            &mut element.items,
423            ctx,
424            |view| view,
425            |_| None,
426        );
427
428        if prev.items.len() != self.items.len() {
429            // A structural change invalidates the recorded press: the armed
430            // index may now name a different card (or none at all).
431            element.armed = None;
432            element.active = None;
433            element.state.set_pressed(false);
434            element.state.set_hovered(false);
435            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
436        }
437
438        if element.outer_radius != self.outer_radius
439            || element.inner_radius != self.inner_radius
440            || element.color != self.color
441            || element.selected != self.selected
442        {
443            element.outer_radius = self.outer_radius;
444            element.inner_radius = self.inner_radius;
445            element.color = self.color;
446            element.selected = self.selected.clone();
447            flags |= ChangeFlags::PAINT;
448        }
449        if element.gap != self.gap || element.item_padding != self.item_padding {
450            element.gap = self.gap;
451            element.item_padding = self.item_padding;
452            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
453        }
454
455        let now_interactive = self.on_press.is_some();
456        if element.interactive != now_interactive {
457            element.interactive = now_interactive;
458            if !now_interactive {
459                // Losing the interactive surface mid-gesture must not leave a
460                // dangling capture, press, or hover behind.
461                element.armed = None;
462                element.active = None;
463                element.state.set_pressed(false);
464                element.state.set_hovered(false);
465            }
466            flags |= ChangeFlags::PAINT;
467        }
468        if element.enabled != self.enabled {
469            element.enabled = self.enabled;
470            if !self.enabled {
471                element.armed = None;
472                element.active = None;
473                element.state.set_pressed(false);
474                element.state.set_hovered(false);
475            }
476            flags |= ChangeFlags::PAINT;
477        }
478        element.haptic = self.haptic;
479        element.on_press = self
480            .on_press
481            .as_ref()
482            .map(frust::authoring::erase_callback_arg);
483        flags
484    }
485
486    fn teardown(&self, element: &mut CardListWidget, ctx: &mut BuildCtx<'_>) {
487        for (view, pod) in self.items.iter().zip(element.items.iter_mut()) {
488            frust::authoring::teardown_child(view, pod, ctx);
489        }
490    }
491}
492
493impl CardListWidget {
494    /// The local-space rect of card `index`, or `None` before the first
495    /// layout pass (or past the end).
496    fn card_rect(&self, index: usize) -> Option<(Point, Size)> {
497        let (y, height) = *self.cards.get(index)?;
498        Some((Point::new(0.0, y), Size::new(self.width, height)))
499    }
500
501    /// The card containing local `pos`, if any — a position in a gap between
502    /// two cards belongs to neither.
503    fn card_at(&self, pos: Point) -> Option<usize> {
504        if pos.x < 0.0 || pos.x >= self.width {
505            return None;
506        }
507        self.cards
508            .iter()
509            .position(|(y, height)| pos.y >= *y && pos.y < y + height)
510    }
511
512    /// The radii card `index` of this stack takes.
513    fn radii_at(&self, index: usize) -> CornerRadii {
514        card_radii(
515            card_position(index, self.items.len()),
516            self.outer_radius,
517            self.inner_radius,
518        )
519    }
520
521    /// Clear whatever gesture state a press left behind (shared by the
522    /// `Up`/`Cancel` arms and by the rebuild paths above).
523    fn clear_press(&mut self) {
524        self.armed = None;
525        self.state.set_pressed(false);
526        if !self.state.is_active() {
527            self.active = None;
528        }
529    }
530}
531
532impl Widget for CardListWidget {
533    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
534        let width = if bc.max().width.is_finite() {
535            bc.max().width
536        } else {
537            0.0
538        };
539        self.width = width;
540        let inner_width = (width - self.item_padding * 2.0).max(0.0);
541        let max_height = bc.max().height;
542        // Tight width, loose height: each card spans the stack
543        // (`M3ECard(width: double.infinity)`) and takes its child's height.
544        let child_bc = BoxConstraints::new(
545            Size::new(inner_width, 0.0),
546            Size::new(inner_width, max_height),
547        );
548
549        self.cards.clear();
550        let mut y = 0.0;
551        let count = self.items.len();
552        for (index, pod) in self.items.iter_mut().enumerate() {
553            let child = pod.layout_child(ctx, &child_bc);
554            let card_height = child.height + self.item_padding * 2.0;
555            pod.set_origin(Point::new(self.item_padding, y + self.item_padding));
556            self.cards.push((y, card_height));
557            y += card_height;
558            if index + 1 < count {
559                y += self.gap;
560            }
561        }
562
563        bc.constrain(Size::new(width, y))
564    }
565
566    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
567        // Every theme read happens before `ctx` is taken mutably below
568        // (`paint_child`) — mirrors `card::CardWidget::paint`'s ordering.
569        let theme = Theme::from_paint_ctx(ctx);
570        let disabled = self.interactive && !self.enabled;
571        let content_color = resolve_content_color(theme);
572        let fills: Vec<Color> = (0..self.items.len())
573            .map(|index| {
574                resolve_card_color(theme, self.color, self.selected.contains(&index), disabled)
575            })
576            .collect();
577        // Authoritative hover read, self-correcting the latched flag — inert
578        // while non-interactive or disabled (`docs/CODE_STANDARDS.md`'s
579        // Interaction Semantics; never react to hover while disabled).
580        let hovered = self.interactive && self.enabled && ctx.is_hovered();
581        self.state.set_hovered(hovered);
582        if !hovered && !self.state.pressed {
583            self.active = None;
584        }
585
586        let origin = ctx.origin();
587        let overlay_opacity = if self.interactive && self.enabled {
588            self.state.resolve_opacity()
589        } else {
590            0.0
591        };
592
593        for (index, fill) in fills.iter().enumerate() {
594            let Some((card_origin, size)) = self.card_rect(index) else {
595                continue;
596            };
597            let absolute = Point::new(origin.x + card_origin.x, origin.y + card_origin.y);
598            let radii = self.radii_at(index);
599            scene.fill_rounded_rect_radii(absolute, size, radii, *fill);
600            if overlay_opacity > 0.0 && self.active == Some(index) {
601                scene.fill_rounded_rect_radii(
602                    absolute,
603                    size,
604                    radii,
605                    with_alpha(content_color, overlay_opacity),
606                );
607            }
608        }
609
610        for pod in &mut self.items {
611            pod.paint_child(ctx, scene);
612        }
613    }
614
615    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
616        if !self.interactive {
617            return frust::authoring::route_event(&mut self.items, ctx, event);
618        }
619        // Non-pointer events (Key, Ime, focus-routed) must be forwarded to the
620        // children, even when interactive. Only pointer events drive the
621        // interactive stack's own capture/press/hover behavior.
622        let InputEvent::Pointer(p) = event else {
623            return frust::authoring::route_event(&mut self.items, ctx, event);
624        };
625        if !self.enabled {
626            // A disabled interactive stack arms nothing and claims no hover,
627            // and does not forward to its children either — mirrors `card`'s
628            // disabled early-return.
629            return EventResult::Ignored;
630        }
631        match p.phase {
632            PointerPhase::Down => {
633                if !presses(p) {
634                    return EventResult::Ignored;
635                }
636                let Some(index) = self.card_at(p.position) else {
637                    // The gap between two cards is nobody's target.
638                    return EventResult::Ignored;
639                };
640                self.armed = Some(index);
641                self.active = Some(index);
642                self.state.set_pressed(true);
643                ctx.capture_pointer();
644                ctx.request_redraw();
645                EventResult::Handled
646            }
647            PointerPhase::Move => {
648                let Some(armed) = self.armed else {
649                    // No capture: this is the hover pass — claim, latch, and
650                    // let paint self-correct (`docs/CODE_STANDARDS.md`'s
651                    // three-part hover contract).
652                    let over = self.card_at(p.position);
653                    if over.is_some() {
654                        ctx.claim_hover();
655                    }
656                    let changed = self.state.set_hovered(over.is_some()) || self.active != over;
657                    self.active = over;
658                    if changed {
659                        ctx.request_redraw();
660                    }
661                    return EventResult::Ignored;
662                };
663                let still_on_card = self.card_at(p.position) == Some(armed);
664                if self.state.set_pressed(still_on_card) {
665                    ctx.request_redraw();
666                }
667                EventResult::Handled
668            }
669            PointerPhase::Up => {
670                let Some(armed) = self.armed else {
671                    return EventResult::Ignored;
672                };
673                if self.card_at(p.position) == Some(armed) {
674                    if self.haptic != HapticSignal::None {
675                        MaterialHaptics::fire(self.haptic);
676                    }
677                    if let Some(on_press) = self.on_press.as_mut() {
678                        (on_press)(ctx, armed);
679                    }
680                }
681                self.clear_press();
682                ctx.request_redraw();
683                EventResult::Handled
684            }
685            PointerPhase::Cancel => {
686                if self.armed.is_none() {
687                    return EventResult::Ignored;
688                }
689                self.clear_press();
690                ctx.request_redraw();
691                EventResult::Handled
692            }
693        }
694    }
695
696    fn semantics(&self, ctx: &mut SemanticsCtx) {
697        ctx.push_container(
698            Role::List,
699            |node| {
700                if self.interactive {
701                    if self.enabled {
702                        node.add_action(Action::Click);
703                    } else {
704                        node.set_disabled();
705                    }
706                }
707            },
708            |ctx| {
709                for pod in &self.items {
710                    pod.semantics_child(ctx);
711                }
712            },
713        );
714    }
715
716    frust::authoring::visit_children!(items);
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use frust::authoring::any;
723    use frust::authoring::text::TextContext;
724    use frust_widgets::test_support::leaf_any;
725    use std::any::Any;
726
727    const WIDTH: f64 = 300.0;
728    /// Each test child is this tall, so a card is `ITEM + 2 * padding`.
729    const CHILD_HEIGHT: f64 = 20.0;
730
731    fn card_height() -> f64 {
732        CHILD_HEIGHT + CARD_LIST_ITEM_PADDING * 2.0
733    }
734
735    fn build<S: 'static>(view: &CardListView<S>) -> CardListWidget {
736        let mut counter = 0u64;
737        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
738    }
739
740    fn layout(w: &mut CardListWidget) -> Size {
741        let mut tcx = TextContext::new();
742        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
743        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(WIDTH, 1000.0)))
744    }
745
746    fn leaves(count: usize) -> Vec<AnyView<()>> {
747        (0..count).map(|_| leaf_any(10.0, CHILD_HEIGHT)).collect()
748    }
749
750    /// Records every per-corner rounded rect's `(origin, size, radii, color)`.
751    #[derive(Default)]
752    struct Recorder {
753        rrects: Vec<(Point, Size, CornerRadii, Color)>,
754    }
755
756    impl PaintScene for Recorder {
757        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
758        fn draw_text(&mut self, _o: Point, _t: &str) {}
759        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
760            self.rrects.push((o, s, radii, color));
761        }
762    }
763
764    impl Recorder {
765        /// The opaque card fills (the translucent state-layer overlays are
766        /// told apart by alpha).
767        fn fills(&self) -> Vec<(Point, CornerRadii, Color)> {
768            self.rrects
769                .iter()
770                .filter(|(_, _, _, c)| c.components[3] >= 1.0)
771                .map(|(o, _, radii, c)| (*o, *radii, *c))
772                .collect()
773        }
774
775        /// The translucent state-layer overlays painted this frame.
776        fn overlays(&self) -> Vec<(Point, CornerRadii, f32)> {
777            self.rrects
778                .iter()
779                .filter(|(_, _, _, c)| c.components[3] < 1.0)
780                .map(|(o, _, radii, c)| (*o, *radii, c.components[3]))
781                .collect()
782        }
783    }
784
785    fn paint(w: &mut CardListWidget, theme: Option<&Theme>) -> Recorder {
786        let mut rec = Recorder::default();
787        let size = Size::new(WIDTH, 1000.0);
788        let mut ctx = match theme {
789            Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
790            None => PaintCtx::new(Point::ZERO, size),
791        };
792        w.paint(&mut ctx, &mut rec);
793        rec
794    }
795
796    // --- The corner matrix ---
797
798    #[test]
799    fn card_position_classifies_single_first_middle_last() {
800        assert_eq!(card_position(0, 1), CardPosition::Single);
801        assert_eq!(card_position(0, 3), CardPosition::First);
802        assert_eq!(card_position(1, 3), CardPosition::Middle);
803        assert_eq!(card_position(2, 3), CardPosition::Last);
804        // Degenerate inputs resolve rather than panic.
805        assert_eq!(card_position(0, 0), CardPosition::Single);
806        assert_eq!(card_position(9, 3), CardPosition::Last);
807    }
808
809    #[test]
810    fn card_radii_pins_the_reference_corner_matrix() {
811        let (outer, inner) = (CARD_LIST_OUTER_RADIUS, CARD_LIST_INNER_RADIUS);
812        assert_eq!(
813            card_radii(CardPosition::Single, outer, inner),
814            CornerRadii::uniform(outer)
815        );
816        assert_eq!(
817            card_radii(CardPosition::First, outer, inner),
818            CornerRadii::new(outer, outer, inner, inner),
819            "first: outer on top, inner on the joined bottom edge"
820        );
821        assert_eq!(
822            card_radii(CardPosition::Middle, outer, inner),
823            CornerRadii::uniform(inner)
824        );
825        assert_eq!(
826            card_radii(CardPosition::Last, outer, inner),
827            CornerRadii::new(inner, inner, outer, outer),
828            "last: inner on the joined top edge, outer on the bottom"
829        );
830    }
831
832    #[test]
833    fn a_painted_stack_carries_the_position_radii_in_order() {
834        let view: CardListView<()> = card_list(leaves(3));
835        let mut w = build(&view);
836        layout(&mut w);
837        let rec = paint(&mut w, None);
838        let (outer, inner) = (CARD_LIST_OUTER_RADIUS, CARD_LIST_INNER_RADIUS);
839        let radii: Vec<CornerRadii> = rec.fills().iter().map(|(_, r, _)| *r).collect();
840        assert_eq!(
841            radii,
842            vec![
843                card_radii(CardPosition::First, outer, inner),
844                card_radii(CardPosition::Middle, outer, inner),
845                card_radii(CardPosition::Last, outer, inner),
846            ]
847        );
848    }
849
850    #[test]
851    fn a_lone_card_takes_the_single_radii() {
852        let view: CardListView<()> = card_list(leaves(1));
853        let mut w = build(&view);
854        layout(&mut w);
855        let rec = paint(&mut w, None);
856        let fills = rec.fills();
857        assert_eq!(fills.len(), 1);
858        assert_eq!(fills[0].1, CornerRadii::uniform(CARD_LIST_OUTER_RADIUS));
859    }
860
861    #[test]
862    fn overridden_radii_reach_the_painted_cards() {
863        let view: CardListView<()> = card_list(leaves(2)).outer_radius(30.0).inner_radius(2.0);
864        let mut w = build(&view);
865        layout(&mut w);
866        let rec = paint(&mut w, None);
867        let radii: Vec<CornerRadii> = rec.fills().iter().map(|(_, r, _)| *r).collect();
868        assert_eq!(
869            radii,
870            vec![
871                CornerRadii::new(30.0, 30.0, 2.0, 2.0),
872                CornerRadii::new(2.0, 2.0, 30.0, 30.0),
873            ]
874        );
875    }
876
877    // --- Layout: padding, gap, stacking ---
878
879    #[test]
880    fn cards_stack_with_the_gap_and_pad_their_children() {
881        let view: CardListView<()> = card_list(leaves(3));
882        let mut w = build(&view);
883        let size = layout(&mut w);
884        let card = card_height();
885        assert_eq!(size.width, WIDTH);
886        assert_eq!(size.height, card * 3.0 + CARD_LIST_GAP * 2.0);
887
888        // Each child is inset by the item padding inside its own card.
889        for (index, pod) in w.items.iter().enumerate() {
890            let card_top = (card + CARD_LIST_GAP) * index as f64;
891            assert_eq!(
892                pod.origin(),
893                Point::new(CARD_LIST_ITEM_PADDING, card_top + CARD_LIST_ITEM_PADDING)
894            );
895        }
896        // The last card contributes no trailing gap (`isLast ? 0 : gap`).
897        let (last_y, last_h) = w.cards[2];
898        assert_eq!(last_y + last_h, size.height);
899    }
900
901    #[test]
902    fn an_empty_stack_lays_out_and_paints_nothing() {
903        let view: CardListView<()> = card_list(Vec::new());
904        let mut w = build(&view);
905        let size = layout(&mut w);
906        assert_eq!(size.height, 0.0);
907        assert!(paint(&mut w, None).rrects.is_empty());
908    }
909
910    // --- Fills: default, selected, override, disabled ---
911
912    #[test]
913    fn cards_take_the_filled_role_and_selected_indices_the_selection_fill() {
914        let theme = crate::baseline();
915        let view: CardListView<()> = card_list(leaves(3)).selected([1]);
916        let mut w = build(&view);
917        layout(&mut w);
918        let rec = paint(&mut w, Some(&theme));
919        let colors: Vec<Color> = rec.fills().iter().map(|(_, _, c)| *c).collect();
920        let scheme = theme.scheme();
921        assert_eq!(
922            colors,
923            vec![
924                scheme.surface_container_highest,
925                scheme.secondary_container,
926                scheme.surface_container_highest,
927            ]
928        );
929    }
930
931    #[test]
932    fn an_explicit_color_replaces_the_unselected_fill_only() {
933        let theme = crate::baseline();
934        let custom = Color::from_rgb8(0x10, 0x20, 0x30);
935        let view: CardListView<()> = card_list(leaves(2)).color(custom).selected([1]);
936        let mut w = build(&view);
937        layout(&mut w);
938        let rec = paint(&mut w, Some(&theme));
939        let colors: Vec<Color> = rec.fills().iter().map(|(_, _, c)| *c).collect();
940        assert_eq!(colors, vec![custom, theme.scheme().secondary_container]);
941    }
942
943    #[test]
944    fn a_disabled_interactive_stack_dims_every_card_and_paints_no_overlay() {
945        let theme = crate::baseline();
946        let view: CardListView<()> = card_list(leaves(2))
947            .on_press(|_: &mut (), _| {})
948            .selected([0])
949            .enabled(false);
950        let mut w = build(&view);
951        layout(&mut w);
952        let rec = paint(&mut w, Some(&theme));
953        let dimmed = with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY);
954        // The dim wash is translucent, so it lands in `overlays()` by alpha —
955        // assert against the raw rects instead.
956        assert_eq!(rec.rrects.len(), 2);
957        for (_, _, _, color) in &rec.rrects {
958            assert_eq!(*color, dimmed, "every card dims, selected included");
959        }
960    }
961
962    // --- Interaction ---
963
964    #[derive(Default)]
965    struct Pressed {
966        indices: Vec<usize>,
967    }
968
969    fn dispatch<S: 'static>(
970        w: &mut CardListWidget,
971        state: &mut S,
972        phase: PointerPhase,
973        x: f64,
974        y: f64,
975    ) -> EventResult {
976        let state_any: &mut dyn Any = state;
977        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, 1000.0));
978        w.event(
979            &mut ctx,
980            &InputEvent::Pointer(frust::authoring::PointerEvent {
981                phase,
982                position: Point::new(x, y),
983                button: frust::authoring::PointerButton::Primary,
984            }),
985        )
986    }
987
988    fn interactive_stack() -> CardListWidget {
989        let view: CardListView<Pressed> = card_list(
990            (0..3).map(|_| any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))),
991        )
992        .on_press(|s: &mut Pressed, index| s.indices.push(index));
993        let mut w = build(&view);
994        layout(&mut w);
995        w
996    }
997
998    /// The y-centre of card `index`.
999    fn card_y(index: usize) -> f64 {
1000        (card_height() + CARD_LIST_GAP) * index as f64 + card_height() / 2.0
1001    }
1002
1003    #[test]
1004    fn an_interactive_stack_fires_the_released_cards_index() {
1005        let mut w = interactive_stack();
1006        let mut state = Pressed::default();
1007        dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(1));
1008        assert_eq!(w.armed, Some(1));
1009        dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(1));
1010        assert_eq!(state.indices, vec![1]);
1011        assert_eq!(w.armed, None);
1012    }
1013
1014    #[test]
1015    fn a_release_over_another_card_fires_nothing() {
1016        let mut w = interactive_stack();
1017        let mut state = Pressed::default();
1018        dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0));
1019        dispatch(&mut w, &mut state, PointerPhase::Move, 50.0, card_y(2));
1020        dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(2));
1021        assert!(state.indices.is_empty());
1022    }
1023
1024    #[test]
1025    fn a_press_in_the_gap_between_cards_is_ignored() {
1026        let mut w = interactive_stack();
1027        let mut state = Pressed::default();
1028        // Half a gap past the first card's bottom edge.
1029        let gap_y = card_height() + CARD_LIST_GAP / 2.0;
1030        assert_eq!(
1031            dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, gap_y),
1032            EventResult::Ignored
1033        );
1034        assert_eq!(w.armed, None);
1035    }
1036
1037    #[test]
1038    fn a_secondary_press_never_arms_or_fires() {
1039        let mut w = interactive_stack();
1040        let mut state = Pressed::default();
1041        let state_any: &mut dyn Any = &mut state;
1042        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(WIDTH, 1000.0));
1043        let result = w.event(
1044            &mut ctx,
1045            &InputEvent::Pointer(frust::authoring::PointerEvent {
1046                phase: PointerPhase::Down,
1047                position: Point::new(50.0, card_y(0)),
1048                button: frust::authoring::PointerButton::Secondary,
1049            }),
1050        );
1051        assert_eq!(result, EventResult::Ignored);
1052        assert_eq!(w.armed, None);
1053    }
1054
1055    #[test]
1056    fn a_cancel_clears_the_armed_card_without_firing() {
1057        let mut w = interactive_stack();
1058        let mut state = Pressed::default();
1059        dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0));
1060        dispatch(&mut w, &mut state, PointerPhase::Cancel, 50.0, card_y(0));
1061        assert_eq!(w.armed, None);
1062        dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(0));
1063        assert!(state.indices.is_empty());
1064    }
1065
1066    #[test]
1067    fn a_disabled_stack_ignores_every_pointer_phase() {
1068        let view: CardListView<Pressed> = card_list(
1069            (0..2).map(|_| any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))),
1070        )
1071        .on_press(|s: &mut Pressed, index| s.indices.push(index))
1072        .enabled(false);
1073        let mut w = build(&view);
1074        layout(&mut w);
1075        let mut state = Pressed::default();
1076        assert_eq!(
1077            dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(0)),
1078            EventResult::Ignored
1079        );
1080        dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, card_y(0));
1081        assert!(state.indices.is_empty());
1082        assert_eq!(w.armed, None);
1083    }
1084
1085    #[test]
1086    fn the_pressed_card_paints_its_overlay_at_its_own_radii() {
1087        let mut w = interactive_stack();
1088        let mut state = Pressed::default();
1089        dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, card_y(2));
1090        let rec = paint(&mut w, None);
1091        let overlays = rec.overlays();
1092        assert_eq!(overlays.len(), 1, "only the pressed card tints");
1093        assert_eq!(overlays[0].0.y, w.cards[2].0);
1094        assert_eq!(
1095            overlays[0].1,
1096            card_radii(
1097                CardPosition::Last,
1098                CARD_LIST_OUTER_RADIUS,
1099                CARD_LIST_INNER_RADIUS
1100            )
1101        );
1102        assert_eq!(overlays[0].2, frust::authoring::PRESSED_OPACITY);
1103    }
1104
1105    #[test]
1106    fn a_non_interactive_stack_routes_events_into_its_children() {
1107        // Without a list-level `on_press`, a hosted row's own press handling
1108        // is what runs — the stack is a plain container.
1109        #[derive(Default)]
1110        struct Counter {
1111            presses: u32,
1112        }
1113        let view: CardListView<Counter> = card_list_items(vec![
1114            crate::list_item::list_item::<Counter>("row")
1115                .on_press(|s: &mut Counter| s.presses += 1),
1116        ]);
1117        let mut w = build(&view);
1118        layout(&mut w);
1119        let mut state = Counter::default();
1120        let row_y = CARD_LIST_ITEM_PADDING + 5.0;
1121        assert_eq!(
1122            dispatch(&mut w, &mut state, PointerPhase::Down, 50.0, row_y),
1123            EventResult::Handled,
1124            "the row itself takes the press"
1125        );
1126        dispatch(&mut w, &mut state, PointerPhase::Up, 50.0, row_y);
1127        assert_eq!(state.presses, 1);
1128        assert_eq!(w.armed, None, "the stack armed nothing of its own");
1129    }
1130
1131    #[test]
1132    fn card_list_items_hosts_rows_that_paint_no_surface_of_their_own() {
1133        // A hosted row paints no container of its own: only the stack's own
1134        // per-card fills reach the scene.
1135        let view: CardListView<()> = card_list_items(vec![
1136            crate::list_item::list_item::<()>("a"),
1137            crate::list_item::list_item::<()>("b"),
1138        ]);
1139        let mut w = build(&view);
1140        layout(&mut w);
1141        let rec = paint(&mut w, None);
1142        assert_eq!(
1143            rec.rrects.len(),
1144            2,
1145            "two card fills, and no row-owned surface underneath them"
1146        );
1147    }
1148
1149    // --- Semantics ---
1150
1151    #[test]
1152    fn semantics_is_a_list_container_forwarding_its_rows() {
1153        fn logic(_s: &mut ()) -> CardListView<()> {
1154            card_list_items(vec![
1155                crate::list_item::list_item::<()>("a"),
1156                crate::list_item::list_item::<()>("b"),
1157            ])
1158        }
1159        let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1160        let mut state = ();
1161        root.rebuild(&mut logic, &mut state);
1162        let mut tcx = TextContext::new();
1163        root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1164        let update = root.semantics();
1165        let (_, list) = update
1166            .nodes
1167            .iter()
1168            .find(|(_, n)| n.role() == Role::List)
1169            .expect("the stack contributes a Role::List node");
1170        assert_eq!(list.children().len(), 2, "both rows are semantics children");
1171        assert_eq!(
1172            update
1173                .nodes
1174                .iter()
1175                .filter(|(_, n)| n.role() == Role::ListItem)
1176                .count(),
1177            2
1178        );
1179    }
1180
1181    #[test]
1182    fn semantics_an_interactive_stack_carries_click_and_a_disabled_one_reports_disabled() {
1183        fn interactive(_s: &mut ()) -> CardListView<()> {
1184            card_list(vec![leaf_any(10.0, CHILD_HEIGHT)]).on_press(|_: &mut (), _| {})
1185        }
1186        let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1187        let mut state = ();
1188        root.rebuild(&mut interactive, &mut state);
1189        let mut tcx = TextContext::new();
1190        root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1191        let update = root.semantics();
1192        let (_, list) = update
1193            .nodes
1194            .iter()
1195            .find(|(_, n)| n.role() == Role::List)
1196            .expect("list node");
1197        assert!(list.supports_action(Action::Click));
1198        assert!(!list.is_disabled());
1199
1200        fn disabled(_s: &mut ()) -> CardListView<()> {
1201            card_list(vec![leaf_any(10.0, CHILD_HEIGHT)])
1202                .on_press(|_: &mut (), _| {})
1203                .enabled(false)
1204        }
1205        let mut root: frust_core::RenderRoot<(), CardListView<()>> = frust_core::RenderRoot::new();
1206        root.rebuild(&mut disabled, &mut state);
1207        root.layout_with_text(Size::new(WIDTH, 400.0), &mut tcx as &mut dyn Any);
1208        let update = root.semantics();
1209        let (_, list) = update
1210            .nodes
1211            .iter()
1212            .find(|(_, n)| n.role() == Role::List)
1213            .expect("list node");
1214        assert!(list.is_disabled());
1215        assert!(!list.supports_action(Action::Click));
1216    }
1217
1218    // --- Hover, through a real RenderRoot ---
1219    //
1220    // Hover is an authoritative read off `PaintCtx`, seeded by the pod chain,
1221    // so only a real tree can exercise it (the harness shape
1222    // `list_item`/`card` both use).
1223
1224    struct HoverHarness {
1225        root: frust_core::RenderRoot<Pressed, CardListView<Pressed>>,
1226        state: Pressed,
1227        tcx: TextContext,
1228        enabled: bool,
1229    }
1230
1231    impl HoverHarness {
1232        fn new(enabled: bool) -> Self {
1233            let mut h = HoverHarness {
1234                root: frust_core::RenderRoot::new(),
1235                state: Pressed::default(),
1236                tcx: TextContext::new(),
1237                enabled,
1238            };
1239            let enabled_flag = enabled;
1240            let mut app =
1241                move |_s: &mut Pressed| {
1242                    card_list((0..3).map(|_| {
1243                        any::<Pressed, _>(frust::SizedBox(Some(10.0), Some(CHILD_HEIGHT)))
1244                    }))
1245                    .on_press(|s: &mut Pressed, index| s.indices.push(index))
1246                    .enabled(enabled_flag)
1247                };
1248            h.root.rebuild(&mut app, &mut h.state);
1249            h.root
1250                .layout_with_text(Size::new(WIDTH, 400.0), &mut h.tcx as &mut dyn Any);
1251            h
1252        }
1253
1254        fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1255            self.root.event(
1256                &mut self.state,
1257                &InputEvent::Pointer(frust::authoring::PointerEvent {
1258                    phase,
1259                    position: Point::new(x, y),
1260                    button: frust::authoring::PointerButton::Primary,
1261                }),
1262            );
1263        }
1264
1265        /// The overlays painted this frame, as `(card index, alpha)`.
1266        fn overlays(&mut self) -> Vec<(usize, f32)> {
1267            let mut rec = Recorder::default();
1268            self.root.paint(&mut rec, frust::FrameTime::ZERO);
1269            rec.overlays()
1270                .iter()
1271                .map(|(origin, _, alpha)| {
1272                    let index = (origin.y / (card_height() + CARD_LIST_GAP)).round() as usize;
1273                    (index, *alpha)
1274                })
1275                .collect()
1276        }
1277
1278        /// Every rect painted this frame — the disabled wash is itself
1279        /// translucent, so a disabled stack is asserted by rect *count*
1280        /// rather than through [`Self::overlays`]'s alpha filter.
1281        fn rect_count(&mut self) -> usize {
1282            let mut rec = Recorder::default();
1283            self.root.paint(&mut rec, frust::FrameTime::ZERO);
1284            rec.rrects.len()
1285        }
1286    }
1287
1288    #[test]
1289    fn hovering_a_card_tints_that_card_only() {
1290        let mut h = HoverHarness::new(true);
1291        assert!(h.overlays().is_empty(), "no overlay at rest");
1292
1293        h.dispatch(PointerPhase::Move, 50.0, card_y(1));
1294        assert_eq!(
1295            h.overlays(),
1296            vec![(1, crate::interaction::HOVER_OPACITY)],
1297            "the hovered card tints at the M3E hover opacity"
1298        );
1299
1300        h.dispatch(PointerPhase::Move, 50.0, card_y(2));
1301        assert_eq!(h.overlays(), vec![(2, crate::interaction::HOVER_OPACITY)]);
1302
1303        // Off every card (past the stack), nothing tints.
1304        h.dispatch(PointerPhase::Move, 50.0, 390.0);
1305        assert!(h.overlays().is_empty());
1306    }
1307
1308    #[test]
1309    fn a_disabled_stack_never_tints_and_never_fires() {
1310        let mut h = HoverHarness::new(false);
1311        h.dispatch(PointerPhase::Move, 50.0, card_y(0));
1312        assert_eq!(
1313            h.rect_count(),
1314            3,
1315            "three dimmed card fills and no state layer: a disabled stack claims no hover"
1316        );
1317        h.dispatch(PointerPhase::Down, 50.0, card_y(0));
1318        h.dispatch(PointerPhase::Up, 50.0, card_y(0));
1319        assert!(h.state.indices.is_empty());
1320        assert!(!h.enabled);
1321    }
1322}