Skip to main content

frust_material/
list_item.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 `M3EListItem`,
4//   `styles/m3e_list_theme.dart`'s `M3EListItemTheme`,
5//   `components/m3e_list_item_scope.dart`)
6// This rework adds the reference's overline slot, selected tint, and its
7// card-backed container surface (opt-in here rather than default — see the
8// module docs' Container surface section) on top of the v1 transparent
9// fixed-height row; the reference's `variant`/`border` customization escape
10// hatches stay unported — see the module docs' Not ported section.
11
12//! `ListItem` rows: 1/2/3-line variants at 56/72/88dp heights, meant to pair
13//! with [`frust::ListView`] or [`mod@super::card_list`].
14//!
15//! # Anatomy
16//!
17//! A row is `[ leading? | (overline? / headline / supporting?) | trailing? ]`
18//! with 16dp horizontal padding (`M3EListItemTheme.horizontalPadding`) and a
19//! 16dp gap (`.gap`) between the slots and the text column. The
20//! `overline`/`headline`/`supporting` runs are child [`frust::text`] widgets
21//! laid out during the layout pass — [`frust::authoring::PaintCtx`] has no
22//! text-shaping context, so all text sizing happens at layout time — carrying
23//! the reference's own type/role/truncation mapping:
24//!
25//! | Run | Type token | Color role | Lines |
26//! |---|---|---|---|
27//! | `overline` | label-small (11sp, 0.5 tracking) | `on_surface_variant` | 1, ellipsized |
28//! | `headline` | body-large (16sp, the default) | `on_surface` | 1, ellipsized |
29//! | `supporting` | body-medium (14sp) | `on_surface_variant` | 2, ellipsized |
30//!
31//! The `leading`/`trailing` slots are arbitrary [`frust::authoring::AnyView`]s
32//! — an icon, avatar, image, checkbox, switch, or a trailing supporting-text
33//! run — exactly as upstream types them (`Widget? leading` / `Widget?
34//! trailing`); there is no closed slot vocabulary to port. They are vertically
35//! centered, except on a three-line row, where they pin to the top inset
36//! (`crossAxisAlignment: threeLine ? start : center`).
37//!
38//! # Height
39//!
40//! | Variant | Height | Constructed by |
41//! |---|---|---|
42//! | One-line   | 56dp | [`list_item`] |
43//! | Two-line   | 72dp | [`ListItem::supporting`] / [`ListItem::overline`] |
44//! | Three-line | 88dp | overline **and** supporting, or [`ListItem::three_line`] |
45//!
46//! Fixed heights are this port's own model (M3's list spec) rather than the
47//! reference's intrinsic `minHeight: 40` + 8/12dp vertical padding — one-line
48//! rows agree exactly (40 + 2×8 = 56), and a fixed extent is what
49//! [`frust::list_view`]'s uniform-extent fast path needs. The three-line
50//! promotion rule *is* the reference's (`_isThreeLine => supportingText !=
51//! null && overline != null`), and [`ListItem::three_line`] still forces it.
52//!
53//! # Container surface
54//!
55//! [`ListItem::contained`] paints the reference's own row surface —
56//! `M3EListItem` wraps its body in an `M3ECard(variant: filled)`, so a
57//! contained row fills `surface_container_highest` at the `shape.medium`
58//! (12dp) radius, and `secondary_container` while [`ListItem::selected`]
59//! (`M3EListItemTheme.selectedColor`).
60//!
61//! **It is opt-in here, where upstream has it on by default** — a documented
62//! deviation, for two reasons. First, the reference's own default is
63//! conditional in practice: `M3EListItemScope` makes every row hosted by a
64//! card-backed list (`M3ECardList`, the dismissible/expandable lists) drop the
65//! card and render its body only, because the host already owns the surface —
66//! and [`mod@super::card_list`] is where that shape lives in this catalog.
67//! Second, this catalog's rows predate the M3E surface and are placed by
68//! consumers inside their own scrollers and containers, where an
69//! unconditional per-row card would stack surfaces (and scallop adjacent rows'
70//! 12dp corners) rather than group them.
71//!
72//! So a bare row is content — the port of what the reference's embedded rows
73//! render — and a row that wants the standalone card asks for it. A
74//! **selected** row still paints its selection fill either way: `selected` is
75//! a state the user must see, not a container preference.
76//!
77//! # Interactivity
78//!
79//! [`ListItem::on_press`] makes the whole row one interactive target,
80//! mirroring [`mod@super::card`]: the row owns capture, fires on release
81//! inside its bounds, and paints an [`crate::interaction::InteractionState`]
82//! overlay tinted `on_surface`. The overlay takes the container's radius when
83//! the row paints one, and square corners when it does not — the host owns
84//! whatever shape is under a bare row, and a 12dp overlay corner inside a
85//! differently-rounded host pokes out at the corners. A non-interactive row
86//! routes pointer events to its slot children (so a trailing control stays
87//! live).
88//!
89//! An interactive row is also the catalog's **hover** reference consumer, and
90//! shows the whole three-part contract: it claims the hover link from its
91//! uncaptured `Move` arm ([`frust::authoring::EventCtx::claim_hover`]), latches the
92//! same hit test into [`crate::interaction::InteractionState::set_hovered`] and requests
93//! a redraw only when that flag changes (the frame that makes the 8% overlay appear
94//! on entry), and re-syncs the flag from
95//! [`frust::authoring::PaintCtx::is_hovered`] every paint — authoritative, because
96//! a pointer *leaving* the row routes its next move to whatever it moved onto, so
97//! the row never hears about the departure. A press wins visually while it lasts
98//! (pressed 10% > hover 8%, the M3E precedence order), and a **captured** drag
99//! paints no hover at all, the framework refusing a claim from a captured pointer.
100//! A touch drag that captured nothing is an ordinary hover pass, though, so the row
101//! can tint under a finger until the lift's `Up` ends the link.
102//!
103//! # Disabled state
104//!
105//! [`ListItem::enabled`] (default `true`) is this crate's own extension — the
106//! reference `M3EListItem` carries no `enabled` field — following the same
107//! convention [`mod@super::card`] established: an interactive row that is
108//! disabled keeps its [`Role::ListItem`] semantics (reporting
109//! `Node::set_disabled()` instead of [`Action::Click`]) and dims its container
110//! to `on_surface` at [`crate::interaction::DISABLED_CONTAINER_OPACITY`], but
111//! claims no hover, paints no state layer, and fires nothing. **Hover is
112//! enabled-gated** in both the event pass and paint's self-correction (never
113//! react to hover while disabled). A disabled interactive row's `Widget::event`
114//! early-returns `Ignored` for every pointer phase rather than forwarding to
115//! its slots — a disabled row is fully inert, not a pass-through (mirroring
116//! `card`'s identical disabled early-return). `enabled` is a no-op on a
117//! non-interactive row, which has no interactive surface to gate.
118//!
119//! Text runs are *not* dimmed: their color resolves inside
120//! [`frust::text`]'s own themed-role path at layout time, which this row
121//! cannot override without baking an explicit unthemed color. The container
122//! wash is the whole disabled treatment here, so a disabled row with no
123//! container painted (neither `contained` nor `selected`) is gated but not
124//! dimmed.
125//!
126//! # Not ported
127//!
128//! The reference's `variant`/`border` card overrides (and the
129//! `M3EListItemTheme` knobs behind them) stay unported, the same scope line
130//! [`mod@super::card`] draws around its own customization escape hatches: a
131//! [`ListItem::contained`] row is the filled variant, and a row that needs
132//! another surface goes inside the container that paints it.
133//! `IconTheme.merge`'s implicit leading/trailing icon sizing/tinting has no
134//! frust analogue either — a slot view carries its own size and color.
135
136use std::rc::Rc;
137
138use frust::Theme;
139use frust::authoring::text::TextOverflow;
140use frust::authoring::{Action, Role};
141use frust::authoring::{
142    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, ErasedCallback, EventCtx,
143    EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
144    ThemeTextColor, ThemeTextType, View, Widget, any,
145};
146use frust::text;
147use kurbo::{Point, Size};
148use peniko::Color;
149
150use crate::interaction::{DISABLED_CONTAINER_OPACITY, InteractionState};
151
152use super::press::presses;
153
154/// Horizontal padding on the leading and trailing edges
155/// (`M3EListItemTheme.horizontalPadding`, 16dp).
156const HPAD: f64 = 16.0;
157/// Gap between a leading/trailing slot and the text column
158/// (`M3EListItemTheme.gap`, 16dp).
159const GAP: f64 = 16.0;
160/// Vertical inset a three-line row's slots pin to
161/// (`M3EListItemTheme.threeLineVerticalPadding`, 12dp) — the reference's
162/// `crossAxisAlignment: start` case.
163const VPAD_THREE_LINE: f64 = 12.0;
164/// Supporting-text font size (M3 body-medium, 14sp); the headline keeps the
165/// default 16sp body-large size.
166const SUPPORTING_SIZE: f32 = 14.0;
167/// Overline font size (M3 label-small, 11sp).
168const OVERLINE_SIZE: f32 = 11.0;
169/// Overline letter spacing (M3 label-small, 0.5).
170const OVERLINE_TRACKING: f32 = 0.5;
171/// Rendered line cap for the headline (`maxLines: 1`, ellipsized).
172const HEADLINE_MAX_LINES: usize = 1;
173/// Rendered line cap for the supporting run (`maxLines: 2`, ellipsized).
174const SUPPORTING_MAX_LINES: usize = 2;
175
176/// One-line row height, in logical px (M3 list spec).
177pub const ONE_LINE_HEIGHT: f64 = 56.0;
178/// Two-line row height, in logical px (M3 list spec).
179pub const TWO_LINE_HEIGHT: f64 = 72.0;
180/// Three-line row height, in logical px (M3 list spec).
181pub const THREE_LINE_HEIGHT: f64 = 88.0;
182
183/// Corner radius of a standalone row's container (unthemed fallback; a theme
184/// resolves this from `shape.medium`, the 12dp token `M3ECardTheme`'s
185/// `radiusMedium` names).
186const RADIUS: f64 = 12.0;
187
188/// Unthemed-fallback state-layer content color for an interactive row (a theme
189/// resolves this from `colors.on_surface`).
190const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
191/// Unthemed-fallback container fill for a standalone row (a theme resolves
192/// this from `colors.surface_container_highest`, the filled-card role).
193const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
194/// Unthemed-fallback container fill for a selected row (a theme resolves this
195/// from `colors.secondary_container`, `M3EListItemTheme.selectedColor`).
196const SELECTED_CONTAINER: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
197
198/// The number of text lines a [`ListItem`] reserves height for.
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum ListItemLines {
201    /// Headline only — 56dp.
202    One,
203    /// Headline + one more run (supporting or overline) — 72dp.
204    Two,
205    /// Overline + headline + supporting — 88dp.
206    Three,
207}
208
209impl ListItemLines {
210    /// The fixed row height for this line count.
211    pub fn height(self) -> f64 {
212        match self {
213            ListItemLines::One => ONE_LINE_HEIGHT,
214            ListItemLines::Two => TWO_LINE_HEIGHT,
215            ListItemLines::Three => THREE_LINE_HEIGHT,
216        }
217    }
218}
219
220/// A view-held, typed press callback (erased on build).
221type OnPress<State> = Rc<dyn Fn(&mut State)>;
222
223/// A declarative M3 list row. See the [module docs](self).
224pub struct ListItem<State: 'static> {
225    headline: String,
226    supporting: Option<String>,
227    overline: Option<String>,
228    lines: ListItemLines,
229    leading: Option<AnyView<State>>,
230    trailing: Option<AnyView<State>>,
231    on_press: Option<OnPress<State>>,
232    selected: bool,
233    enabled: bool,
234    contained: bool,
235}
236
237/// Create a one-line list row with the given `headline` text.
238pub fn list_item<State: 'static>(headline: impl Into<String>) -> ListItem<State> {
239    ListItem {
240        headline: headline.into(),
241        supporting: None,
242        overline: None,
243        lines: ListItemLines::One,
244        leading: None,
245        trailing: None,
246        on_press: None,
247        selected: false,
248        enabled: true,
249        contained: false,
250    }
251}
252
253impl<State: 'static> ListItem<State> {
254    /// Add supporting text below the headline, promoting the row's height per
255    /// the reference's line rule (see the [module docs](self)' Height table).
256    pub fn supporting(mut self, supporting: impl Into<String>) -> Self {
257        self.supporting = Some(supporting.into());
258        self.promote_lines();
259        self
260    }
261
262    /// Add an overline label above the headline (`M3EListItem.overline`),
263    /// promoting the row's height per the reference's line rule (see the
264    /// [module docs](self)' Height table).
265    pub fn overline(mut self, overline: impl Into<String>) -> Self {
266        self.overline = Some(overline.into());
267        self.promote_lines();
268        self
269    }
270
271    /// Raise the reserved height to whatever the present runs imply —
272    /// overline **and** supporting is the reference's three-line case
273    /// (`_isThreeLine`), either one alone is two-line. Never *lowers* the
274    /// count, so an explicit [`ListItem::three_line`] survives a later
275    /// `supporting`/`overline` call regardless of ordering.
276    fn promote_lines(&mut self) {
277        let implied = match (self.overline.is_some(), self.supporting.is_some()) {
278            (true, true) => ListItemLines::Three,
279            (true, false) | (false, true) => ListItemLines::Two,
280            (false, false) => ListItemLines::One,
281        };
282        if implied.height() > self.lines.height() {
283            self.lines = implied;
284        }
285    }
286
287    /// Force the three-line (88dp) variant (for a supporting line that wraps to
288    /// two visual lines).
289    pub fn three_line(mut self) -> Self {
290        self.lines = ListItemLines::Three;
291        self
292    }
293
294    /// Set the leading slot (an icon/avatar/image/control) — vertically
295    /// centered, or pinned to the top inset on a three-line row.
296    pub fn leading<V: View<State>>(mut self, leading: V) -> Self {
297        self.leading = Some(any(leading));
298        self
299    }
300
301    /// Set the trailing slot (an icon, a metadata/supporting-text run, a
302    /// checkbox/switch) — vertically centered, or pinned to the top inset on a
303    /// three-line row.
304    pub fn trailing<V: View<State>>(mut self, trailing: V) -> Self {
305        self.trailing = Some(any(trailing));
306        self
307    }
308
309    /// Make the whole row interactive, firing `on_press` on release inside its
310    /// bounds (see the module docs' Interactivity section).
311    pub fn on_press<F: Fn(&mut State) + 'static>(mut self, on_press: F) -> Self {
312        self.on_press = Some(Rc::new(on_press));
313        self
314    }
315
316    /// Paint the row's container with the selected fill
317    /// (`colors.secondary_container`, `M3EListItemTheme.selectedColor`) and
318    /// report the state to accessibility. Defaults to `false`. A selected row
319    /// paints its fill whether or not it is [`ListItem::contained`] — the
320    /// state has to be visible.
321    pub fn selected(mut self, selected: bool) -> Self {
322        self.selected = selected;
323        self
324    }
325
326    /// Paint the reference's standalone row surface: the filled-card
327    /// container (`colors.surface_container_highest`) at the `shape.medium`
328    /// radius. Defaults to `false` — a bare row is content, the shape a row
329    /// hosted by [`mod@super::card_list`] (or any other container that owns
330    /// the surface) needs. See the [module docs](self)' Container surface
331    /// section for why this port inverts the reference's default.
332    pub fn contained(mut self, contained: bool) -> Self {
333        self.contained = contained;
334        self
335    }
336
337    /// Gate the interactive treatment (hover/press tint and firing
338    /// [`ListItem::on_press`]) and dim the container, without dropping back to
339    /// a transparently-forwarding row. Defaults to `true`; only meaningful
340    /// once `on_press` is chained. See the [module docs](self)' Disabled state
341    /// section.
342    pub fn enabled(mut self, enabled: bool) -> Self {
343        self.enabled = enabled;
344        self
345    }
346
347    /// The presence-of-slots shape; a change forces a full child rebuild.
348    fn shape(&self) -> (bool, bool, bool, bool) {
349        (
350            self.leading.is_some(),
351            self.overline.is_some(),
352            self.supporting.is_some(),
353            self.trailing.is_some(),
354        )
355    }
356
357    /// Whether the row paints a container of its own: the opt-in
358    /// [`ListItem::contained`] surface, or the selection fill a
359    /// [`ListItem::selected`] row must show regardless.
360    fn paints_container(&self) -> bool {
361        self.contained || self.selected
362    }
363
364    /// The overline text view (`on_surface_variant`, label-small, family from
365    /// the theme's `labelSmall` role), or `None` when this row has no overline.
366    fn overline_view(&self) -> Option<AnyView<State>> {
367        self.overline.as_ref().map(|s| {
368            any(text(s.clone())
369                .size(OVERLINE_SIZE)
370                .letter_spacing(OVERLINE_TRACKING)
371                .max_lines(1)
372                .overflow(TextOverflow::Ellipsis)
373                .themed_role(ThemeTextColor::OnSurfaceVariant)
374                .themed_family(ThemeTextType::LabelSmall))
375        })
376    }
377
378    /// The headline text view (`on_surface`, default body-large size, family
379    /// from the theme's `bodyLarge` role, one ellipsized line).
380    fn headline_view(&self) -> impl View<State> + use<State> {
381        text(self.headline.clone())
382            .max_lines(HEADLINE_MAX_LINES)
383            .overflow(TextOverflow::Ellipsis)
384            .themed_family(ThemeTextType::BodyLarge)
385    }
386
387    /// The supporting text view (`on_surface_variant`, body-medium size,
388    /// family from the theme's `bodyMedium` role, two ellipsized lines), or
389    /// `None` when this row has no supporting line.
390    fn supporting_view(&self) -> Option<AnyView<State>> {
391        self.supporting.as_ref().map(|s| {
392            any(text(s.clone())
393                .size(SUPPORTING_SIZE)
394                .max_lines(SUPPORTING_MAX_LINES)
395                .overflow(TextOverflow::Ellipsis)
396                .themed_role(ThemeTextColor::OnSurfaceVariant)
397                .themed_family(ThemeTextType::BodyMedium))
398        })
399    }
400}
401
402/// Which entry of [`ListItemWidget::children`] each logical slot occupies. The
403/// headline is always present; the others are optional. Storing every child in
404/// one `Vec` lets a non-interactive row route events through
405/// [`frust::authoring::route_event`] and recurse uniformly for paint/semantics.
406#[derive(Clone, Copy)]
407struct Slots {
408    leading: Option<usize>,
409    overline: Option<usize>,
410    headline: usize,
411    supporting: Option<usize>,
412    trailing: Option<usize>,
413}
414
415/// The retained widget for a [`ListItem`].
416pub struct ListItemWidget {
417    children: Vec<ChildPod>,
418    slots: Slots,
419    lines: ListItemLines,
420    interactive: bool,
421    enabled: bool,
422    selected: bool,
423    /// Whether a container fill is painted at all — see
424    /// [`ListItem::paints_container`].
425    paints_container: bool,
426    /// Armed by a `Down` (alongside `capture_pointer`), cleared on
427    /// `Up`/`Cancel`/loss of interactivity or enablement.
428    captured: bool,
429    state: InteractionState,
430    on_press: Option<ErasedCallback>,
431}
432
433/// Build the ordered child window `[leading?, overline?, headline,
434/// supporting?, trailing?]` and the [`Slots`] index map, from a [`ListItem`]
435/// view.
436fn build_children<State: 'static>(
437    view: &ListItem<State>,
438    ctx: &mut BuildCtx<'_>,
439) -> (Vec<ChildPod>, Slots) {
440    let mut children = Vec::new();
441    let leading = view.leading.as_ref().map(|v| {
442        children.push(frust::authoring::build_child(v, ctx));
443        children.len() - 1
444    });
445    let overline = view.overline_view().map(|v| {
446        children.push(frust::authoring::build_child(&v, ctx));
447        children.len() - 1
448    });
449    children.push(frust::authoring::build_child(&view.headline_view(), ctx));
450    let headline = children.len() - 1;
451    let supporting = view.supporting_view().map(|v| {
452        children.push(frust::authoring::build_child(&v, ctx));
453        children.len() - 1
454    });
455    let trailing = view.trailing.as_ref().map(|v| {
456        children.push(frust::authoring::build_child(v, ctx));
457        children.len() - 1
458    });
459    (
460        children,
461        Slots {
462            leading,
463            overline,
464            headline,
465            supporting,
466            trailing,
467        },
468    )
469}
470
471fn inside(pos: Point, size: Size) -> bool {
472    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
473}
474
475/// Return `color` with its alpha channel replaced by `alpha` (mirrors
476/// [`super::card`]'s helper of the same shape).
477fn with_alpha(color: Color, alpha: f32) -> Color {
478    let c = color.components;
479    Color::new([c[0], c[1], c[2], alpha])
480}
481
482/// The resolved state-layer content color for an interactive row. Themed:
483/// `colors.on_surface`. Unthemed: [`ON_SURFACE`] exactly.
484fn resolve_content_color(theme: Option<&Theme>) -> Color {
485    match theme {
486        Some(theme) => theme.scheme().on_surface,
487        None => ON_SURFACE,
488    }
489}
490
491/// The resolved container fill of a standalone row. `disabled` overrides
492/// everything with the dimmed `on_surface` wash [`super::card`] uses; a
493/// selected row takes `colors.secondary_container`, an ordinary one the filled
494/// card role `colors.surface_container_highest`. Unthemed:
495/// [`SELECTED_CONTAINER`]/[`FILLED_CONTAINER`] exactly.
496fn resolve_container(theme: Option<&Theme>, selected: bool, disabled: bool) -> Color {
497    if disabled {
498        return with_alpha(resolve_content_color(theme), DISABLED_CONTAINER_OPACITY);
499    }
500    match theme {
501        Some(theme) => {
502            let scheme = theme.scheme();
503            if selected {
504                scheme.secondary_container
505            } else {
506                scheme.surface_container_highest
507            }
508        }
509        None => {
510            if selected {
511                SELECTED_CONTAINER
512            } else {
513                FILLED_CONTAINER
514            }
515        }
516    }
517}
518
519/// The resolved container radius. Themed: `shape.medium`. Unthemed:
520/// [`RADIUS`] exactly.
521fn resolve_radius(theme: Option<&Theme>) -> f64 {
522    match theme {
523        Some(theme) => theme.shape.medium,
524        None => RADIUS,
525    }
526}
527
528impl<State: 'static> View<State> for ListItem<State> {
529    type Element = ListItemWidget;
530
531    fn build(&self, ctx: &mut BuildCtx<'_>) -> ListItemWidget {
532        let (children, slots) = build_children(self, ctx);
533        ListItemWidget {
534            children,
535            slots,
536            lines: self.lines,
537            interactive: self.on_press.is_some(),
538            enabled: self.enabled,
539            selected: self.selected,
540            paints_container: self.paints_container(),
541            captured: false,
542            state: InteractionState::new(),
543            on_press: self.on_press.as_ref().map(frust::authoring::erase_callback),
544        }
545    }
546
547    fn rebuild(
548        &self,
549        prev: &Self,
550        element: &mut ListItemWidget,
551        ctx: &mut BuildCtx<'_>,
552    ) -> ChangeFlags {
553        let mut flags = ChangeFlags::NONE;
554
555        if self.shape() != prev.shape() {
556            // Slot presence changed: tear the whole child set down (against the
557            // previous view, whose shape the live children still match) and
558            // rebuild fresh.
559            teardown_children(prev, element.slots, &mut element.children, ctx);
560            let (children, slots) = build_children(self, ctx);
561            element.children = children;
562            element.slots = slots;
563            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
564        } else {
565            // Same shape: reconcile each child in place against its prev view.
566            let slots = element.slots;
567            if let (Some(pi), Some(ni)) = (prev.leading.as_ref(), self.leading.as_ref())
568                && let Some(idx) = slots.leading
569            {
570                flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
571            }
572            if let (Some(pv), Some(nv)) = (prev.overline_view(), self.overline_view())
573                && let Some(idx) = slots.overline
574            {
575                flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
576            }
577            flags |= frust::authoring::rebuild_child(
578                &prev.headline_view(),
579                &self.headline_view(),
580                &mut element.children[slots.headline],
581                ctx,
582            );
583            if let (Some(pv), Some(nv)) = (prev.supporting_view(), self.supporting_view())
584                && let Some(idx) = slots.supporting
585            {
586                flags |= frust::authoring::rebuild_child(&pv, &nv, &mut element.children[idx], ctx);
587            }
588            if let (Some(pi), Some(ni)) = (prev.trailing.as_ref(), self.trailing.as_ref())
589                && let Some(idx) = slots.trailing
590            {
591                flags |= frust::authoring::rebuild_child(pi, ni, &mut element.children[idx], ctx);
592            }
593        }
594
595        if element.lines != self.lines {
596            element.lines = self.lines;
597            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
598        }
599        if element.selected != self.selected || element.paints_container != self.paints_container()
600        {
601            element.selected = self.selected;
602            element.paints_container = self.paints_container();
603            flags |= ChangeFlags::PAINT;
604        }
605
606        let now_interactive = self.on_press.is_some();
607        if element.interactive != now_interactive {
608            element.interactive = now_interactive;
609            if !now_interactive {
610                // Losing the interactive surface mid-gesture must not leave a
611                // dangling capture, press, or hover behind.
612                element.captured = false;
613                element.state.set_pressed(false);
614                element.state.set_hovered(false);
615            }
616            flags |= ChangeFlags::PAINT;
617        }
618        if element.enabled != self.enabled {
619            element.enabled = self.enabled;
620            if !self.enabled && element.interactive {
621                // A row disabled mid-gesture keeps neither the press nor the
622                // hover it was holding — mirrors `card`'s identical clear.
623                element.captured = false;
624                element.state.set_pressed(false);
625                element.state.set_hovered(false);
626            }
627            flags |= ChangeFlags::PAINT;
628        }
629        element.on_press = self.on_press.as_ref().map(frust::authoring::erase_callback);
630        flags
631    }
632
633    fn teardown(&self, element: &mut ListItemWidget, ctx: &mut BuildCtx<'_>) {
634        teardown_children(self, element.slots, &mut element.children, ctx);
635    }
636}
637
638/// Tear down every child pod through the view it was built from, per the
639/// [`Slots`] index map. `teardown_child` cancels an in-flight capture regardless
640/// of view type; the reconstructed text views only need to match the pod's
641/// concrete `TextWidget` type for the (usually no-op) `teardown` dispatch.
642fn teardown_children<State: 'static>(
643    view: &ListItem<State>,
644    slots: Slots,
645    children: &mut [ChildPod],
646    ctx: &mut BuildCtx<'_>,
647) {
648    for (index, pod) in children.iter_mut().enumerate() {
649        if Some(index) == slots.leading
650            && let Some(v) = view.leading.as_ref()
651        {
652            frust::authoring::teardown_child(v, pod, ctx);
653        } else if Some(index) == slots.overline
654            && let Some(v) = view.overline_view()
655        {
656            frust::authoring::teardown_child(&v, pod, ctx);
657        } else if index == slots.headline {
658            frust::authoring::teardown_child(&view.headline_view(), pod, ctx);
659        } else if Some(index) == slots.supporting
660            && let Some(v) = view.supporting_view()
661        {
662            frust::authoring::teardown_child(&v, pod, ctx);
663        } else if Some(index) == slots.trailing
664            && let Some(v) = view.trailing.as_ref()
665        {
666            frust::authoring::teardown_child(v, pod, ctx);
667        }
668    }
669}
670
671impl ListItemWidget {
672    /// The y origin a slot pins to for a `slot`-tall child: the top inset on a
673    /// three-line row (`crossAxisAlignment: start`), centered otherwise.
674    fn slot_y(&self, row_height: f64, slot_height: f64) -> f64 {
675        if self.lines == ListItemLines::Three {
676            VPAD_THREE_LINE
677        } else {
678            ((row_height - slot_height) / 2.0).max(0.0)
679        }
680    }
681}
682
683impl Widget for ListItemWidget {
684    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
685        let width = if bc.max().width.is_finite() {
686            bc.max().width
687        } else {
688            0.0
689        };
690        let height = self.lines.height();
691        let slot_bc = BoxConstraints::new(Size::ZERO, Size::new(width, height));
692
693        // Leading slot at the left inset.
694        let mut text_left = HPAD;
695        if let Some(idx) = self.slots.leading {
696            let s = self.children[idx].layout_child(ctx, &slot_bc);
697            let y = self.slot_y(height, s.height);
698            self.children[idx].set_origin(Point::new(HPAD, y));
699            text_left = HPAD + s.width + GAP;
700        }
701
702        // Trailing slot at the right inset.
703        let mut text_right = width - HPAD;
704        if let Some(idx) = self.slots.trailing {
705            let s = self.children[idx].layout_child(ctx, &slot_bc);
706            let x = width - HPAD - s.width;
707            let y = self.slot_y(height, s.height);
708            self.children[idx].set_origin(Point::new(x, y));
709            text_right = x - GAP;
710        }
711
712        // Text column between the slots.
713        let text_w = (text_right - text_left).max(0.0);
714        let text_bc = BoxConstraints::new(Size::ZERO, Size::new(text_w, height));
715        let over_size = if let Some(oi) = self.slots.overline {
716            self.children[oi].layout_child(ctx, &text_bc)
717        } else {
718            Size::ZERO
719        };
720        let hi = self.slots.headline;
721        let head_size = self.children[hi].layout_child(ctx, &text_bc);
722        let supp_size = if let Some(si) = self.slots.supporting {
723            self.children[si].layout_child(ctx, &text_bc)
724        } else {
725            Size::ZERO
726        };
727
728        // Vertically center the overline+headline+supporting block within the
729        // row (the fixed row heights already bake in the vertical padding, so a
730        // block that fits leaves >=8dp above and below).
731        let block = over_size.height + head_size.height + supp_size.height;
732        let mut y = ((height - block) / 2.0).max(0.0);
733        if let Some(oi) = self.slots.overline {
734            self.children[oi].set_origin(Point::new(text_left, y));
735            y += over_size.height;
736        }
737        self.children[hi].set_origin(Point::new(text_left, y));
738        y += head_size.height;
739        if let Some(si) = self.slots.supporting {
740            self.children[si].set_origin(Point::new(text_left, y));
741        }
742
743        bc.constrain(Size::new(width, height))
744    }
745
746    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
747        // Every theme read happens before `ctx` is taken mutably below
748        // (`paint_child`) — mirrors `card::CardWidget::paint`'s ordering.
749        let theme = Theme::from_paint_ctx(ctx);
750        let disabled = self.interactive && !self.enabled;
751        // A row with no container of its own tints square: the host owns
752        // whatever corner shape is under it, and a 12dp overlay corner inside
753        // a differently-rounded host pokes out at the corners.
754        let radius = if self.paints_container {
755            resolve_radius(theme)
756        } else {
757            0.0
758        };
759        let container = self
760            .paints_container
761            .then(|| resolve_container(theme, self.selected, disabled));
762        let content_color = resolve_content_color(theme);
763        // The pod's hover link is authoritative; the flag the `Move` arm
764        // latched is only what earns the row a frame when hover *begins*. A
765        // pointer that left the row routed its next move elsewhere, so no event
766        // ever told this row it stopped being hovered — self-correcting the flag
767        // here is what makes the overlay drop on the very frame the pointer
768        // moves onto a sibling. Enabled-gated: a disabled row never reacts to
769        // hover.
770        let hovered = self.interactive && self.enabled && ctx.is_hovered();
771
772        let o = ctx.origin();
773        let size = ctx.size();
774
775        self.state.set_hovered(hovered);
776
777        if let Some(container) = container {
778            scene.fill_rounded_rect(o, size, radius, container);
779        }
780        if self.interactive && self.enabled {
781            let opacity = self.state.resolve_opacity();
782            if opacity > 0.0 {
783                scene.fill_rounded_rect(o, size, radius, with_alpha(content_color, opacity));
784            }
785        }
786        for pod in &mut self.children {
787            pod.paint_child(ctx, scene);
788        }
789    }
790
791    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
792        if !self.interactive {
793            return frust::authoring::route_event(&mut self.children, ctx, event);
794        }
795        // Non-pointer events (Key, Ime, focus-routed) must be forwarded to
796        // children, even when interactive. Only pointer events drive the
797        // interactive row's own capture/press behavior.
798        let InputEvent::Pointer(p) = event else {
799            return frust::authoring::route_event(&mut self.children, ctx, event);
800        };
801        if !self.enabled {
802            // A disabled interactive row arms nothing and claims no hover, and
803            // does not forward to its slots either (see the module docs'
804            // Disabled state section) — mirrors `card`'s disabled early-return.
805            return EventResult::Ignored;
806        }
807        let on_press = self
808            .on_press
809            .as_mut()
810            .expect("on_press is set whenever interactive is true");
811        match p.phase {
812            PointerPhase::Down => {
813                if !presses(p) {
814                    return EventResult::Ignored;
815                }
816                self.state.set_pressed(true);
817                self.captured = true;
818                ctx.capture_pointer();
819                ctx.request_redraw();
820                EventResult::Handled
821            }
822            PointerPhase::Move => {
823                if !self.captured {
824                    // No capture: this is the hover pass. Claim the link whenever
825                    // the pointer is inside the row — every qualifying move, not
826                    // just the first, since a claim covers only its own pass. The
827                    // redraw is gated on the setter's changed-return: that gating is
828                    // what paints the overlay on entry (a claim asks for no frame)
829                    // while a pointer wandering *within* the row costs nothing after
830                    // the first move. A claim made while some pointer is captured
831                    // (this row's or anyone's) is refused by the framework, so no
832                    // drag can reach this arm and tint the row; an uncaptured touch
833                    // drag can, and its lift's `Up` ends the link.
834                    let over = inside(p.position, ctx.size());
835                    if over {
836                        ctx.claim_hover();
837                    }
838                    if self.state.set_hovered(over) {
839                        ctx.request_redraw();
840                    }
841                    // Still `Ignored`: watching a move is not consuming it.
842                    return EventResult::Ignored;
843                }
844                let inside_now = inside(p.position, ctx.size());
845                if self.state.set_pressed(inside_now) {
846                    ctx.request_redraw();
847                }
848                EventResult::Handled
849            }
850            PointerPhase::Up => {
851                if !self.captured {
852                    return EventResult::Ignored;
853                }
854                if inside(p.position, ctx.size()) {
855                    (on_press)(ctx);
856                }
857                self.state.set_pressed(false);
858                self.captured = false;
859                ctx.request_redraw();
860                EventResult::Handled
861            }
862            PointerPhase::Cancel => {
863                if !self.captured {
864                    return EventResult::Ignored;
865                }
866                self.state.set_pressed(false);
867                self.captured = false;
868                ctx.request_redraw();
869                EventResult::Handled
870            }
871        }
872    }
873
874    fn semantics(&self, ctx: &mut SemanticsCtx) {
875        ctx.push_container(
876            Role::ListItem,
877            |node| {
878                if self.interactive {
879                    if self.enabled {
880                        node.add_action(Action::Click);
881                    } else {
882                        node.set_disabled();
883                    }
884                }
885                // Set only while selected: accesskit's own guidance is that the
886                // flag's absence means "selection doesn't apply here", and a
887                // `false` earns an extraneous "not selected" announcement.
888                if self.selected {
889                    node.set_selected(true);
890                }
891            },
892            |ctx| {
893                for pod in &self.children {
894                    pod.semantics_child(ctx);
895                }
896            },
897        );
898    }
899
900    frust::authoring::visit_children!(children);
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use frust::authoring::text::TextContext;
907    use frust_core::RenderRoot;
908    use frust_widgets::test_support::leaf;
909    use kurbo::Point;
910    use std::any::Any;
911
912    fn build<S: 'static>(view: &ListItem<S>) -> ListItemWidget {
913        let mut counter = 0u64;
914        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
915    }
916
917    fn layout(w: &mut ListItemWidget, width: f64) -> Size {
918        let mut tcx = TextContext::new();
919        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
920        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 400.0)))
921    }
922
923    /// Records each rounded rect's `(origin, size, radius, color)` — the
924    /// container fill and the state-layer overlay both land here.
925    #[derive(Default)]
926    struct RRectRecorder {
927        rrects: Vec<(Point, Size, f64, Color)>,
928    }
929
930    impl PaintScene for RRectRecorder {
931        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
932        fn draw_text(&mut self, _o: Point, _t: &str) {}
933        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
934            self.rrects.push((o, s, radius, color));
935        }
936    }
937
938    impl RRectRecorder {
939        /// Every translucent rect painted this frame — the state-layer
940        /// overlays, told apart from the opaque container fills by alpha.
941        fn overlays(&self) -> Vec<(Point, f32)> {
942            self.rrects
943                .iter()
944                .filter(|(_, _, _, c)| c.components[3] < 1.0)
945                .map(|(o, _, _, c)| (*o, c.components[3]))
946                .collect()
947        }
948    }
949
950    fn paint(w: &mut ListItemWidget, size: Size, theme: Option<&Theme>) -> RRectRecorder {
951        let mut rec = RRectRecorder::default();
952        let mut ctx = match theme {
953            Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
954            None => PaintCtx::new(Point::ZERO, size),
955        };
956        w.paint(&mut ctx, &mut rec);
957        rec
958    }
959
960    #[test]
961    fn one_line_row_is_56dp() {
962        let view: ListItem<()> = list_item("Title");
963        let mut w = build(&view);
964        let size = layout(&mut w, 300.0);
965        assert_eq!(size.height, ONE_LINE_HEIGHT);
966    }
967
968    #[test]
969    fn supporting_promotes_to_two_line_72dp() {
970        let view: ListItem<()> = list_item("Title").supporting("Subtitle");
971        assert_eq!(view.lines, ListItemLines::Two);
972        let mut w = build(&view);
973        let size = layout(&mut w, 300.0);
974        assert_eq!(size.height, TWO_LINE_HEIGHT);
975    }
976
977    #[test]
978    fn overline_alone_promotes_to_two_line_and_with_supporting_to_three() {
979        // The reference's `_isThreeLine`: overline AND supporting.
980        let two: ListItem<()> = list_item("Title").overline("OVERLINE");
981        assert_eq!(two.lines, ListItemLines::Two);
982
983        let three: ListItem<()> = list_item("Title").overline("OVERLINE").supporting("Sub");
984        assert_eq!(three.lines, ListItemLines::Three);
985
986        // Order-independent, and an explicit `three_line()` is never demoted.
987        let flipped: ListItem<()> = list_item("Title").supporting("Sub").overline("OVERLINE");
988        assert_eq!(flipped.lines, ListItemLines::Three);
989        let forced: ListItem<()> = list_item("Title").three_line().supporting("Sub");
990        assert_eq!(forced.lines, ListItemLines::Three);
991    }
992
993    #[test]
994    fn three_line_is_88dp() {
995        let view: ListItem<()> = list_item("Title").supporting("Sub").three_line();
996        assert_eq!(view.lines, ListItemLines::Three);
997        let mut w = build(&view);
998        let size = layout(&mut w, 300.0);
999        assert_eq!(size.height, THREE_LINE_HEIGHT);
1000    }
1001
1002    #[test]
1003    fn leading_and_trailing_slots_are_placed_and_inset_the_text() {
1004        // A 24x24 leading and a 16x16 trailing leaf, so the text column starts
1005        // after the leading + gap and ends before the trailing + gap.
1006        let view: ListItem<()> = list_item("Title")
1007            .leading(leaf(24.0, 24.0))
1008            .trailing(leaf(16.0, 16.0));
1009        let mut w = build(&view);
1010        layout(&mut w, 300.0);
1011
1012        let leading_i = w.slots.leading.expect("leading present");
1013        let trailing_i = w.slots.trailing.expect("trailing present");
1014        let headline_i = w.slots.headline;
1015
1016        // Leading sits at the 16dp inset, vertically centered in the 56dp row.
1017        assert_eq!(w.children[leading_i].origin().x, HPAD);
1018        assert_eq!(
1019            w.children[leading_i].origin().y,
1020            (ONE_LINE_HEIGHT - 24.0) / 2.0
1021        );
1022        // Trailing sits at the right inset.
1023        assert_eq!(w.children[trailing_i].origin().x, 300.0 - HPAD - 16.0);
1024        // The headline is pushed right of the leading + gap.
1025        assert_eq!(w.children[headline_i].origin().x, HPAD + 24.0 + GAP);
1026    }
1027
1028    #[test]
1029    fn a_three_line_rows_slots_pin_to_the_top_inset() {
1030        // `crossAxisAlignment: threeLine ? start : center` — a tall slot on a
1031        // three-line row starts at the 12dp inset instead of centering.
1032        let view: ListItem<()> = list_item("Title")
1033            .overline("OVER")
1034            .supporting("Sub")
1035            .leading(leaf(40.0, 40.0));
1036        let mut w = build(&view);
1037        layout(&mut w, 300.0);
1038        let leading_i = w.slots.leading.expect("leading present");
1039        assert_eq!(w.children[leading_i].origin().y, VPAD_THREE_LINE);
1040    }
1041
1042    #[test]
1043    fn build_orders_children_leading_overline_headline_supporting_trailing() {
1044        let view: ListItem<()> = list_item("H")
1045            .overline("O")
1046            .supporting("S")
1047            .leading(leaf(10.0, 10.0))
1048            .trailing(leaf(10.0, 10.0));
1049        let w = build(&view);
1050        assert_eq!(w.children.len(), 5);
1051        assert_eq!(w.slots.leading, Some(0));
1052        assert_eq!(w.slots.overline, Some(1));
1053        assert_eq!(w.slots.headline, 2);
1054        assert_eq!(w.slots.supporting, Some(3));
1055        assert_eq!(w.slots.trailing, Some(4));
1056    }
1057
1058    #[test]
1059    fn the_text_column_stacks_overline_headline_supporting_in_order() {
1060        let view: ListItem<()> = list_item("Headline")
1061            .overline("OVERLINE")
1062            .supporting("Supporting");
1063        let mut w = build(&view);
1064        layout(&mut w, 300.0);
1065        let over = w.children[w.slots.overline.expect("overline")].origin().y;
1066        let head = w.children[w.slots.headline].origin().y;
1067        let supp = w.children[w.slots.supporting.expect("supporting")]
1068            .origin()
1069            .y;
1070        assert!(over < head, "overline sits above the headline");
1071        assert!(head < supp, "supporting sits below the headline");
1072    }
1073
1074    #[test]
1075    fn every_slot_combination_x_state_builds_lays_out_and_paints() {
1076        // The reference's whole slot surface (`leading`/`overline`/
1077        // `supporting`/`trailing`, each optional) crossed with the states that
1078        // change what is painted. Each case must reach a laid-out row of its
1079        // declared height whose children all fit inside it, and paint at most
1080        // one container plus one state layer.
1081        let theme = crate::baseline();
1082        for leading in [false, true] {
1083            for overline in [false, true] {
1084                for supporting in [false, true] {
1085                    for trailing in [false, true] {
1086                        for (selected, contained, interactive) in [
1087                            (false, false, false),
1088                            (false, true, false),
1089                            (true, false, false),
1090                            (true, true, true),
1091                            (false, false, true),
1092                        ] {
1093                            let mut view: ListItem<()> = list_item("Headline");
1094                            if leading {
1095                                view = view.leading(leaf(24.0, 24.0));
1096                            }
1097                            if overline {
1098                                view = view.overline("OVERLINE");
1099                            }
1100                            if supporting {
1101                                view = view.supporting("Supporting text");
1102                            }
1103                            if trailing {
1104                                view = view.trailing(leaf(16.0, 16.0));
1105                            }
1106                            if interactive {
1107                                view = view.on_press(|_: &mut ()| {});
1108                            }
1109                            let expected_height = view.lines.height();
1110                            let view = view.selected(selected).contained(contained);
1111
1112                            let mut w = build(&view);
1113                            let size = layout(&mut w, 300.0);
1114                            let case = format!(
1115                                "leading={leading} overline={overline} \
1116                                 supporting={supporting} trailing={trailing} \
1117                                 selected={selected} contained={contained} \
1118                                 interactive={interactive}"
1119                            );
1120                            assert_eq!(size.height, expected_height, "{case}");
1121                            for pod in &w.children {
1122                                assert!(
1123                                    pod.origin().y >= 0.0
1124                                        && pod.origin().y + pod.size().height <= size.height + 0.01,
1125                                    "a child escapes the row vertically ({case})"
1126                                );
1127                            }
1128                            let rec = paint(&mut w, size, Some(&theme));
1129                            let expected_rects = usize::from(selected || contained);
1130                            assert_eq!(
1131                                rec.rrects.len(),
1132                                expected_rects,
1133                                "container fill count ({case})"
1134                            );
1135                        }
1136                    }
1137                }
1138            }
1139        }
1140    }
1141
1142    // --- Container surface ---
1143
1144    #[test]
1145    fn a_bare_row_paints_no_container_of_its_own() {
1146        // The default: a row is content, and whatever hosts it owns the
1147        // surface (the shape the reference's `M3EListItemScope` rows render).
1148        let view: ListItem<()> = list_item("Title");
1149        let mut w = build(&view);
1150        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
1151        assert!(rec.rrects.is_empty());
1152    }
1153
1154    #[test]
1155    fn a_contained_row_paints_the_filled_container_at_the_medium_radius() {
1156        let view: ListItem<()> = list_item("Title").contained(true);
1157        let mut w = build(&view);
1158        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), None);
1159        assert_eq!(rec.rrects.len(), 1, "just the container fill at rest");
1160        assert_eq!(rec.rrects[0].3, FILLED_CONTAINER);
1161        assert_eq!(rec.rrects[0].2, RADIUS);
1162    }
1163
1164    #[test]
1165    fn a_selected_row_paints_the_secondary_container_fill_with_or_without_contained() {
1166        let theme = crate::baseline();
1167        // Selection is a state the user must see, so it paints its fill even
1168        // on an otherwise-bare row.
1169        let view: ListItem<()> = list_item("Title").selected(true);
1170        let mut w = build(&view);
1171        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1172        assert_eq!(rec.rrects.len(), 1);
1173        assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1174        assert_eq!(rec.rrects[0].2, theme.shape.medium);
1175
1176        let contained: ListItem<()> = list_item("Title").contained(true).selected(true);
1177        let mut w = build(&contained);
1178        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1179        assert_eq!(rec.rrects[0].3, theme.scheme().secondary_container);
1180
1181        let unselected: ListItem<()> = list_item("Title").contained(true);
1182        let mut w = build(&unselected);
1183        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1184        assert_eq!(rec.rrects[0].3, theme.scheme().surface_container_highest);
1185    }
1186
1187    #[test]
1188    fn a_disabled_interactive_row_dims_its_container_and_paints_no_overlay() {
1189        let theme = crate::baseline();
1190        let view: ListItem<()> = list_item("Title")
1191            .contained(true)
1192            .on_press(|_: &mut ()| {})
1193            .enabled(false);
1194        let mut w = build(&view);
1195        let rec = paint(&mut w, Size::new(300.0, ONE_LINE_HEIGHT), Some(&theme));
1196        assert_eq!(
1197            rec.rrects[0].3,
1198            with_alpha(theme.scheme().on_surface, DISABLED_CONTAINER_OPACITY)
1199        );
1200        // The dim wash is itself translucent, so count rects rather than
1201        // filtering by alpha: one container fill, no state layer on top.
1202        assert_eq!(rec.rrects.len(), 1, "no state layer while disabled");
1203    }
1204
1205    // --- Interactivity ---
1206
1207    #[derive(Default)]
1208    struct Counter {
1209        presses: u32,
1210    }
1211
1212    fn dispatch<S: 'static>(
1213        w: &mut ListItemWidget,
1214        state: &mut S,
1215        event: &InputEvent,
1216    ) -> EventResult {
1217        let state_any: &mut dyn Any = state;
1218        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, ONE_LINE_HEIGHT));
1219        w.event(&mut ctx, event)
1220    }
1221
1222    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1223        InputEvent::Pointer(frust::authoring::PointerEvent {
1224            phase,
1225            position: Point::new(x, y),
1226            button: frust::authoring::PointerButton::Primary,
1227        })
1228    }
1229
1230    /// The same event on the secondary (right) button.
1231    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1232        InputEvent::Pointer(frust::authoring::PointerEvent {
1233            phase,
1234            position: Point::new(x, y),
1235            button: frust::authoring::PointerButton::Secondary,
1236        })
1237    }
1238
1239    #[test]
1240    fn interactive_row_fires_on_up_inside() {
1241        let view: ListItem<Counter> =
1242            list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1243        let mut w = build(&view);
1244        let mut state = Counter::default();
1245        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1246        assert!(w.captured);
1247        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1248        assert_eq!(state.presses, 1);
1249    }
1250
1251    #[test]
1252    fn a_secondary_press_never_presses_captures_or_fires() {
1253        let view: ListItem<Counter> =
1254            list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1255        let mut w = build(&view);
1256        let mut state = Counter::default();
1257        let r = dispatch(
1258            &mut w,
1259            &mut state,
1260            &secondary_ev(PointerPhase::Down, 10.0, 10.0),
1261        );
1262        assert_eq!(r, EventResult::Ignored);
1263        assert!(!w.state.pressed, "no pressed state layer on a right-click");
1264        assert!(!w.captured, "and no capture for the shell to wedge on");
1265        dispatch(
1266            &mut w,
1267            &mut state,
1268            &secondary_ev(PointerPhase::Up, 10.0, 10.0),
1269        );
1270        assert_eq!(state.presses, 0);
1271
1272        // The primary gesture is untouched by the guard.
1273        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1274        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1275        assert_eq!(state.presses, 1);
1276    }
1277
1278    #[test]
1279    fn interactive_row_up_outside_does_not_fire() {
1280        let view: ListItem<Counter> =
1281            list_item("Tap me").on_press(|s: &mut Counter| s.presses += 1);
1282        let mut w = build(&view);
1283        let mut state = Counter::default();
1284        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
1285        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 999.0, 999.0));
1286        assert_eq!(state.presses, 0);
1287    }
1288
1289    #[test]
1290    fn a_disabled_interactive_row_ignores_every_pointer_phase() {
1291        let view: ListItem<Counter> = list_item("Tap me")
1292            .on_press(|s: &mut Counter| s.presses += 1)
1293            .enabled(false);
1294        let mut w = build(&view);
1295        let mut state = Counter::default();
1296        assert_eq!(
1297            dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0)),
1298            EventResult::Ignored
1299        );
1300        assert!(!w.captured, "a disabled row never captures");
1301        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
1302        assert_eq!(state.presses, 0, "and never fires");
1303    }
1304
1305    // --- Interactive with focus-routed child events ---
1306
1307    /// A minimal widget that handles Key events for testing focus routing.
1308    struct FocusConsumer;
1309
1310    impl FocusConsumer {
1311        fn new() -> Self {
1312            FocusConsumer
1313        }
1314    }
1315
1316    struct FocusConsumerWidget;
1317
1318    impl View<Counter> for FocusConsumer {
1319        type Element = FocusConsumerWidget;
1320        fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusConsumerWidget {
1321            FocusConsumerWidget
1322        }
1323        fn rebuild(
1324            &self,
1325            _prev: &Self,
1326            _element: &mut FocusConsumerWidget,
1327            _ctx: &mut BuildCtx<'_>,
1328        ) -> ChangeFlags {
1329            ChangeFlags::NONE
1330        }
1331    }
1332
1333    impl Widget for FocusConsumerWidget {
1334        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1335            bc.max()
1336        }
1337        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1338        fn event(&mut self, _ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1339            // Handle any key event (focus-routed events reach here only if the
1340            // pod is focused, so a Key event here proves the routing worked).
1341            if let InputEvent::Key(_) = event {
1342                return EventResult::Handled;
1343            }
1344            EventResult::Ignored
1345        }
1346    }
1347
1348    #[test]
1349    fn interactive_row_with_trailing_control_forwards_key_events() {
1350        let view: ListItem<Counter> = list_item("Item")
1351            .trailing(FocusConsumer::new())
1352            .on_press(|s: &mut Counter| s.presses += 1);
1353        let mut w = build(&view);
1354        let mut state = Counter::default();
1355
1356        // Manually set the trailing control as focused to simulate a prior focus state.
1357        // (In real usage, the control would be focused by a pointer-down event,
1358        // but here we're directly testing the event-routing path.)
1359        let trailing_idx = w.slots.trailing.expect("trailing is present");
1360        w.children[trailing_idx].set_focused(true);
1361
1362        // Send a Key event — it should be forwarded to the focused child.
1363        let key_event = InputEvent::Key(frust::authoring::KeyEvent {
1364            key: frust::authoring::Key::Named(frust::authoring::NamedKey::Backspace),
1365            modifiers: frust::authoring::Modifiers::default(),
1366            repeat: false,
1367        });
1368        let result = dispatch(&mut w, &mut state, &key_event);
1369
1370        // Verify the key event was handled (forwarded to and handled by child).
1371        assert_eq!(
1372            result,
1373            EventResult::Handled,
1374            "key event must be forwarded to focused child"
1375        );
1376
1377        // The row press should not have fired (the row only fires on pointer Up).
1378        assert_eq!(
1379            state.presses, 0,
1380            "row press callback does not fire on key event"
1381        );
1382    }
1383
1384    // --- Hover ---
1385
1386    /// Two interactive rows in a `Column` under a real `RenderRoot` — the only
1387    /// harness that can exercise hover at all, since the hover link is recorded by
1388    /// the root's event pass and read back through `PaintCtx::is_hovered`. It is
1389    /// also the out-of-tree reachability proof: everything here is reached through
1390    /// the `frust` facade, exactly as a third-party catalog would.
1391    struct HoverHarness {
1392        root: frust_core::RenderRoot<Counter, frust::FlexView<Counter>>,
1393        state: Counter,
1394        tcx: TextContext,
1395    }
1396
1397    /// Window/row geometry: two stacked 56dp rows in a 300x200 window.
1398    const HOVER_WINDOW: Size = Size::new(300.0, 200.0);
1399
1400    impl HoverHarness {
1401        fn new() -> Self {
1402            let mut h = HoverHarness {
1403                root: frust_core::RenderRoot::new(),
1404                state: Counter::default(),
1405                tcx: TextContext::new(),
1406            };
1407            h.rebuild_layout();
1408            h
1409        }
1410
1411        fn rebuild_layout(&mut self) {
1412            let mut app = |_s: &mut Counter| {
1413                frust::column()
1414                    .child(list_item::<Counter>("first").on_press(|s: &mut Counter| s.presses += 1))
1415                    .child(
1416                        list_item::<Counter>("second").on_press(|s: &mut Counter| s.presses += 1),
1417                    )
1418            };
1419            self.root.rebuild(&mut app, &mut self.state);
1420            self.root
1421                .layout_with_text(HOVER_WINDOW, &mut self.tcx as &mut dyn Any);
1422        }
1423
1424        fn dispatch(
1425            &mut self,
1426            phase: PointerPhase,
1427            x: f64,
1428            y: f64,
1429        ) -> frust::authoring::EventOutcome {
1430            self.root.event(
1431                &mut self.state,
1432                &InputEvent::Pointer(frust::authoring::PointerEvent {
1433                    phase,
1434                    position: Point::new(x, y),
1435                    button: frust::authoring::PointerButton::Primary,
1436                }),
1437            )
1438        }
1439
1440        /// The y-centre of row `index` (rows are `ONE_LINE_HEIGHT` tall).
1441        fn row_y(index: usize) -> f64 {
1442            ONE_LINE_HEIGHT * index as f64 + ONE_LINE_HEIGHT / 2.0
1443        }
1444
1445        /// Paint and report which rows painted a state-layer overlay, by the
1446        /// overlay rect's y origin. The opaque container fills every row paints
1447        /// are filtered out by alpha.
1448        fn overlay_rows(&mut self) -> Vec<usize> {
1449            let mut rec = RRectRecorder::default();
1450            self.root.paint(&mut rec, frust::FrameTime::ZERO);
1451            rec.overlays()
1452                .iter()
1453                .map(|(origin, _)| (origin.y / ONE_LINE_HEIGHT).round() as usize)
1454                .collect()
1455        }
1456
1457        /// The alpha of the single overlay painted this frame.
1458        fn overlay_alpha(&mut self) -> f32 {
1459            let mut rec = RRectRecorder::default();
1460            self.root.paint(&mut rec, frust::FrameTime::ZERO);
1461            rec.overlays().first().expect("one overlay painted").1
1462        }
1463    }
1464
1465    #[test]
1466    fn hovering_a_row_paints_the_hover_overlay() {
1467        let mut h = HoverHarness::new();
1468        assert!(h.overlay_rows().is_empty(), "no overlay at rest");
1469
1470        // The frame that makes the overlay appear is the row's own: `claim_hover`
1471        // requests none, and the pipeline manufactures one only for a hover that
1472        // ended, so the row's change-gated `request_redraw` is what asks here.
1473        let gain = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1474        assert!(gain.needs_redraw, "entering a row repaints");
1475        assert_eq!(h.overlay_rows(), vec![0], "the hovered row tints");
1476        assert_eq!(
1477            h.overlay_alpha(),
1478            crate::interaction::HOVER_OPACITY,
1479            "at the documented M3 hover opacity"
1480        );
1481
1482        // Moving within the same row re-claims but changes nothing.
1483        let settled = h.dispatch(PointerPhase::Move, 160.0, HoverHarness::row_y(0));
1484        assert!(!settled.needs_redraw, "an unchanged flag asks for nothing");
1485        assert_eq!(h.overlay_rows(), vec![0]);
1486    }
1487
1488    #[test]
1489    fn hover_moves_to_the_row_under_the_pointer() {
1490        let mut h = HoverHarness::new();
1491        h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1492        assert_eq!(h.overlay_rows(), vec![0]);
1493
1494        // The row the pointer left never receives an event about it — the move
1495        // routes to its sibling — so this is the case a widget cannot handle on
1496        // its own, and exactly one row may end up tinted. The arriving row's own
1497        // flag change is what asks for the frame (the root's hover mirror is
1498        // identity-free and sees `true` → `true` across a handoff); a repaint being
1499        // global is what lets the departing row drop its tint in the same frame.
1500        let handoff = h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1501        assert!(handoff.needs_redraw, "a row-to-row handoff repaints");
1502        assert_eq!(h.overlay_rows(), vec![1]);
1503
1504        // Off both rows: nothing tints, and the row that lost hover gets the
1505        // repaint it could not ask for itself.
1506        let outcome = h.dispatch(PointerPhase::Move, 150.0, 180.0);
1507        assert!(outcome.needs_redraw);
1508        assert!(h.overlay_rows().is_empty());
1509    }
1510
1511    #[test]
1512    fn a_captured_drag_paints_no_hover_overlay() {
1513        let mut h = HoverHarness::new();
1514        // Press row 0: the row captures, and the press overlay (10%) is what
1515        // shows — never the hover one.
1516        h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
1517        assert_eq!(h.overlay_rows(), vec![0]);
1518        assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
1519
1520        // Drag off the row: the captured row keeps receiving moves and its own
1521        // `Move` arm calls `claim_hover()`, which must record nothing.
1522        h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1523        assert!(
1524            h.overlay_rows().is_empty(),
1525            "dragged outside: neither pressed nor hovered"
1526        );
1527
1528        // Release outside: no press fires, and no hover was left behind.
1529        h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
1530        assert_eq!(h.state.presses, 0);
1531        assert!(h.overlay_rows().is_empty());
1532    }
1533
1534    #[test]
1535    fn hover_survives_a_rebuild_and_clears_on_press() {
1536        let mut h = HoverHarness::new();
1537        h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(0));
1538        h.rebuild_layout();
1539        assert_eq!(
1540            h.overlay_rows(),
1541            vec![0],
1542            "an in-place rebuild keeps the pod that holds the link"
1543        );
1544
1545        // A `Down` ends the hover link outright; the press overlay takes over.
1546        h.dispatch(PointerPhase::Down, 150.0, HoverHarness::row_y(0));
1547        assert_eq!(h.overlay_alpha(), frust::authoring::PRESSED_OPACITY);
1548        h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(0));
1549        assert_eq!(h.state.presses, 1);
1550        assert!(
1551            h.overlay_rows().is_empty(),
1552            "a click leaves no hover behind until the pointer moves again"
1553        );
1554    }
1555
1556    #[test]
1557    fn an_uncaptured_drag_tints_the_row_only_until_the_lift() {
1558        // The touch shape: a contact that started on empty chrome captures nothing,
1559        // so its moves are ordinary hover passes and the row it slides over does
1560        // tint — nothing in the pipeline tells a finger from a mouse. The lift is
1561        // what bounds it: a lifted contact sends no further move, so `Up` ending the
1562        // hover link is the only thing that can drop the tint.
1563        let mut h = HoverHarness::new();
1564        h.dispatch(PointerPhase::Down, 150.0, 180.0);
1565        assert!(h.overlay_rows().is_empty(), "pressed empty chrome, no row");
1566
1567        h.dispatch(PointerPhase::Move, 150.0, HoverHarness::row_y(1));
1568        assert_eq!(
1569            h.overlay_rows(),
1570            vec![1],
1571            "an uncaptured drag over the row is a hover pass"
1572        );
1573
1574        let lift = h.dispatch(PointerPhase::Up, 150.0, HoverHarness::row_y(1));
1575        assert!(
1576            lift.needs_redraw,
1577            "the lift asks for the frame that clears it"
1578        );
1579        assert!(
1580            h.overlay_rows().is_empty(),
1581            "the tint is transient: the lift ends the link"
1582        );
1583        assert_eq!(h.state.presses, 0, "no press fired: the row never captured");
1584    }
1585
1586    // --- Semantics ---
1587
1588    #[test]
1589    fn semantics_row_is_a_list_item_with_the_headline_label() {
1590        fn logic(_s: &mut ()) -> ListItem<()> {
1591            list_item("Inbox")
1592        }
1593        let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1594        let mut state = ();
1595        root.rebuild(&mut logic, &mut state);
1596        let mut tcx = TextContext::new();
1597        root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1598        let update = root.semantics();
1599
1600        let (_, item) = update
1601            .nodes
1602            .iter()
1603            .find(|(_, n)| n.role() == Role::ListItem)
1604            .expect("row contributes a Role::ListItem node");
1605        assert!(
1606            !item.children().is_empty(),
1607            "the headline text is a semantics child of the row"
1608        );
1609        // The headline run's text is carried on a Label node.
1610        assert!(
1611            update
1612                .nodes
1613                .iter()
1614                .any(|(_, n)| n.role() == Role::Label && n.value() == Some("Inbox")),
1615            "the headline text is announced"
1616        );
1617        assert_eq!(
1618            item.is_selected(),
1619            None,
1620            "an unselected row omits the flag entirely (accesskit's own guidance)"
1621        );
1622    }
1623
1624    #[test]
1625    fn interactive_row_semantics_carries_a_click_action() {
1626        fn logic(_s: &mut ()) -> ListItem<()> {
1627            list_item("Go").on_press(|_s: &mut ()| {})
1628        }
1629        let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1630        let mut state = ();
1631        root.rebuild(&mut logic, &mut state);
1632        let mut tcx = TextContext::new();
1633        root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1634        let update = root.semantics();
1635        let (_, item) = update
1636            .nodes
1637            .iter()
1638            .find(|(_, n)| n.role() == Role::ListItem)
1639            .expect("row node");
1640        assert!(
1641            item.supports_action(Action::Click),
1642            "an interactive row exposes the Click action"
1643        );
1644        assert!(!item.is_disabled());
1645    }
1646
1647    #[test]
1648    fn semantics_disabled_row_reports_disabled_and_selected_row_reports_selection() {
1649        fn disabled_logic(_s: &mut ()) -> ListItem<()> {
1650            list_item("Go").on_press(|_s: &mut ()| {}).enabled(false)
1651        }
1652        let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1653        let mut state = ();
1654        root.rebuild(&mut disabled_logic, &mut state);
1655        let mut tcx = TextContext::new();
1656        root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1657        let update = root.semantics();
1658        let (_, item) = update
1659            .nodes
1660            .iter()
1661            .find(|(_, n)| n.role() == Role::ListItem)
1662            .expect("row node");
1663        assert!(item.is_disabled());
1664        assert!(!item.supports_action(Action::Click));
1665
1666        fn selected_logic(_s: &mut ()) -> ListItem<()> {
1667            list_item("Go").selected(true)
1668        }
1669        let mut root: RenderRoot<(), ListItem<()>> = RenderRoot::new();
1670        root.rebuild(&mut selected_logic, &mut state);
1671        root.layout_with_text(Size::new(300.0, 100.0), &mut tcx as &mut dyn Any);
1672        let update = root.semantics();
1673        let (_, item) = update
1674            .nodes
1675            .iter()
1676            .find(|(_, n)| n.role() == Role::ListItem)
1677            .expect("row node");
1678        assert_eq!(item.is_selected(), Some(true));
1679    }
1680
1681    #[cfg(feature = "bundled-fonts")]
1682    #[test]
1683    fn every_text_slot_paints_in_roboto_flex_under_the_material_theme() {
1684        // Overline, headline and supporting line — all three text slots a
1685        // row builds from plain `text(..)`. See
1686        // `crate::appbar::top::typeface_probe`.
1687        use crate::appbar::top::typeface_probe::{Face, assert_paints_only_in};
1688        let flex = crate::tokens::font_data()[0];
1689        let runs = assert_paints_only_in(
1690            "the list item's text slots",
1691            |_: &mut ()| {
1692                list_item::<()>("Headline")
1693                    .overline("Overline")
1694                    .supporting("Supporting text")
1695            },
1696            crate::baseline(),
1697            &[flex],
1698            Size::new(300.0, 100.0),
1699            Face {
1700                bytes: flex,
1701                name: "Roboto Flex",
1702            },
1703        );
1704        assert!(
1705            runs >= 3,
1706            "fixture sanity: expected a run per text slot, got {runs}"
1707        );
1708    }
1709}