Skip to main content

frust_material/
expandable_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/ — components/m3e_expandable_item.dart,
4//   components/m3e_expandable_item_body.dart,
5//   components/m3e_expandable_list_base.dart,
6//   styles/m3e_expandable_style.dart, styles/m3e_list_theme.dart's
7//   `M3EListExpandableTheme`, enums/m3e_expandable_enums.dart (retrieved
8//   2026-08-19). The reference's own item is in turn ported from
9//   https://github.com/Mudit200408/m3e_expandable (MIT, © 2026 Mudit
10//   Purohit) — already credited in `plugins/material/NOTICE`.
11// The offstage double-measurement overlay, the tap-body/tap-icon interaction
12// switches, tooltips, ink splash/factory overrides, and the per-progress
13// header/body builders stay unported — see the module docs' Not ported.
14
15//! The M3E **expandable list**: a stack of card-backed disclosure items, each a
16//! pressable header row with a rotating chevron over a spring-revealed body.
17//!
18//! [`expandable_list`] is *controlled*: the caller owns the expanded index set
19//! and receives [`ExpandableListView::on_toggle`] with the pressed item's
20//! index; the widget never mutates its own `expanded` (the same never-self-
21//! mutating contract every control in this catalog follows, see
22//! `docs/CODE_STANDARDS.md`'s Interaction Semantics).
23//!
24//! # Reveal timeline, and the layout-skip trap
25//!
26//! Each item's body child is **always laid out at its full natural height** and
27//! stays laid out throughout; the reveal is a `0..1` fraction of that measured
28//! height applied twice — the item's reported height is `header + fraction ·
29//! body_height` (layout), and the body is painted under a clip of exactly that
30//! revealed band (paint). The animation therefore never re-measures the child
31//! mid-flight, and a collapsed item still contributes its subtree to layout and
32//! semantics.
33//!
34//! The fraction is driven by a spring ([`REVEAL_SPRING`], the reference's
35//! `expressiveSpatialDefault` — `MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`)
36//! launched fresh on every toggle from wherever the fraction currently reads,
37//! so an interrupted reveal reverses smoothly rather than jumping.
38//!
39//! **The clock lives on [`PaintCtx`], but the height is computed in `layout`.**
40//! That split is the known trap for this widget class: on the mobile intra-frame
41//! layout skip (`docs/SHELLS_ARCHITECTURE.md`'s `frame_gate`), layout does not
42//! re-run merely because paint asked for another frame, so a paint-only
43//! `request_frame` would leave the revealed height frozen while the chevron kept
44//! turning. `paint` records what fraction each item's own last `layout` call
45//! used ([`ExpandableListWidget::laid_out_reveals`]) and, after advancing every
46//! reveal, compares the live values against it: any difference — an ordinary
47//! in-flight frame, the reduce-motion snap, or the driver's *landing* frame —
48//! requests exactly one relayout via [`PaintCtx::request_layout`] (which implies
49//! `request_frame`). Gating solely on [`AnimationController::advance`]'s own
50//! return (still the first line of defense, so a still-in-flight frame with a
51//! zero-delta seed never stalls the pump) would miss the landing frame
52//! specifically: `advance` reports `false` on the very pass that snaps the
53//! controller's value onto its target, so without the comparison the last
54//! geometry `layout` would ever see is a hair short of rest. This is the same
55//! mechanism `frust_glyph::accordion` documents, and
56//! `paint_requests_layout_through_the_settle_frame` is its regression guard
57//! here.
58//!
59//! # Accordion (single-open) vs independent
60//!
61//! Upstream's `M3EExpandableStateMixin.handleToggle` owns both modes behind an
62//! `allowMultipleExpanded` flag. Since the set is app-owned here, that rule
63//! ships as a pure controller function instead — [`toggle_expanded`] with an
64//! [`ExpandMode`] — mirroring how [`crate::selection`]'s index-set controller
65//! functions are shaped:
66//!
67//! ```
68//! use std::collections::BTreeSet;
69//! use frust_material::{ExpandMode, toggle_expanded};
70//!
71//! let set = |items: &[usize]| items.iter().copied().collect::<BTreeSet<usize>>();
72//! let open = set(&[0]);
73//! // Accordion: opening 2 closes 0.
74//! assert_eq!(toggle_expanded(&open, 2, ExpandMode::SingleOpen), set(&[2]));
75//! // Independent: both stay open.
76//! assert_eq!(toggle_expanded(&open, 2, ExpandMode::Independent), set(&[0, 2]));
77//! // Either way, toggling an already-open index closes it.
78//! assert!(toggle_expanded(&open, 0, ExpandMode::SingleOpen).is_empty());
79//! ```
80//!
81//! # Corner matrix and interaction radii
82//!
83//! Item corners follow the same position matrix [`crate::card_list`] resolves
84//! ([`crate::card_position`]/[`crate::card_radii`]) — outer on a stack's
85//! outward edges, inner on every adjoining one. Upstream additionally swaps the
86//! *inner* radius while an item is hovered or pressed
87//! ([`EXPANDABLE_HOVER_RADIUS`] / [`EXPANDABLE_PRESSED_RADIUS`]), and rounds an
88//! expanded item uniformly when `expandedRadius` is set
89//! ([`ExpandableListView::expanded_radius`]); both are ported.
90//!
91//! # Interaction
92//!
93//! The **header row alone** toggles (upstream's default `tapHeaderToToggle:
94//! true`, `tapBodyToExpand`/`tapBodyToCollapse: false`) — a pointer in the
95//! revealed body routes to the body child instead, so a control inside a body
96//! stays live. The header press follows the catalog's fire-on-up-inside
97//! contract, and hover follows the three-part claim → latch → self-correct
98//! rule.
99//!
100//! # Semantics
101//!
102//! One [`Role::List`] container node carrying [`Action::Click`], with every
103//! header and body pod's own node forwarded beneath it.
104//! [`frust::authoring::SemanticsCtx`] exposes no seam for contributing a node at
105//! a *child's* bounds, so a synthetic per-item `expanded` node would publish the
106//! whole stack's rect for every row — the same trade
107//! [`crate::card_list`] documents. An app that needs per-item expanded state in
108//! the accessibility tree stacks single-item [`expandable_list`]s of its own
109//! instead, one per row.
110//!
111//! # Not ported
112//!
113//! * **The offstage double-measurement overlay** (`_buildMeasurementOverlay`) —
114//!   upstream builds the body twice offstage (at progress `0` and `1`) to learn
115//!   a collapsed and an expanded content height, because its body *builder*
116//!   takes the progress and may return different content at each end. This port
117//!   takes a single body [`frust::authoring::AnyView`], so there is exactly one
118//!   natural height to measure and the overlay has nothing to resolve.
119//! * **`tapBodyToExpand`/`tapBodyToCollapse`/`tapIconToToggle`** and the
120//!   tooltips/`useInkWell`/`splashColor`/`splashFactory`/`enableFeedback`
121//!   knobs — the same customization surface [`crate::card_list`] leaves
122//!   unported. The header is the toggle target, full stop.
123//! * **`iconPlacement`, `expandIcon`/`collapseIcon`, `headerAlignment`,
124//!   `bodyAlignment`, `margin`, `titleSubtitleGap`, `elevation`, `border`** —
125//!   the chevron is trailing and fixed ([`crate::icons::EXPAND_MORE_ROUNDED`]),
126//!   the header is start-aligned, and the card is flat and borderless, matching
127//!   every default in `M3EExpandableStyle`.
128//! * **The 40ms `TweenAnimationBuilder` radius cross-fade** between two
129//!   identical radii — a no-op in the reference (`begin` and `end` are the same
130//!   value); the resolved radii are applied directly here, the same
131//!   simplification [`crate::card_list`] documents for `M3ECardRadiusMotion`.
132
133use std::collections::BTreeSet;
134use std::rc::Rc;
135use std::time::Duration;
136
137use frust::authoring::{Action, Role};
138use frust::authoring::{
139    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, CornerRadii, ErasedArgCallback,
140    EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx,
141    View, Widget, any,
142};
143use frust::{AnimationController, FrameTime, IconData, SpringDesc, Theme, Tween};
144use kurbo::{Affine, BezPath, Point, Size, Vec2};
145use peniko::{Brush, Color};
146
147use crate::card_list::{card_position, card_radii};
148use crate::interaction::{HapticSignal, InteractionState, MaterialHaptics};
149use crate::press::presses;
150
151/// Outward corner radius of the first/last (and only) item
152/// (`M3EListExpandableTheme.defaultOuterRadius`, 24dp — the same value
153/// [`crate::CARD_LIST_OUTER_RADIUS`] carries, which is what makes an expandable
154/// stack read like a card list).
155pub const EXPANDABLE_OUTER_RADIUS: f64 = 24.0;
156/// Corner radius of every adjoining item edge
157/// (`M3EListExpandableTheme.defaultInnerRadius`, 4dp).
158pub const EXPANDABLE_INNER_RADIUS: f64 = 4.0;
159/// Inner corner radius while an item is hovered
160/// (`M3EListExpandableTheme.defaultHoverRadius`, 10dp).
161pub const EXPANDABLE_HOVER_RADIUS: f64 = 10.0;
162/// Inner corner radius while an item is pressed
163/// (`M3EListExpandableTheme.defaultPressedRadius`, 4dp).
164pub const EXPANDABLE_PRESSED_RADIUS: f64 = 4.0;
165/// Vertical gap between adjacent items
166/// (`M3EListExpandableTheme.defaultGap`, 4dp).
167pub const EXPANDABLE_GAP: f64 = 4.0;
168/// Total chevron rotation across a full reveal, in radians
169/// (`M3EListExpandableTheme.defaultIconRotationAngle`, `math.pi`) — the
170/// down-pointing `expand_more` glyph ends up pointing up.
171pub const EXPANDABLE_ICON_ROTATION: f64 = std::f64::consts::PI;
172
173/// Header horizontal padding, in logical px
174/// (`M3EListExpandableTheme.defaultHeaderPadding`'s L/R).
175const HEADER_PAD_X: f64 = 16.0;
176/// Header top padding, in logical px (that same `EdgeInsets`' top).
177const HEADER_PAD_TOP: f64 = 14.0;
178/// Header bottom padding, in logical px (that same `EdgeInsets`' bottom).
179const HEADER_PAD_BOTTOM: f64 = 2.0;
180/// Body horizontal padding, in logical px
181/// (`M3EListExpandableTheme.defaultBodyPadding`'s L/R).
182const BODY_PAD_X: f64 = 16.0;
183/// Body top padding, in logical px (that same `EdgeInsets`' top — zero, since
184/// the header's own bottom padding already separates them).
185const BODY_PAD_TOP: f64 = 0.0;
186/// Body bottom padding, in logical px (that same `EdgeInsets`' bottom).
187const BODY_PAD_BOTTOM: f64 = 20.0;
188/// Chevron glyph size, in logical px (Flutter's default `Icon` size, which
189/// `M3EExpandableStyle.expandIcon`'s bare `Icon(...)` inherits).
190const CHEVRON_SIZE: f64 = 24.0;
191/// Padding around the chevron on every side, in logical px
192/// (`M3EListExpandableTheme.defaultIconPadding`, `EdgeInsets.all(8)`).
193const CHEVRON_PAD: f64 = 8.0;
194/// The trailing slot the chevron and its padding reserve in a header row.
195const CHEVRON_SLOT: f64 = CHEVRON_SIZE + CHEVRON_PAD * 2.0;
196
197/// Fallback item fill (a theme resolves this from
198/// `colors.surface_container_highest`) — the same unthemed value
199/// [`crate::card_list`] falls back to.
200const FILLED_CONTAINER: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
201/// Fallback state-layer content color (a theme resolves this from
202/// `colors.on_surface`).
203const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
204/// Fallback chevron color (a theme resolves this from
205/// `colors.on_surface_variant`).
206const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
207
208/// The reveal spring: `MaterialSpringMotion.expressiveSpatialDefault`
209/// (stiffness 380, ζ 0.8) — the reference's own
210/// `M3EListExpandableTheme.expandMotion`/`collapseMotion` default, equal to
211/// [`crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`]. Restated as a
212/// `SpringDesc` here because a `fling` needs one and this crate's event/paint
213/// code never reads a `Theme` for motion (the same shape
214/// [`crate::split_button`]'s `CHEVRON_SPRING` takes; a test pins the equality).
215pub const REVEAL_SPRING: SpringDesc = SpringDesc {
216    mass: 1.0,
217    stiffness: 380.0,
218    damping_ratio: 0.8,
219};
220
221/// Nominal period seeding a reveal [`AnimationController`]'s clock; the motion
222/// is spring-driven via `fling`, so this backs construction only.
223const REVEAL_PERIOD: Duration = Duration::from_millis(300);
224
225/// Launch velocity for a reveal leg. Sub-visible on purpose: the spring's shape
226/// comes from its displacement (`x0`), so only the sign matters — and it is
227/// always positive here, because the leg runs `0 → 1` in *progress-toward-the-
228/// new-target* space, never in reveal space (see [`Reveal`]). The same
229/// sign-only convention [`crate::switch`]'s `RELEASE_VELOCITY` documents.
230const REVEAL_LAUNCH_VELOCITY: f64 = 1e-3;
231
232// ---- Controller: pure functions over an app-owned `BTreeSet<usize>` ------
233
234/// How [`toggle_expanded`] treats the items that are already open
235/// (`M3EExpandableListBase.allowMultipleExpanded`).
236#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
237pub enum ExpandMode {
238    /// Accordion: opening an item closes every other one
239    /// (`allowMultipleExpanded: false`, the reference's default).
240    #[default]
241    SingleOpen,
242    /// Independent: each item opens and closes on its own
243    /// (`allowMultipleExpanded: true`).
244    Independent,
245}
246
247/// Whether `index` is expanded (`M3EExpandableStateMixin.isExpanded`).
248pub fn is_expanded(expanded: &BTreeSet<usize>, index: usize) -> bool {
249    expanded.contains(&index)
250}
251
252/// The expanded set with `index`'s membership flipped under `mode`
253/// (`M3EExpandableStateMixin.handleToggle`).
254///
255/// Collapsing is mode-independent: an already-expanded `index` is simply
256/// removed. Expanding under [`ExpandMode::SingleOpen`] clears the set first, so
257/// exactly one item is ever open — the accordion rule.
258pub fn toggle_expanded(
259    expanded: &BTreeSet<usize>,
260    index: usize,
261    mode: ExpandMode,
262) -> BTreeSet<usize> {
263    if expanded.contains(&index) {
264        let mut next = expanded.clone();
265        next.remove(&index);
266        return next;
267    }
268    let mut next = match mode {
269        ExpandMode::SingleOpen => BTreeSet::new(),
270        ExpandMode::Independent => expanded.clone(),
271    };
272    next.insert(index);
273    next
274}
275
276// ---- The reveal driver ----------------------------------------------------
277
278/// One item's `0..1` reveal fraction, spring-driven.
279///
280/// The controller runs a plain `0 → 1` leg (its `fling` target is always `1.0`)
281/// and [`Tween`] maps that leg onto `from → target` in reveal space, so a
282/// retarget mid-flight starts a fresh leg from wherever the fraction currently
283/// reads instead of jumping. The controller's value is read **clamped**: this
284/// spring is under-damped (ζ 0.8) and an overshoot past `1.0` would reveal a
285/// band taller than the measured body — empty space below the content — which
286/// is why the reveal clamps where [`crate::switch`]'s spatial travel
287/// deliberately does not.
288struct Reveal {
289    /// The authoritative fraction, always in `0.0..=1.0`.
290    value: f64,
291    /// The fraction the current leg is heading for (`0.0` or `1.0`).
292    target: f64,
293    /// Maps the controller's `0 → 1` leg onto `from → target`.
294    tween: Tween<f64>,
295    driver: AnimationController,
296}
297
298impl Reveal {
299    /// A driver resting at `initial` (`0.0` collapsed, `1.0` expanded), with no
300    /// leg in flight — an item built already-expanded shows its body on the
301    /// first frame rather than animating into it, matching upstream's
302    /// `..value = widget.isExpanded ? 1.0 : 0.0` seed.
303    fn new(initial: f64) -> Self {
304        Self {
305            value: initial,
306            target: initial,
307            tween: Tween::new(initial, initial),
308            driver: AnimationController::new(REVEAL_PERIOD),
309        }
310    }
311
312    /// Launch a fresh leg toward `target` (a no-op if already heading there).
313    fn set_target(&mut self, target: f64) {
314        if self.target == target {
315            return;
316        }
317        self.target = target;
318        self.tween = Tween::new(self.value, target);
319        self.driver = AnimationController::new(REVEAL_PERIOD);
320        self.driver.fling(REVEAL_LAUNCH_VELOCITY, REVEAL_SPRING);
321    }
322
323    /// Jump straight to the target, cancelling any leg (the reduce-motion path).
324    fn snap(&mut self) {
325        self.value = self.target;
326        self.tween = Tween::new(self.target, self.target);
327        self.driver.stop();
328    }
329
330    /// Advance one frame, returning whether the leg is still in flight.
331    fn advance(&mut self, now: FrameTime) -> bool {
332        let animating = self.driver.advance(now);
333        self.value = self.tween.lerp(self.driver.value_clamped()).clamp(0.0, 1.0);
334        animating
335    }
336}
337
338// ---- The view -------------------------------------------------------------
339
340/// One expandable item: a header row over a body revealed when it expands.
341pub struct ExpandableItem<State: 'static> {
342    header: AnyView<State>,
343    body: AnyView<State>,
344}
345
346/// Build an [`ExpandableItem`] from a `header` row and the `body` it reveals
347/// (upstream's `headerBuilder`/`bodyBuilder` pair, minus their progress
348/// argument — see the [module docs](self)' Not ported).
349pub fn expandable_item<State: 'static, H: View<State>, B: View<State>>(
350    header: H,
351    body: B,
352) -> ExpandableItem<State> {
353    ExpandableItem {
354        header: any(header),
355        body: any(body),
356    }
357}
358
359/// A view-held, typed per-index toggle callback (erased on build).
360type OnToggleIndex<State> = Rc<dyn Fn(&mut State, usize)>;
361
362/// A declarative M3E expandable list. See the [module docs](self).
363pub struct ExpandableListView<State: 'static> {
364    items: Vec<ExpandableItem<State>>,
365    expanded: BTreeSet<usize>,
366    outer_radius: f64,
367    inner_radius: f64,
368    hover_radius: f64,
369    pressed_radius: f64,
370    expanded_radius: Option<f64>,
371    gap: f64,
372    color: Option<Color>,
373    haptic: HapticSignal,
374    on_toggle: Option<OnToggleIndex<State>>,
375}
376
377/// Stack `items` as an expandable list. Controlled: pass the app-owned
378/// [`expanded`](ExpandableListView::expanded) set and handle
379/// [`on_toggle`](ExpandableListView::on_toggle), resolving the new set through
380/// [`toggle_expanded`].
381pub fn expandable_list<State: 'static>(
382    items: impl IntoIterator<Item = ExpandableItem<State>>,
383) -> ExpandableListView<State> {
384    ExpandableListView {
385        items: items.into_iter().collect(),
386        expanded: BTreeSet::new(),
387        outer_radius: EXPANDABLE_OUTER_RADIUS,
388        inner_radius: EXPANDABLE_INNER_RADIUS,
389        hover_radius: EXPANDABLE_HOVER_RADIUS,
390        pressed_radius: EXPANDABLE_PRESSED_RADIUS,
391        expanded_radius: None,
392        gap: EXPANDABLE_GAP,
393        color: None,
394        haptic: HapticSignal::None,
395        on_toggle: None,
396    }
397}
398
399impl<State: 'static> ExpandableListView<State> {
400    /// Mark these indices expanded (`initiallyExpanded`, then every later
401    /// app-resolved set). The widget never writes this back.
402    pub fn expanded(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
403        self.expanded = indices.into_iter().collect();
404        self
405    }
406
407    /// Override the outward corner radius of the first/last (and only) item.
408    /// Defaults to [`EXPANDABLE_OUTER_RADIUS`].
409    pub fn outer_radius(mut self, radius: f64) -> Self {
410        self.outer_radius = radius;
411        self
412    }
413
414    /// Override the radius of every adjoining item edge. Defaults to
415    /// [`EXPANDABLE_INNER_RADIUS`].
416    pub fn inner_radius(mut self, radius: f64) -> Self {
417        self.inner_radius = radius;
418        self
419    }
420
421    /// Override the inner radius used while an item is hovered. Defaults to
422    /// [`EXPANDABLE_HOVER_RADIUS`].
423    pub fn hover_radius(mut self, radius: f64) -> Self {
424        self.hover_radius = radius;
425        self
426    }
427
428    /// Override the inner radius used while an item is pressed. Defaults to
429    /// [`EXPANDABLE_PRESSED_RADIUS`].
430    pub fn pressed_radius(mut self, radius: f64) -> Self {
431        self.pressed_radius = radius;
432        self
433    }
434
435    /// Round an *expanded* item uniformly at `radius`, overriding its position
436    /// radii entirely (`M3EExpandableStyle.expandedRadius`). Unset by default,
437    /// as upstream.
438    pub fn expanded_radius(mut self, radius: f64) -> Self {
439        self.expanded_radius = Some(radius);
440        self
441    }
442
443    /// Override the vertical gap between adjacent items. Defaults to
444    /// [`EXPANDABLE_GAP`].
445    pub fn gap(mut self, gap: f64) -> Self {
446        self.gap = gap;
447        self
448    }
449
450    /// Replace the item fill (`M3EExpandableStyle.color`), which otherwise
451    /// resolves from `colors.surface_container_highest`.
452    pub fn color(mut self, color: Color) -> Self {
453        self.color = Some(color);
454        self
455    }
456
457    /// Set the haptic fired immediately before
458    /// [`on_toggle`](Self::on_toggle) (`M3EExpandableStyle.haptic`). Defaults
459    /// to [`HapticSignal::None`].
460    pub fn haptic(mut self, haptic: HapticSignal) -> Self {
461        self.haptic = haptic;
462        self
463    }
464
465    /// Fire `on_toggle` with an item's index when its header is released
466    /// inside itself. Without this the stack is inert chrome: no press state,
467    /// no hover claim, and pointer events route straight into the children.
468    pub fn on_toggle<F: Fn(&mut State, usize) + 'static>(mut self, on_toggle: F) -> Self {
469        self.on_toggle = Some(Rc::new(on_toggle));
470        self
471    }
472
473    /// The header/body views flattened into one paint-ordered child list —
474    /// `[header_0, body_0, header_1, body_1, ...]`, the order
475    /// [`ExpandableListWidget::pods`] retains.
476    fn flat_views(&self) -> Vec<&AnyView<State>> {
477        self.items
478            .iter()
479            .flat_map(|item| [&item.header, &item.body])
480            .collect()
481    }
482}
483
484// ---- The widget -----------------------------------------------------------
485
486/// One item's measured geometry in the stack's local space, recorded during
487/// layout and read by both paint and the event pass's hit test.
488#[derive(Clone, Copy, Debug, Default, PartialEq)]
489struct ItemGeometry {
490    /// Local y of the item's top edge.
491    y: f64,
492    /// The header row's height (the toggle target's extent).
493    header: f64,
494    /// The body's full natural height, padding included — the reveal's `1.0`.
495    body_full: f64,
496    /// The item's currently reported height (`header + reveal · body_full`).
497    height: f64,
498}
499
500/// The retained widget for an [`ExpandableListView`].
501pub struct ExpandableListWidget {
502    /// `[header_0, body_0, header_1, body_1, ...]` — see
503    /// [`ExpandableListView::flat_views`].
504    pods: Vec<ChildPod>,
505    expanded: BTreeSet<usize>,
506    reveals: Vec<Reveal>,
507    geometry: Vec<ItemGeometry>,
508    /// The reveal fraction each item's own last `layout` call actually used
509    /// — recorded there, compared against the live value in `paint` so the
510    /// driver's landing frame (which reports "not animating" but still moves
511    /// the value) is never missed. See the module docs' reveal timeline.
512    laid_out_reveals: Vec<f64>,
513    outer_radius: f64,
514    inner_radius: f64,
515    hover_radius: f64,
516    pressed_radius: f64,
517    expanded_radius: Option<f64>,
518    gap: f64,
519    color: Option<Color>,
520    haptic: HapticSignal,
521    interactive: bool,
522    /// The laid-out stack width; item rects span it fully.
523    width: f64,
524    /// The item a `Down` armed (alongside `capture_pointer`), cleared on
525    /// `Up`/`Cancel`/loss of interactivity.
526    armed: Option<usize>,
527    /// The item currently painting a state-layer overlay.
528    active: Option<usize>,
529    state: InteractionState,
530    /// The chevron glyph's `(path, design size)`, resolved once at build.
531    chevron: Option<(BezPath, f64)>,
532    on_toggle: Option<ErasedArgCallback<usize>>,
533}
534
535/// Return `color` with its alpha channel replaced by `alpha` (the same helper
536/// [`crate::card_list`] carries).
537fn with_alpha(color: Color, alpha: f32) -> Color {
538    let c = color.components;
539    Color::new([c[0], c[1], c[2], alpha])
540}
541
542/// The resolved item fill. Themed: `colors.surface_container_highest`, unless
543/// an explicit `override_color` replaces it.
544fn resolve_container(theme: Option<&Theme>, override_color: Option<Color>) -> Color {
545    if let Some(color) = override_color {
546        return color;
547    }
548    match theme {
549        Some(theme) => theme.scheme().surface_container_highest,
550        None => FILLED_CONTAINER,
551    }
552}
553
554/// The resolved state-layer content color. Themed: `colors.on_surface`.
555fn resolve_content(theme: Option<&Theme>) -> Color {
556    match theme {
557        Some(theme) => theme.scheme().on_surface,
558        None => ON_SURFACE,
559    }
560}
561
562/// The resolved chevron color. Themed: `colors.on_surface_variant`.
563fn resolve_chevron(theme: Option<&Theme>) -> Color {
564    match theme {
565        Some(theme) => theme.scheme().on_surface_variant,
566        None => ON_SURFACE_VARIANT,
567    }
568}
569
570impl<State: 'static> View<State> for ExpandableListView<State> {
571    type Element = ExpandableListWidget;
572
573    fn build(&self, ctx: &mut BuildCtx<'_>) -> ExpandableListWidget {
574        let pods = self
575            .flat_views()
576            .into_iter()
577            .map(|view| frust::authoring::build_child(view, ctx))
578            .collect();
579        let reveals = (0..self.items.len())
580            .map(|index| {
581                Reveal::new(if self.expanded.contains(&index) {
582                    1.0
583                } else {
584                    0.0
585                })
586            })
587            .collect();
588        ExpandableListWidget {
589            pods,
590            expanded: self.expanded.clone(),
591            reveals,
592            geometry: vec![ItemGeometry::default(); self.items.len()],
593            laid_out_reveals: vec![0.0; self.items.len()],
594            outer_radius: self.outer_radius,
595            inner_radius: self.inner_radius,
596            hover_radius: self.hover_radius,
597            pressed_radius: self.pressed_radius,
598            expanded_radius: self.expanded_radius,
599            gap: self.gap,
600            color: self.color,
601            haptic: self.haptic,
602            interactive: self.on_toggle.is_some(),
603            width: 0.0,
604            armed: None,
605            active: None,
606            state: InteractionState::new(),
607            chevron: Some(IconData::from(crate::icons::EXPAND_MORE_ROUNDED).resolve()),
608            on_toggle: self
609                .on_toggle
610                .as_ref()
611                .map(frust::authoring::erase_callback_arg),
612        }
613    }
614
615    fn rebuild(
616        &self,
617        prev: &Self,
618        element: &mut ExpandableListWidget,
619        ctx: &mut BuildCtx<'_>,
620    ) -> ChangeFlags {
621        let prev_flat = prev.flat_views();
622        let next_flat = self.flat_views();
623        let mut flags = frust::authoring::rebuild_children(
624            &prev_flat,
625            &next_flat,
626            &mut element.pods,
627            ctx,
628            |view| *view,
629            |_| None,
630        );
631
632        if prev.items.len() != self.items.len() {
633            // A structural change invalidates the recorded press (the armed
634            // index may now name a different item) and the per-item reveal
635            // drivers, which are positional.
636            element.armed = None;
637            element.active = None;
638            element.state.set_pressed(false);
639            element.state.set_hovered(false);
640            element.reveals = (0..self.items.len())
641                .map(|index| {
642                    Reveal::new(if self.expanded.contains(&index) {
643                        1.0
644                    } else {
645                        0.0
646                    })
647                })
648                .collect();
649            element.geometry = vec![ItemGeometry::default(); self.items.len()];
650            element.laid_out_reveals = vec![0.0; self.items.len()];
651            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
652        } else if element.expanded != self.expanded {
653            for (index, reveal) in element.reveals.iter_mut().enumerate() {
654                reveal.set_target(if self.expanded.contains(&index) {
655                    1.0
656                } else {
657                    0.0
658                });
659            }
660            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
661        }
662        element.expanded = self.expanded.clone();
663
664        if element.outer_radius != self.outer_radius
665            || element.inner_radius != self.inner_radius
666            || element.hover_radius != self.hover_radius
667            || element.pressed_radius != self.pressed_radius
668            || element.expanded_radius != self.expanded_radius
669            || element.color != self.color
670        {
671            element.outer_radius = self.outer_radius;
672            element.inner_radius = self.inner_radius;
673            element.hover_radius = self.hover_radius;
674            element.pressed_radius = self.pressed_radius;
675            element.expanded_radius = self.expanded_radius;
676            element.color = self.color;
677            flags |= ChangeFlags::PAINT;
678        }
679        if element.gap != self.gap {
680            element.gap = self.gap;
681            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
682        }
683
684        let now_interactive = self.on_toggle.is_some();
685        if element.interactive != now_interactive {
686            element.interactive = now_interactive;
687            if !now_interactive {
688                // Losing the interactive surface mid-gesture must not leave a
689                // dangling press or hover behind.
690                element.armed = None;
691                element.active = None;
692                element.state.set_pressed(false);
693                element.state.set_hovered(false);
694            }
695            flags |= ChangeFlags::PAINT;
696        }
697        element.haptic = self.haptic;
698        element.on_toggle = self
699            .on_toggle
700            .as_ref()
701            .map(frust::authoring::erase_callback_arg);
702        flags
703    }
704
705    fn teardown(&self, element: &mut ExpandableListWidget, ctx: &mut BuildCtx<'_>) {
706        for (view, pod) in self.flat_views().into_iter().zip(element.pods.iter_mut()) {
707            frust::authoring::teardown_child(view, pod, ctx);
708        }
709    }
710}
711
712impl ExpandableListWidget {
713    /// How many items the stack holds (two pods each).
714    fn item_count(&self) -> usize {
715        self.pods.len() / 2
716    }
717
718    /// The item whose *header row* contains local `pos`, if any — a position in
719    /// a revealed body, or in the gap between two items, belongs to none.
720    fn header_at(&self, pos: Point) -> Option<usize> {
721        if pos.x < 0.0 || pos.x >= self.width {
722            return None;
723        }
724        self.geometry
725            .iter()
726            .position(|g| pos.y >= g.y && pos.y < g.y + g.header)
727    }
728
729    /// The radii item `index` takes: its stack position's matrix, with the
730    /// inner radius swapped for the hover/pressed one, or a uniform
731    /// [`ExpandableListView::expanded_radius`] while expanded.
732    fn radii_at(&self, index: usize) -> CornerRadii {
733        if let Some(radius) = self.expanded_radius
734            && self.expanded.contains(&index)
735        {
736            return CornerRadii::uniform(radius);
737        }
738        let inner = if self.armed == Some(index) && self.state.pressed {
739            self.pressed_radius
740        } else if self.active == Some(index) && self.state.hovered {
741            self.hover_radius
742        } else {
743            self.inner_radius
744        };
745        card_radii(
746            card_position(index, self.item_count()),
747            self.outer_radius,
748            inner,
749        )
750    }
751
752    /// Clear whatever gesture state a press left behind.
753    fn clear_press(&mut self) {
754        self.armed = None;
755        self.state.set_pressed(false);
756        if !self.state.is_active() {
757            self.active = None;
758        }
759    }
760
761    /// The chevron path for item `index`, positioned and rotated for the
762    /// current reveal fraction, in the widget's local space.
763    fn chevron_path(&self, index: usize) -> Option<BezPath> {
764        let (path, design) = self.chevron.as_ref()?;
765        if *design <= 0.0 {
766            return None;
767        }
768        let geometry = self.geometry.get(index)?;
769        let center = Point::new(
770            self.width - HEADER_PAD_X - CHEVRON_SLOT / 2.0,
771            geometry.y + HEADER_PAD_TOP + CHEVRON_SLOT / 2.0,
772        );
773        let angle = self.reveals.get(index).map_or(0.0, |r| r.value) * EXPANDABLE_ICON_ROTATION;
774        let scale = CHEVRON_SIZE / design;
775        // Rotate about the glyph's own center: recentre the design box on the
776        // origin, scale, rotate, then translate onto the slot's center.
777        let transform = Affine::translate(Vec2::new(center.x, center.y))
778            * Affine::rotate(angle)
779            * Affine::scale(scale)
780            * Affine::translate(Vec2::new(-design / 2.0, -design / 2.0));
781        Some(transform * path.clone())
782    }
783}
784
785impl Widget for ExpandableListWidget {
786    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
787        let width = if bc.max().width.is_finite() {
788            bc.max().width
789        } else {
790            0.0
791        };
792        self.width = width;
793        let header_width = (width - HEADER_PAD_X * 2.0 - CHEVRON_SLOT).max(0.0);
794        let body_width = (width - BODY_PAD_X * 2.0).max(0.0);
795
796        let count = self.item_count();
797        self.geometry.resize(count, ItemGeometry::default());
798        self.laid_out_reveals.resize(count, 0.0);
799        let mut y = 0.0;
800        for index in 0..count {
801            let header_bc = BoxConstraints::new(
802                Size::new(header_width, 0.0),
803                Size::new(header_width, f64::INFINITY),
804            );
805            let header_size = self.pods[index * 2].layout_child(ctx, &header_bc);
806            // The chevron slot sets the row's floor: a one-line header must
807            // still leave the glyph its own 40dp box.
808            let header = header_size.height.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM;
809            self.pods[index * 2].set_origin(Point::new(HEADER_PAD_X, y + HEADER_PAD_TOP));
810
811            // The body is always laid out at its full natural height; the
812            // reveal is a fraction of that, never a re-measure.
813            let body_bc = BoxConstraints::new(
814                Size::new(body_width, 0.0),
815                Size::new(body_width, f64::INFINITY),
816            );
817            let body_size = self.pods[index * 2 + 1].layout_child(ctx, &body_bc);
818            let body_full = body_size.height + BODY_PAD_TOP + BODY_PAD_BOTTOM;
819            self.pods[index * 2 + 1].set_origin(Point::new(BODY_PAD_X, y + header + BODY_PAD_TOP));
820
821            let reveal = self.reveals.get(index).map_or(0.0, |r| r.value);
822            self.laid_out_reveals[index] = reveal;
823            let height = header + (reveal * body_full).max(0.0);
824            self.geometry[index] = ItemGeometry {
825                y,
826                header,
827                body_full,
828                height,
829            };
830            y += height;
831            if index + 1 < count {
832                y += self.gap;
833            }
834        }
835
836        bc.constrain(Size::new(width, y))
837    }
838
839    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
840        // Every theme read happens before `ctx` is taken mutably below
841        // (`paint_child`) — the ordering `card_list`/`card` share.
842        let theme = Theme::from_paint_ctx(ctx);
843        let container = resolve_container(theme, self.color);
844        let content = resolve_content(theme);
845        let chevron_color = resolve_chevron(theme);
846        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
847
848        // Advance every reveal. The item height is computed in `layout` from
849        // these fractions, so an in-flight reveal needs an explicit relayout
850        // request, not merely another frame (see the module docs). Gating
851        // solely on `advance`'s return would miss its landing frame:
852        // `AnimationController::advance` reports `false` on the very pass
853        // that snaps the value onto its target, so without the second check
854        // below the last geometry `layout` ever saw is a hair short of
855        // rest. Comparing against what `layout` actually last ran with
856        // covers that landing pass, the reduce-motion snap, and an ordinary
857        // in-flight frame with one rule — the pattern `crate::button_group`
858        // documents at length.
859        let now = ctx.frame_time();
860        for reveal in &mut self.reveals {
861            if reduce_motion {
862                reveal.snap();
863            } else if reveal.advance(now) {
864                ctx.request_layout();
865            }
866        }
867        if self
868            .reveals
869            .iter()
870            .map(|r| r.value)
871            .ne(self.laid_out_reveals.iter().copied())
872        {
873            ctx.request_layout();
874        }
875
876        // Authoritative hover read, self-correcting the latched flag — inert
877        // while non-interactive (`docs/CODE_STANDARDS.md`'s three-part hover
878        // contract).
879        let hovered = self.interactive && ctx.is_hovered();
880        self.state.set_hovered(hovered);
881        if !hovered && !self.state.pressed {
882            self.active = None;
883        }
884
885        let origin = ctx.origin();
886        let overlay_opacity = if self.interactive {
887            self.state.resolve_opacity()
888        } else {
889            0.0
890        };
891        let count = self.item_count();
892        let radii: Vec<CornerRadii> = (0..count).map(|index| self.radii_at(index)).collect();
893        let chevrons: Vec<Option<BezPath>> =
894            (0..count).map(|index| self.chevron_path(index)).collect();
895
896        for index in 0..count {
897            let Some(geometry) = self.geometry.get(index).copied() else {
898                continue;
899            };
900            let absolute = Point::new(origin.x, origin.y + geometry.y);
901            let size = Size::new(self.width, geometry.height);
902            scene.fill_rounded_rect_radii(absolute, size, radii[index], container);
903            if overlay_opacity > 0.0 && self.active == Some(index) {
904                scene.fill_rounded_rect_radii(
905                    absolute,
906                    size,
907                    radii[index],
908                    with_alpha(content, overlay_opacity),
909                );
910            }
911
912            self.pods[index * 2].paint_child(ctx, scene);
913
914            let revealed = geometry.height - geometry.header;
915            if revealed > 0.0 {
916                // Only the revealed band of the body shows; the child stays
917                // laid out at its full height behind this clip.
918                scene.push_clip(
919                    Point::new(absolute.x, absolute.y + geometry.header),
920                    Size::new(self.width, revealed),
921                );
922                self.pods[index * 2 + 1].paint_child(ctx, scene);
923                scene.pop_clip();
924            }
925
926            if let Some(path) = &chevrons[index] {
927                scene.fill_path(origin, path, &Brush::Solid(chevron_color));
928            }
929        }
930    }
931
932    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
933        // A broadcast is not user input: it reaches every child ahead of the
934        // capture/hit-test branches and is never consumed.
935        if event.is_broadcast() || !self.interactive {
936            return frust::authoring::route_event(&mut self.pods, ctx, event);
937        }
938        // Key/Ime are focus-routed; the stack itself has no key handling.
939        let InputEvent::Pointer(p) = event else {
940            return frust::authoring::route_event(&mut self.pods, ctx, event);
941        };
942        match p.phase {
943            PointerPhase::Down => {
944                let Some(index) = self.header_at(p.position).filter(|_| presses(p)) else {
945                    // Outside every header row (a revealed body, or the gap
946                    // between two items): the children own it.
947                    return frust::authoring::route_event(&mut self.pods, ctx, event);
948                };
949                self.armed = Some(index);
950                self.active = Some(index);
951                self.state.set_pressed(true);
952                ctx.capture_pointer();
953                ctx.request_redraw();
954                EventResult::Handled
955            }
956            PointerPhase::Move => {
957                let Some(armed) = self.armed else {
958                    // No capture: route first, then claim hover as a fallback —
959                    // an ancestor claiming ahead of its children would make
960                    // every descendant ineligible for the pass.
961                    let result = frust::authoring::route_event(&mut self.pods, ctx, event);
962                    let over = self.header_at(p.position);
963                    if over.is_some() {
964                        ctx.claim_hover();
965                    }
966                    let changed = self.state.set_hovered(over.is_some()) || self.active != over;
967                    self.active = over;
968                    if changed {
969                        ctx.request_redraw();
970                    }
971                    return result;
972                };
973                let still_on_header = self.header_at(p.position) == Some(armed);
974                if self.state.set_pressed(still_on_header) {
975                    ctx.request_redraw();
976                }
977                EventResult::Handled
978            }
979            PointerPhase::Up => {
980                let Some(armed) = self.armed else {
981                    return frust::authoring::route_event(&mut self.pods, ctx, event);
982                };
983                if self.header_at(p.position) == Some(armed) {
984                    if self.haptic != HapticSignal::None {
985                        MaterialHaptics::fire(self.haptic);
986                    }
987                    if let Some(on_toggle) = self.on_toggle.as_mut() {
988                        (on_toggle)(ctx, armed);
989                    }
990                }
991                self.clear_press();
992                ctx.request_redraw();
993                EventResult::Handled
994            }
995            PointerPhase::Cancel => {
996                if self.armed.is_none() {
997                    return frust::authoring::route_event(&mut self.pods, ctx, event);
998                }
999                // A `Cancel` arm only clears internal flags — never state.
1000                self.clear_press();
1001                ctx.request_redraw();
1002                EventResult::Handled
1003            }
1004        }
1005    }
1006
1007    fn semantics(&self, ctx: &mut SemanticsCtx) {
1008        ctx.push_container(
1009            Role::List,
1010            |node| {
1011                if self.interactive {
1012                    node.add_action(Action::Click);
1013                }
1014            },
1015            |ctx| {
1016                for pod in &self.pods {
1017                    pod.semantics_child(ctx);
1018                }
1019            },
1020        );
1021    }
1022
1023    frust::authoring::visit_children!(pods);
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use frust::authoring::text::TextContext;
1030    use frust_widgets::test_support::leaf_any;
1031    use kurbo::Shape;
1032    use std::any::Any;
1033
1034    const WIDTH: f64 = 320.0;
1035    const HEADER_CHILD: f64 = 20.0;
1036    const BODY_CHILD: f64 = 60.0;
1037
1038    /// The header row height a [`HEADER_CHILD`]-tall header child yields — the
1039    /// chevron slot is the floor, so the child's own 20dp never wins.
1040    fn header_height() -> f64 {
1041        HEADER_CHILD.max(CHEVRON_SLOT) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM
1042    }
1043
1044    fn body_height() -> f64 {
1045        BODY_CHILD + BODY_PAD_TOP + BODY_PAD_BOTTOM
1046    }
1047
1048    fn items(count: usize) -> Vec<ExpandableItem<()>> {
1049        (0..count)
1050            .map(|_| ExpandableItem {
1051                header: leaf_any(40.0, HEADER_CHILD),
1052                body: leaf_any(40.0, BODY_CHILD),
1053            })
1054            .collect()
1055    }
1056
1057    fn build<S: 'static>(view: &ExpandableListView<S>) -> ExpandableListWidget {
1058        let mut counter = 0u64;
1059        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1060    }
1061
1062    fn layout(w: &mut ExpandableListWidget) -> Size {
1063        let mut tcx = TextContext::new();
1064        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1065        w.layout(
1066            &mut lctx,
1067            &BoxConstraints::new(Size::new(WIDTH, 0.0), Size::new(WIDTH, f64::INFINITY)),
1068        )
1069    }
1070
1071    #[derive(Default)]
1072    struct Recorder {
1073        rrects: Vec<(Point, Size, CornerRadii, Color)>,
1074        clips: Vec<(Point, Size)>,
1075        paths: Vec<BezPath>,
1076    }
1077
1078    impl PaintScene for Recorder {
1079        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1080        fn draw_text(&mut self, _o: Point, _t: &str) {}
1081        fn fill_rounded_rect_radii(&mut self, o: Point, s: Size, radii: CornerRadii, color: Color) {
1082            self.rrects.push((o, s, radii, color));
1083        }
1084        fn push_clip(&mut self, origin: Point, size: Size) {
1085            self.clips.push((origin, size));
1086        }
1087        fn pop_clip(&mut self) {}
1088        fn fill_path(&mut self, origin: Point, path: &BezPath, _brush: &Brush) {
1089            self.paths
1090                .push(Affine::translate(Vec2::new(origin.x, origin.y)) * path.clone());
1091        }
1092    }
1093
1094    /// Paint at `t_ms` with a real clock, returning `(recorder, needs_layout)`.
1095    fn paint_at(
1096        w: &mut ExpandableListWidget,
1097        t_ms: u64,
1098        theme: Option<&Theme>,
1099    ) -> (Recorder, bool) {
1100        let mut rec = Recorder::default();
1101        let size = Size::new(WIDTH, 4000.0);
1102        let mut ctx =
1103            PaintCtx::for_test(Point::ZERO, size, FrameTime::from_nanos(t_ms * 1_000_000));
1104        if let Some(theme) = theme {
1105            ctx = ctx.with_theme(theme);
1106        }
1107        w.paint(&mut ctx, &mut rec);
1108        let needs_layout = ctx.needs_layout();
1109        (rec, needs_layout)
1110    }
1111
1112    fn dispatch(
1113        w: &mut ExpandableListWidget,
1114        state: &mut dyn Any,
1115        phase: PointerPhase,
1116        pos: Point,
1117    ) -> EventResult {
1118        let event = InputEvent::Pointer(frust::authoring::PointerEvent {
1119            phase,
1120            position: pos,
1121            button: frust::authoring::PointerButton::Primary,
1122        });
1123        let mut ctx = EventCtx::new(state, Point::ZERO, Size::new(WIDTH, 4000.0));
1124        w.event(&mut ctx, &event)
1125    }
1126
1127    // ---- The accordion controller ----------------------------------------
1128
1129    #[test]
1130    fn single_open_closes_every_other_item() {
1131        let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1132        let next = toggle_expanded(&open, 2, ExpandMode::SingleOpen);
1133        assert_eq!(next, [2].into_iter().collect::<BTreeSet<usize>>());
1134    }
1135
1136    #[test]
1137    fn independent_mode_keeps_the_others_open() {
1138        let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1139        let next = toggle_expanded(&open, 2, ExpandMode::Independent);
1140        assert_eq!(next, [0, 1, 2].into_iter().collect::<BTreeSet<usize>>());
1141    }
1142
1143    #[test]
1144    fn collapsing_is_mode_independent() {
1145        let open: BTreeSet<usize> = [0, 1].into_iter().collect();
1146        assert_eq!(
1147            toggle_expanded(&open, 1, ExpandMode::SingleOpen),
1148            [0].into_iter().collect::<BTreeSet<usize>>()
1149        );
1150        assert_eq!(
1151            toggle_expanded(&open, 1, ExpandMode::Independent),
1152            [0].into_iter().collect::<BTreeSet<usize>>()
1153        );
1154    }
1155
1156    #[test]
1157    fn is_expanded_reads_the_app_owned_set() {
1158        let open: BTreeSet<usize> = [3].into_iter().collect();
1159        assert!(is_expanded(&open, 3));
1160        assert!(!is_expanded(&open, 0));
1161    }
1162
1163    #[test]
1164    fn single_open_is_the_default_mode() {
1165        assert_eq!(ExpandMode::default(), ExpandMode::SingleOpen);
1166    }
1167
1168    // ---- Layout: the reveal is a fraction of a fully-measured body --------
1169
1170    #[test]
1171    fn a_collapsed_item_reports_only_its_header_height() {
1172        let view: ExpandableListView<()> = expandable_list(items(1));
1173        let mut w = build(&view);
1174        let size = layout(&mut w);
1175        assert_eq!(size.height, header_height());
1176        assert_eq!(w.geometry[0].header, header_height());
1177        assert_eq!(
1178            w.geometry[0].body_full,
1179            body_height(),
1180            "the body is measured in full even while collapsed"
1181        );
1182    }
1183
1184    #[test]
1185    fn an_item_built_expanded_reports_header_plus_full_body_on_the_first_layout() {
1186        let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1187        let mut w = build(&view);
1188        let size = layout(&mut w);
1189        assert!((size.height - (header_height() + body_height())).abs() < 1e-9);
1190    }
1191
1192    #[test]
1193    fn a_mid_reveal_height_sits_between_collapsed_and_expanded() {
1194        let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1195        let mut w = build(&view);
1196        layout(&mut w);
1197        w.reveals[0].value = 0.5;
1198        let size = layout(&mut w);
1199        assert!((size.height - (header_height() + 0.5 * body_height())).abs() < 1e-9);
1200    }
1201
1202    #[test]
1203    fn items_stack_with_the_gap_between_them() {
1204        let view: ExpandableListView<()> = expandable_list(items(3));
1205        let mut w = build(&view);
1206        let size = layout(&mut w);
1207        assert_eq!(size.height, header_height() * 3.0 + EXPANDABLE_GAP * 2.0);
1208        assert_eq!(w.geometry[0].y, 0.0);
1209        assert_eq!(w.geometry[1].y, header_height() + EXPANDABLE_GAP);
1210    }
1211
1212    // ---- The reveal timeline, and the layout-skip guard -------------------
1213
1214    #[test]
1215    fn paint_requests_layout_through_the_settle_frame() {
1216        // The landing-frame relayout guard: `AnimationController::advance`
1217        // reports `false` on the very pass that snaps the value onto its
1218        // target, so gating solely on that return would leave the last
1219        // geometry `layout` ever saw a hair short of rest. This drives a
1220        // shell-honest frame loop — `layout` runs only on a frame the
1221        // *previous* paint (or rebuild) actually asked for one, the same
1222        // contract a real shell honours — so a silently-skipped landing
1223        // frame leaves the last recorded height short of the target instead
1224        // of landing on it exactly. See docs/REVIEW_FOCUS.md's layout-skip
1225        // section.
1226        let collapsed: ExpandableListView<()> = expandable_list(items(1));
1227        let mut w = build(&collapsed);
1228        layout(&mut w);
1229        let (_, settled) = paint_at(&mut w, 0, None);
1230        assert!(!settled, "an already-settled stack requests no layout");
1231
1232        let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1233        let mut counter = 0u64;
1234        let flags = <ExpandableListView<()> as View<()>>::rebuild(
1235            &expanded,
1236            &collapsed,
1237            &mut w,
1238            &mut BuildCtx::new(&mut counter),
1239        );
1240        assert!(flags.needs_layout());
1241
1242        let mut frames = 0u32;
1243        let mut heights = Vec::new();
1244        let mut t_ms = 0u64;
1245        let mut needs_layout = true;
1246        loop {
1247            if !needs_layout {
1248                break;
1249            }
1250            heights.push(layout(&mut w).height);
1251            let (_, next_needs_layout) = paint_at(&mut w, t_ms, None);
1252            frames += 1;
1253            assert!(
1254                frames < 600,
1255                "the reveal should settle well inside 600 frames"
1256            );
1257            needs_layout = next_needs_layout;
1258            t_ms += 16;
1259        }
1260        assert!(frames > 1, "the reveal spans more than one frame");
1261        let collapsed_h = header_height();
1262        let expanded_h = header_height() + body_height();
1263        // Clamp discipline: an under-damped spring (ζ 0.8) overshoots past its
1264        // target, and the reveal reads clamped — so no laid-out frame ever
1265        // reports a band taller than the measured body (empty space below
1266        // the content), and the sequence never backslides.
1267        assert!(
1268            heights
1269                .iter()
1270                .all(|h| *h >= collapsed_h - 1e-9 && *h <= expanded_h + 1e-9),
1271            "every laid-out frame stays inside [{collapsed_h}, {expanded_h}]: {heights:?}"
1272        );
1273        // Non-decreasing up to the first frame that reaches the target (past
1274        // that, the clipped overshoot may dip back by a fraction of a px).
1275        let peak = heights
1276            .iter()
1277            .position(|h| *h >= expanded_h - 1e-9)
1278            .expect("the reveal reaches its target");
1279        assert!(peak > 0, "the first frame is not already fully revealed");
1280        assert!(
1281            heights[..=peak]
1282                .windows(2)
1283                .all(|pair| pair[1] >= pair[0] - 1e-9),
1284            "the reveal rises to the target without backsliding: {heights:?}"
1285        );
1286        // The settle-frame guarantee: the *last* layout a shell-honest driver
1287        // ever runs lands exactly on the target — never a hair short of it.
1288        let last = heights.last().copied().expect("at least one frame");
1289        assert_eq!(
1290            last, expanded_h,
1291            "the last layout a shell-honest driver runs lands exactly on the target height"
1292        );
1293        // A settled stack stops asking.
1294        let (_, still) = paint_at(&mut w, t_ms + 16, None);
1295        assert!(!still, "settled: no more layout requests");
1296    }
1297
1298    #[test]
1299    fn reduce_motion_snaps_the_reveal_and_requests_one_relayout_no_animation() {
1300        // Regression guard for the reduce-motion layout-staleness bug: `paint`
1301        // snaps the reveal's `value` straight to `target`, but `layout` already
1302        // ran earlier this same frame against the pre-snap value, so the snap
1303        // must still ask for exactly one relayout — never an ongoing animation
1304        // frame, since the driver is already stopped once snapped.
1305        let mut theme = crate::baseline();
1306        theme.motion.reduce_motion = true;
1307        let collapsed: ExpandableListView<()> = expandable_list(items(1));
1308        let mut w = build(&collapsed);
1309        let collapsed_h = layout(&mut w).height;
1310        assert_eq!(collapsed_h, header_height(), "starts fully collapsed");
1311
1312        let expanded: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1313        let mut counter = 0u64;
1314        <ExpandableListView<()> as View<()>>::rebuild(
1315            &expanded,
1316            &collapsed,
1317            &mut w,
1318            &mut BuildCtx::new(&mut counter),
1319        );
1320
1321        let (_, needs_layout) = paint_at(&mut w, 0, Some(&theme));
1322        assert_eq!(w.reveals[0].value, 1.0, "snapped straight to the target");
1323        assert!(
1324            needs_layout,
1325            "the snap changed a layout-relevant value, so it must request one relayout"
1326        );
1327
1328        // The relayout a real shell would run on seeing `needs_layout` — this
1329        // is the assertion the bug used to skip: the item's laid-out height
1330        // must reach the target on the frame the reveal snaps.
1331        let expanded_h = header_height() + body_height();
1332        let snapped_h = layout(&mut w).height;
1333        assert_eq!(
1334            snapped_h, expanded_h,
1335            "the item's height reaches the target on the snap frame, not some later one"
1336        );
1337
1338        // Settled: no ongoing animation frames follow the one relayout.
1339        let (_, still) = paint_at(&mut w, 16, Some(&theme));
1340        assert!(
1341            !still,
1342            "a snap requests exactly one relayout, never an ongoing frame request"
1343        );
1344    }
1345
1346    #[test]
1347    fn only_the_toggled_item_animates() {
1348        let collapsed: ExpandableListView<()> = expandable_list(items(3));
1349        let mut w = build(&collapsed);
1350        layout(&mut w);
1351        let expanded: ExpandableListView<()> = expandable_list(items(3)).expanded([1]);
1352        let mut counter = 0u64;
1353        <ExpandableListView<()> as View<()>>::rebuild(
1354            &expanded,
1355            &collapsed,
1356            &mut w,
1357            &mut BuildCtx::new(&mut counter),
1358        );
1359        let mut t_ms = 0u64;
1360        loop {
1361            let (rec, needs_layout) = paint_at(&mut w, t_ms, None);
1362            assert!(
1363                rec.clips.len() <= 1,
1364                "only the toggled item ever reveals a body clip, got {}",
1365                rec.clips.len()
1366            );
1367            if !needs_layout {
1368                break;
1369            }
1370            layout(&mut w);
1371            t_ms += 16;
1372            assert!(t_ms < 10_000, "the reveal should settle well inside 10s");
1373        }
1374        assert_eq!(w.reveals[0].value, 0.0);
1375        assert_eq!(w.reveals[2].value, 0.0);
1376        assert_eq!(w.reveals[1].value, 1.0);
1377    }
1378
1379    #[test]
1380    fn the_body_clip_covers_exactly_the_revealed_band() {
1381        let view: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1382        let mut w = build(&view);
1383        layout(&mut w);
1384        let (rec, _) = paint_at(&mut w, 0, None);
1385        assert_eq!(rec.clips.len(), 1);
1386        let (clip_origin, clip_size) = rec.clips[0];
1387        assert_eq!(clip_origin.y, header_height());
1388        assert!((clip_size.height - body_height()).abs() < 1e-9);
1389        assert_eq!(clip_size.width, WIDTH);
1390    }
1391
1392    #[test]
1393    fn a_collapsed_item_paints_no_body_clip() {
1394        let view: ExpandableListView<()> = expandable_list(items(2));
1395        let mut w = build(&view);
1396        layout(&mut w);
1397        let (rec, _) = paint_at(&mut w, 0, None);
1398        assert!(rec.clips.is_empty());
1399    }
1400
1401    #[test]
1402    fn the_chevron_rotates_with_the_reveal() {
1403        let closed: ExpandableListView<()> = expandable_list(items(1));
1404        let mut w = build(&closed);
1405        layout(&mut w);
1406        let (rec_closed, _) = paint_at(&mut w, 0, None);
1407        let closed_box = rec_closed.paths[0].bounding_box();
1408
1409        let open: ExpandableListView<()> = expandable_list(items(1)).expanded([0]);
1410        let mut w2 = build(&open);
1411        layout(&mut w2);
1412        let (rec_open, _) = paint_at(&mut w2, 0, None);
1413        let open_box = rec_open.paths[0].bounding_box();
1414
1415        // A π rotation about the *slot* center point-reflects the glyph's ink
1416        // bounds through that center (the `expand_more` ink box is not itself
1417        // centered in its 24dp design box, so the two bboxes are not equal).
1418        let slot_x = WIDTH - HEADER_PAD_X - CHEVRON_SLOT / 2.0;
1419        let slot_y = HEADER_PAD_TOP + CHEVRON_SLOT / 2.0;
1420        assert!(
1421            (closed_box.center().x + open_box.center().x - 2.0 * slot_x).abs() < 1e-6,
1422            "closed {closed_box:?} / open {open_box:?} about x={slot_x}"
1423        );
1424        assert!(
1425            (closed_box.center().y + open_box.center().y - 2.0 * slot_y).abs() < 1e-6,
1426            "closed {closed_box:?} / open {open_box:?} about y={slot_y}"
1427        );
1428        assert_ne!(
1429            rec_closed.paths[0].to_svg(),
1430            rec_open.paths[0].to_svg(),
1431            "the chevron path must actually rotate"
1432        );
1433    }
1434
1435    #[test]
1436    fn the_reveal_spring_matches_the_reference_preset() {
1437        let reference = crate::MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT;
1438        assert_eq!(REVEAL_SPRING.mass, 1.0);
1439        assert_eq!(REVEAL_SPRING.stiffness, reference.stiffness);
1440        assert_eq!(REVEAL_SPRING.damping_ratio, reference.damping_ratio);
1441    }
1442
1443    // ---- The corner matrix ------------------------------------------------
1444
1445    #[test]
1446    fn item_corners_follow_the_card_list_position_matrix() {
1447        let view: ExpandableListView<()> = expandable_list(items(3));
1448        let mut w = build(&view);
1449        layout(&mut w);
1450        let (rec, _) = paint_at(&mut w, 0, None);
1451        let radii: Vec<CornerRadii> = rec.rrects.iter().map(|(_, _, r, _)| *r).collect();
1452        let (outer, inner) = (EXPANDABLE_OUTER_RADIUS, EXPANDABLE_INNER_RADIUS);
1453        assert_eq!(
1454            radii,
1455            vec![
1456                card_radii(crate::CardPosition::First, outer, inner),
1457                card_radii(crate::CardPosition::Middle, outer, inner),
1458                card_radii(crate::CardPosition::Last, outer, inner),
1459            ]
1460        );
1461    }
1462
1463    #[test]
1464    fn an_expanded_radius_override_rounds_the_open_item_uniformly() {
1465        let view: ExpandableListView<()> = expandable_list(items(2))
1466            .expanded([0])
1467            .expanded_radius(28.0);
1468        let mut w = build(&view);
1469        layout(&mut w);
1470        let (rec, _) = paint_at(&mut w, 0, None);
1471        assert_eq!(rec.rrects[0].2, CornerRadii::uniform(28.0));
1472        assert_ne!(rec.rrects[1].2, CornerRadii::uniform(28.0));
1473    }
1474
1475    #[test]
1476    fn a_pressed_item_swaps_in_the_pressed_inner_radius() {
1477        let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
1478        let mut w = build(&view);
1479        layout(&mut w);
1480        let mut state = ();
1481        dispatch(
1482            &mut w,
1483            &mut state,
1484            PointerPhase::Down,
1485            Point::new(10.0, header_height() / 2.0),
1486        );
1487        let expected = card_radii(
1488            crate::CardPosition::First,
1489            EXPANDABLE_OUTER_RADIUS,
1490            EXPANDABLE_PRESSED_RADIUS,
1491        );
1492        assert_eq!(w.radii_at(0), expected);
1493    }
1494
1495    #[test]
1496    fn a_hovered_item_swaps_in_the_hover_inner_radius() {
1497        // The latch half of the hover contract: an uncaptured `Move` over a
1498        // header claims and latches, and the radii read it straight away.
1499        let view: ExpandableListView<()> = expandable_list(items(2)).on_toggle(|_: &mut (), _| {});
1500        let mut w = build(&view);
1501        layout(&mut w);
1502        let mut state = ();
1503        let second_y = w.geometry[1].y + 1.0;
1504        dispatch(
1505            &mut w,
1506            &mut state,
1507            PointerPhase::Move,
1508            Point::new(10.0, second_y),
1509        );
1510        assert_eq!(w.active, Some(1));
1511        assert!(w.state.hovered);
1512        assert_eq!(
1513            w.radii_at(1),
1514            card_radii(
1515                crate::CardPosition::Last,
1516                EXPANDABLE_OUTER_RADIUS,
1517                EXPANDABLE_HOVER_RADIUS,
1518            )
1519        );
1520        // Its unhovered sibling keeps the plain inner radius.
1521        assert_eq!(
1522            w.radii_at(0),
1523            card_radii(
1524                crate::CardPosition::First,
1525                EXPANDABLE_OUTER_RADIUS,
1526                EXPANDABLE_INNER_RADIUS,
1527            )
1528        );
1529    }
1530
1531    // ---- Interaction ------------------------------------------------------
1532
1533    #[test]
1534    fn a_header_press_released_inside_reports_its_index() {
1535        #[derive(Default)]
1536        struct S {
1537            toggled: Vec<usize>,
1538        }
1539        let view: ExpandableListView<S> = expandable_list((0..3).map(|_| {
1540            expandable_item(
1541                frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1542                frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1543            )
1544        }))
1545        .on_toggle(|s: &mut S, index| s.toggled.push(index));
1546        let mut w = build(&view);
1547        layout(&mut w);
1548        let mut state = S::default();
1549        let y = w.geometry[1].y + header_height() / 2.0;
1550        dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
1551        dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y));
1552        assert_eq!(state.toggled, vec![1]);
1553        // Controlled: the widget's own set is untouched.
1554        assert!(w.expanded.is_empty());
1555    }
1556
1557    #[test]
1558    fn a_release_outside_the_armed_header_fires_nothing() {
1559        #[derive(Default)]
1560        struct S {
1561            toggled: Vec<usize>,
1562        }
1563        let view: ExpandableListView<S> = expandable_list((0..2).map(|_| {
1564            expandable_item(
1565                frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1566                frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1567            )
1568        }))
1569        .on_toggle(|s: &mut S, index| s.toggled.push(index));
1570        let mut w = build(&view);
1571        layout(&mut w);
1572        let mut state = S::default();
1573        let y0 = header_height() / 2.0;
1574        let y1 = w.geometry[1].y + header_height() / 2.0;
1575        dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y0));
1576        dispatch(&mut w, &mut state, PointerPhase::Up, Point::new(10.0, y1));
1577        assert!(state.toggled.is_empty());
1578    }
1579
1580    #[test]
1581    fn a_cancel_clears_the_press_without_firing() {
1582        #[derive(Default)]
1583        struct S {
1584            toggled: Vec<usize>,
1585        }
1586        let view: ExpandableListView<S> = expandable_list((0..1).map(|_| {
1587            expandable_item(
1588                frust::SizedBox::<S>(Some(40.0), Some(HEADER_CHILD)),
1589                frust::SizedBox::<S>(Some(40.0), Some(BODY_CHILD)),
1590            )
1591        }))
1592        .on_toggle(|s: &mut S, index| s.toggled.push(index));
1593        let mut w = build(&view);
1594        layout(&mut w);
1595        let mut state = S::default();
1596        let y = header_height() / 2.0;
1597        dispatch(&mut w, &mut state, PointerPhase::Down, Point::new(10.0, y));
1598        dispatch(
1599            &mut w,
1600            &mut state,
1601            PointerPhase::Cancel,
1602            Point::new(10.0, y),
1603        );
1604        assert!(state.toggled.is_empty());
1605        assert!(w.armed.is_none());
1606        assert!(!w.state.pressed);
1607    }
1608
1609    #[test]
1610    fn a_secondary_press_never_arms_the_header() {
1611        let view: ExpandableListView<()> = expandable_list(items(1)).on_toggle(|_: &mut (), _| {});
1612        let mut w = build(&view);
1613        layout(&mut w);
1614        let event = InputEvent::Pointer(frust::authoring::PointerEvent {
1615            phase: PointerPhase::Down,
1616            position: Point::new(10.0, header_height() / 2.0),
1617            button: frust::authoring::PointerButton::Secondary,
1618        });
1619        let mut state = ();
1620        let sa: &mut dyn Any = &mut state;
1621        let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(WIDTH, 4000.0));
1622        w.event(&mut ctx, &event);
1623        assert!(w.armed.is_none());
1624    }
1625
1626    #[test]
1627    fn a_press_in_a_revealed_body_never_arms_the_header() {
1628        let view: ExpandableListView<()> = expandable_list(items(1))
1629            .expanded([0])
1630            .on_toggle(|_: &mut (), _| {});
1631        let mut w = build(&view);
1632        layout(&mut w);
1633        let mut state = ();
1634        let in_body = header_height() + 10.0;
1635        dispatch(
1636            &mut w,
1637            &mut state,
1638            PointerPhase::Down,
1639            Point::new(10.0, in_body),
1640        );
1641        assert!(w.armed.is_none());
1642    }
1643
1644    #[test]
1645    fn a_stack_without_on_toggle_routes_pointers_to_its_children() {
1646        let view: ExpandableListView<()> = expandable_list(items(1));
1647        let mut w = build(&view);
1648        layout(&mut w);
1649        let mut state = ();
1650        dispatch(
1651            &mut w,
1652            &mut state,
1653            PointerPhase::Down,
1654            Point::new(10.0, header_height() / 2.0),
1655        );
1656        assert!(w.armed.is_none());
1657    }
1658
1659    #[test]
1660    fn header_hit_testing_ignores_the_gap_between_items() {
1661        let view: ExpandableListView<()> = expandable_list(items(2));
1662        let mut w = build(&view);
1663        layout(&mut w);
1664        let gap_y = header_height() + EXPANDABLE_GAP / 2.0;
1665        assert_eq!(w.header_at(Point::new(10.0, gap_y)), None);
1666        assert_eq!(w.header_at(Point::new(10.0, 1.0)), Some(0));
1667        assert_eq!(
1668            w.header_at(Point::new(10.0, w.geometry[1].y + 1.0)),
1669            Some(1)
1670        );
1671    }
1672
1673    // ---- Rebuild bookkeeping ---------------------------------------------
1674
1675    #[test]
1676    fn a_structural_change_reseeds_the_reveal_drivers() {
1677        let two: ExpandableListView<()> = expandable_list(items(2)).expanded([1]);
1678        let mut w = build(&two);
1679        layout(&mut w);
1680        assert_eq!(w.reveals.len(), 2);
1681        let three: ExpandableListView<()> = expandable_list(items(3)).expanded([2]);
1682        let mut counter = 0u64;
1683        <ExpandableListView<()> as View<()>>::rebuild(
1684            &three,
1685            &two,
1686            &mut w,
1687            &mut BuildCtx::new(&mut counter),
1688        );
1689        assert_eq!(w.reveals.len(), 3);
1690        assert_eq!(w.reveals[2].value, 1.0, "the new set seeds at rest");
1691        assert_eq!(w.reveals[1].value, 0.0);
1692    }
1693
1694    #[test]
1695    fn semantics_is_a_clickable_list_forwarding_every_pod() {
1696        fn logic(_s: &mut ()) -> ExpandableListView<()> {
1697            expandable_list([expandable_item(frust::text("header"), frust::text("body"))])
1698                .on_toggle(|_: &mut (), _| {})
1699        }
1700        let mut root: frust_core::RenderRoot<(), ExpandableListView<()>> =
1701            frust_core::RenderRoot::new();
1702        let mut state = ();
1703        root.rebuild(&mut logic, &mut state);
1704        let mut tcx = TextContext::new();
1705        root.layout_with_text(Size::new(WIDTH, 800.0), &mut tcx as &mut dyn Any);
1706        let update = root.semantics();
1707        let (_, node) = update
1708            .nodes
1709            .iter()
1710            .find(|(_, n)| n.role() == Role::List)
1711            .expect("the stack contributes a Role::List node");
1712        assert!(
1713            node.supports_action(Action::Click),
1714            "an interactive stack carries Action::Click"
1715        );
1716        assert!(
1717            !node.children().is_empty(),
1718            "every header and body pod is a semantics child"
1719        );
1720    }
1721}