Skip to main content

frust_widgets/
flex.rs

1//! Flex layout container: `Row`/`Column` over a main/cross axis.
2//!
3//! [`FlexView`]/[`FlexWidget`] are the declarative/retained pair (mirroring
4//! [`crate::text`]'s `TextView`/`TextWidget`). A flex lays its children out along
5//! a main [`Axis`], mirroring Flutter's `Flex`: inflexible children take their
6//! natural main size first, then any remaining main-axis space is divided among
7//! flexible children in proportion to their `flex` factor.
8//!
9//! Construct one with the [`Row`]/[`Column`] sugar (all children inflexible) or
10//! [`FlexView::new`] with explicit [`FlexChild`]s built via [`flexible`] /
11//! [`inflexible`] when some children should expand.
12
13use frust_core::{
14    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
15    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, ViewSeq, Widget, any,
16};
17use kurbo::{Point, Rect, Size};
18
19use crate::ChildKey;
20
21/// The axis a [`FlexView`] lays its children along.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Axis {
24    /// Children are placed left-to-right; main = width, cross = height.
25    Horizontal,
26    /// Children are placed top-to-bottom; main = height, cross = width.
27    Vertical,
28}
29
30impl Axis {
31    /// The main-axis extent of `size`.
32    fn main_of(self, size: Size) -> f64 {
33        match self {
34            Axis::Horizontal => size.width,
35            Axis::Vertical => size.height,
36        }
37    }
38
39    /// The cross-axis extent of `size`.
40    fn cross_of(self, size: Size) -> f64 {
41        match self {
42            Axis::Horizontal => size.height,
43            Axis::Vertical => size.width,
44        }
45    }
46
47    /// Build a [`Size`] from main/cross extents.
48    fn size(self, main: f64, cross: f64) -> Size {
49        match self {
50            Axis::Horizontal => Size::new(main, cross),
51            Axis::Vertical => Size::new(cross, main),
52        }
53    }
54
55    /// Build a [`Point`] from main/cross coordinates.
56    fn point(self, main: f64, cross: f64) -> Point {
57        match self {
58            Axis::Horizontal => Point::new(main, cross),
59            Axis::Vertical => Point::new(cross, main),
60        }
61    }
62}
63
64/// How children are aligned along the cross axis.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum CrossAxisAlignment {
67    /// Pack each child at the cross-axis start (top for a Row, left for a Column).
68    Start,
69    /// Center each child on the cross axis.
70    Center,
71    /// Stretch each child to fill the cross axis (tight cross constraint).
72    Stretch,
73}
74
75/// How children are distributed along the main axis.
76///
77/// v1 ships only [`MainAxisAlignment::Start`] (leading-packed); the
78/// space-between/around/center variants are deferred to a later phase.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum MainAxisAlignment {
81    /// Pack children at the main-axis start with no leading gap.
82    Start,
83}
84
85/// One child of a [`FlexView`]: an erased child view, its `flex` factor
86/// (`0` = inflexible; `> 0` = takes a proportional share of the free main space),
87/// and an optional [`ChildKey`] for keyed reconciliation.
88///
89/// `key` is `None` for the plain [`Row`]/[`Column`]/[`flexible`]/[`inflexible`]
90/// sugar (positional reconciliation, unchanged) and `Some` only for children
91/// built with [`keyed`], which opts the whole list into key-matched
92/// reconciliation so reorders/inserts preserve widget state.
93pub struct FlexChild<State: 'static> {
94    view: AnyView<State>,
95    flex: u32,
96    key: Option<ChildKey>,
97}
98
99/// A flexible child taking `flex` proportional shares of the free main-axis space.
100pub fn flexible<State: 'static, V: View<State>>(flex: u32, view: V) -> FlexChild<State> {
101    FlexChild {
102        view: any(view),
103        flex,
104        key: None,
105    }
106}
107
108/// An inflexible child, sized to its natural main-axis extent.
109pub fn inflexible<State: 'static, V: View<State>>(view: V) -> FlexChild<State> {
110    FlexChild {
111        view: any(view),
112        flex: 0,
113        key: None,
114    }
115}
116
117/// An inflexible child tagged with a stable [`ChildKey`], for a list whose items
118/// reorder, insert, or delete between frames.
119///
120/// Attaching a key to *any* child opts the whole [`FlexView`] into keyed
121/// reconciliation: on the next rebuild, children are matched to their live
122/// widgets by key rather than by position, so a shuffled or grown list preserves
123/// each surviving row's widget and its internal state (a scroll offset, a text
124/// buffer, a toggle) instead of rebuilding whatever now sits at that index. Keys
125/// are all-or-nothing per list and must be unique within it (see
126/// [`ChildKey`]).
127///
128/// **All-or-nothing rule**: once one child has a key, *all* children in that
129/// container must have keys — do not mix `.keyed(..)` with `.child(..)`/`.flex(..)`
130/// in the same container. A keyed list cannot hold a `.flex` spacer. Mixing keyed
131/// and unkeyed children debug-asserts on the first rebuild and falls back to
132/// positional matching (preserving layout and interaction but losing the state
133/// retention that keying adds).
134///
135/// Use it inside [`FlexView::new`] alongside (or instead of) [`inflexible`]:
136///
137/// ```
138/// use frust_widgets::{Axis, FlexView, keyed, text};
139/// # struct Item { id: u64, label: String }
140/// # fn demo(items: &[Item]) -> FlexView<()> {
141/// FlexView::new(
142///     Axis::Vertical,
143///     items.iter().map(|item| keyed(item.id, text(item.label.clone()))).collect(),
144/// )
145/// # }
146/// ```
147///
148/// v1 keyed children are inflexible; combining a key with a `flex` factor is a
149/// future extension.
150pub fn keyed<State: 'static, V: View<State>>(
151    key: impl Into<ChildKey>,
152    view: V,
153) -> FlexChild<State> {
154    FlexChild {
155        view: any(view),
156        flex: 0,
157        key: Some(key.into()),
158    }
159}
160
161/// A declarative flex container. See the [module docs](self).
162pub struct FlexView<State: 'static> {
163    direction: Axis,
164    cross: CrossAxisAlignment,
165    main: MainAxisAlignment,
166    children: Vec<FlexChild<State>>,
167}
168
169impl<State: 'static> FlexView<State> {
170    /// Create a flex laying `children` out along `direction`, cross-aligned to
171    /// the start and main-aligned to the start.
172    pub fn new(direction: Axis, children: Vec<FlexChild<State>>) -> Self {
173        Self {
174            direction,
175            cross: CrossAxisAlignment::Start,
176            main: MainAxisAlignment::Start,
177            children,
178        }
179    }
180
181    /// Append an inflexible child. Accepts any [`View`]; erasure happens
182    /// internally, so no `any()` is needed.
183    pub fn child<V: View<State>>(mut self, view: V) -> Self {
184        self.children.push(inflexible(view));
185        self
186    }
187
188    /// Append a flexible child taking `flex` proportional shares of the free
189    /// main-axis space.
190    pub fn flex<V: View<State>>(mut self, flex: u32, view: V) -> Self {
191        self.children.push(flexible(flex, view));
192        self
193    }
194
195    /// Append an inflexible child tagged with a stable [`ChildKey`] (see
196    /// [`keyed`] for the reconciliation semantics and the all-or-nothing rule).
197    pub fn keyed<V: View<State>>(mut self, key: impl Into<ChildKey>, view: V) -> Self {
198        self.children.push(keyed(key, view));
199        self
200    }
201
202    /// Append every element of `children` as an inflexible child.
203    ///
204    /// Accepts any [`ViewSeq`]: a `Vec`/array of views, a tuple of mixed view
205    /// types, an `Option`, nested sequences, or `views(iter)` for an iterator.
206    /// Tuples are positional: a `None` element drops its slot and nested
207    /// sequences flatten in order. For a keyed list use [`FlexView::keyed`] per
208    /// child (the all-or-nothing rule of [`keyed`] still applies; a tuple does
209    /// not carry keys).
210    pub fn children<M>(mut self, children: impl ViewSeq<State, M>) -> Self {
211        let mut erased = Vec::new();
212        children.extend_views(&mut erased);
213        self.children
214            .extend(erased.into_iter().map(|view| FlexChild {
215                view,
216                flex: 0,
217                key: None,
218            }));
219        self
220    }
221
222    /// Append a pre-built [`FlexChild`] (escape hatch for [`flexible`] /
223    /// [`inflexible`] / [`keyed`] values built elsewhere).
224    pub fn push(mut self, child: FlexChild<State>) -> Self {
225        self.children.push(child);
226        self
227    }
228
229    /// Apply `f` to the builder only when `cond` is true.
230    pub fn when(self, cond: bool, f: impl FnOnce(Self) -> Self) -> Self {
231        if cond { f(self) } else { self }
232    }
233
234    /// Apply `f` with the contained value when `opt` is `Some`.
235    pub fn when_some<T>(self, opt: Option<T>, f: impl FnOnce(Self, T) -> Self) -> Self {
236        match opt {
237            Some(value) => f(self, value),
238            None => self,
239        }
240    }
241
242    /// Set the cross-axis alignment.
243    pub fn cross_axis(mut self, cross: CrossAxisAlignment) -> Self {
244        self.cross = cross;
245        self
246    }
247
248    /// Set the main-axis alignment.
249    pub fn main_axis(mut self, main: MainAxisAlignment) -> Self {
250        self.main = main;
251        self
252    }
253}
254
255/// An empty vertical flex, ready for fluent children (no `any(..)` wrapping).
256///
257/// ```
258/// use frust_widgets::{FlexView, column, text};
259/// # fn demo(cond: bool) -> FlexView<()> {
260/// column()
261///     .child(text("a"))
262///     .flex(1, text("b"))
263///     .when(cond, |c| c.child(text("c")))
264/// # }
265/// # let _ = demo(true);
266/// ```
267pub fn column<State: 'static>() -> FlexView<State> {
268    FlexView::new(Axis::Vertical, Vec::new())
269}
270
271/// An empty horizontal flex, ready for fluent children. See [`column`].
272pub fn row<State: 'static>() -> FlexView<State> {
273    FlexView::new(Axis::Horizontal, Vec::new())
274}
275
276/// A horizontal flex (`Axis::Horizontal`) of inflexible children — the common
277/// sugar. Use [`FlexView::new`] with [`flexible`] children when some should expand.
278///
279/// `children` is any [`ViewSeq`]: a `Vec`/array of views, a tuple of
280/// mixed view types (`(a, b, c)`, positional, up to 12 and nestable), an
281/// `Option` (`None` drops the slot), or `views(iter)` for an iterator. Every
282/// element is erased once, here. A bare empty list needs its item type spelled
283/// out (`Vec::<AnyView<State>>::new()`).
284///
285/// ```
286/// use frust_widgets::{FlexView, Row, text};
287/// # fn demo() -> (FlexView<()>, FlexView<()>) {
288/// // Same type: a `Vec`. Mixed types: a tuple, no `any(..)` needed.
289/// let labels = Row(vec![text("a"), text("b"), text("c")]);
290/// let mixed = Row((text("a"), Row((text("b"), text("c")))));
291/// # (labels, mixed)
292/// # }
293/// # let _ = demo();
294/// ```
295#[allow(non_snake_case)]
296pub fn Row<State: 'static, M>(children: impl ViewSeq<State, M>) -> FlexView<State> {
297    FlexView::new(Axis::Horizontal, Vec::new()).children(children)
298}
299
300/// A vertical flex (`Axis::Vertical`) of inflexible children — the common sugar.
301/// Use [`FlexView::new`] with [`flexible`] children when some should expand.
302///
303/// `children` is any [`ViewSeq`]: a `Vec`/array of views, a tuple of
304/// mixed view types (`(a, b, c)`, positional, up to 12 and nestable), an
305/// `Option` (`None` drops the slot), or `views(iter)` for an iterator. Every
306/// element is erased once, here. A bare empty list needs its item type spelled
307/// out (`Vec::<AnyView<State>>::new()`).
308///
309/// ```
310/// use frust_widgets::{Column, FlexView, text};
311/// # fn demo() -> (FlexView<()>, FlexView<()>) {
312/// // Same type: a `Vec`. Mixed types: a tuple, no `any(..)` needed.
313/// let labels = Column(vec![text("a"), text("b"), text("c")]);
314/// let mixed = Column((text("a"), Column((text("b"), text("c")))));
315/// # (labels, mixed)
316/// # }
317/// # let _ = demo();
318/// ```
319#[allow(non_snake_case)]
320pub fn Column<State: 'static, M>(children: impl ViewSeq<State, M>) -> FlexView<State> {
321    FlexView::new(Axis::Vertical, Vec::new()).children(children)
322}
323
324/// The retained widget for a [`FlexView`]. Holds a parallel `children`/`flex`
325/// pair (same length) so layout can index both without a per-child wrapper.
326pub struct FlexWidget {
327    direction: Axis,
328    cross: CrossAxisAlignment,
329    main: MainAxisAlignment,
330    children: Vec<ChildPod>,
331    flex: Vec<u32>,
332}
333
334/// Build the box constraints for one flex child.
335///
336/// `main_min..main_max` bound the main axis (inflexible children get
337/// `0..∞`; flexible children a tight `share..share`). Under `stretch` the cross
338/// axis is tight at `cross_bound`; otherwise it is loose up to `cross_max`.
339fn child_constraints(
340    axis: Axis,
341    main_min: f64,
342    main_max: f64,
343    cross_bound: f64,
344    stretch: bool,
345    cross_max: f64,
346) -> BoxConstraints {
347    let cross_min = if stretch { cross_bound } else { 0.0 };
348    let cross_hi = if stretch { cross_bound } else { cross_max };
349    BoxConstraints::new(
350        axis.size(main_min, cross_min),
351        axis.size(main_max, cross_hi),
352    )
353}
354
355impl<State: 'static> View<State> for FlexView<State> {
356    type Element = FlexWidget;
357
358    fn build(&self, ctx: &mut BuildCtx<'_>) -> FlexWidget {
359        let mut children = Vec::with_capacity(self.children.len());
360        let mut flex = Vec::with_capacity(self.children.len());
361        for child in &self.children {
362            children.push(crate::authoring::build_child(&child.view, ctx));
363            flex.push(child.flex);
364        }
365        FlexWidget {
366            direction: self.direction,
367            cross: self.cross,
368            main: self.main,
369            children,
370            flex,
371        }
372    }
373
374    fn rebuild(
375        &self,
376        prev: &Self,
377        element: &mut FlexWidget,
378        ctx: &mut BuildCtx<'_>,
379    ) -> ChangeFlags {
380        let mut flags = ChangeFlags::NONE;
381        if prev.direction != self.direction || prev.cross != self.cross || prev.main != self.main {
382            element.direction = self.direction;
383            element.cross = self.cross;
384            element.main = self.main;
385            flags |= ChangeFlags::LAYOUT;
386        }
387
388        // Reconcile the child pods through the shared helper (build/rebuild/
389        // teardown + focus/capture retention for unchanged siblings across a
390        // structural change). Keyed children (`|child| child.key`) opt the list
391        // into key-matched reconciliation; an all-unkeyed list stays positional.
392        flags |= crate::authoring::rebuild_children(
393            &prev.children,
394            &self.children,
395            &mut element.children,
396            ctx,
397            |child| &child.view,
398            |child| child.key,
399        );
400
401        // Rebuild the parallel `flex` sidecar to match the reconciled children's
402        // new order and length in one shot — the keyed path may have reordered
403        // them, so an index-wise diff no longer tracks a given child. Comparing
404        // against the previous sidecar keeps the layout-dirty signal a factor
405        // change (or length/order change) still deserves.
406        let new_flex: Vec<u32> = self.children.iter().map(|child| child.flex).collect();
407        if new_flex != element.flex {
408            element.flex = new_flex;
409            flags |= ChangeFlags::LAYOUT;
410        }
411
412        flags
413    }
414
415    fn teardown(&self, element: &mut FlexWidget, ctx: &mut BuildCtx<'_>) {
416        for (child, pod) in self.children.iter().zip(element.children.iter_mut()) {
417            crate::authoring::teardown_child(&child.view, pod, ctx);
418        }
419    }
420}
421
422impl Widget for FlexWidget {
423    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
424        let axis = self.direction;
425        let max_main = axis.main_of(bc.max());
426        let max_cross = axis.cross_of(bc.max());
427        let stretch = self.cross == CrossAxisAlignment::Stretch;
428        // Stretch needs a finite cross bound to stretch to; fall back to zero
429        // under an (unusual) unbounded cross constraint.
430        let cross_bound = if max_cross.is_finite() {
431            max_cross
432        } else {
433            0.0
434        };
435
436        // Pass 1: lay out inflexible children under an unbounded main axis,
437        // accumulating the space they consume and the total flex weight.
438        let mut allocated_main = 0.0f64;
439        let mut total_flex = 0u32;
440        let mut max_child_cross = 0.0f64;
441        for (i, pod) in self.children.iter_mut().enumerate() {
442            if self.flex[i] == 0 {
443                let cbc =
444                    child_constraints(axis, 0.0, f64::INFINITY, cross_bound, stretch, max_cross);
445                let size = pod.layout_child(ctx, &cbc);
446                allocated_main += axis.main_of(size);
447                max_child_cross = max_child_cross.max(axis.cross_of(size));
448            } else {
449                total_flex += self.flex[i];
450            }
451        }
452
453        // Pass 2: divide the remaining main-axis space among flexible children in
454        // proportion to their flex factor, laying each out under a tight main
455        // constraint equal to its share.
456        let free = if max_main.is_finite() {
457            (max_main - allocated_main).max(0.0)
458        } else {
459            0.0
460        };
461        if total_flex > 0 {
462            for (i, pod) in self.children.iter_mut().enumerate() {
463                if self.flex[i] > 0 {
464                    let share = free * (self.flex[i] as f64) / (total_flex as f64);
465                    let cbc =
466                        child_constraints(axis, share, share, cross_bound, stretch, max_cross);
467                    let size = pod.layout_child(ctx, &cbc);
468                    max_child_cross = max_child_cross.max(axis.cross_of(size));
469                }
470            }
471        }
472
473        // Main extent fills the constraint when flexible children are present (and
474        // bounded); otherwise it shrink-wraps to the sum of the children.
475        let main_size = if total_flex > 0 && max_main.is_finite() {
476            max_main
477        } else {
478            allocated_main
479        };
480        // Cross extent fills under stretch, else shrink-wraps to the widest child.
481        let cross_size = if stretch && max_cross.is_finite() {
482            max_cross
483        } else {
484            max_child_cross
485        };
486
487        // Position children sequentially along the main axis (MainAxisAlignment
488        // v1 = Start → no leading gap), cross-aligned per CrossAxisAlignment.
489        let leading = match self.main {
490            MainAxisAlignment::Start => 0.0,
491        };
492        let mut main_pos = leading;
493        for pod in &mut self.children {
494            let child_cross = axis.cross_of(pod.size());
495            let cross_pos = match self.cross {
496                CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => 0.0,
497                CrossAxisAlignment::Center => (cross_size - child_cross) / 2.0,
498            };
499            pod.set_origin(axis.point(main_pos, cross_pos));
500            main_pos += axis.main_of(pod.size());
501        }
502
503        bc.constrain(axis.size(main_size, cross_size))
504    }
505
506    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
507        // Paint-time visible-rect culling: when a scroll ancestor has threaded a
508        // visible rect (see `PaintCtx::constrain_visible_rect`), skip painting any
509        // child whose absolute bounds fall fully outside it plus a one-viewport
510        // warm margin — so an offscreen animator below the fold never bubbles its
511        // `request_frame` (paint is where that happens), and near-edge content
512        // stays warm for a small scroll. `None` = no constraint → paint every
513        // child, unchanged. Culling is paint-only: layout, events, capture, and
514        // focus all route by layout geometry and are untouched.
515        //
516        // The cull tests a child's LAYOUT BOX ONLY — paint-time transforms
517        // (`AnimatedScale`'s `push_transform`) are deliberately not consulted, so
518        // the decision stays cheap and needs no per-child paint probe. This is
519        // sound on two grounds: (a) the one-viewport warm margin (`vr.inflate` by
520        // a full width/height each side) dwarfs any realistic transform overflow
521        // — the catalog's largest scaled-glow instance
522        // (`examples/glyph-catalog/src/pages/interactions.rs`'s `demo_charge_ring`,
523        // an `AnimatedScale(1.03, …)` over a ~366px-wide row) overflows its layout
524        // box by only ~11px on the dominant width axis (0.03 × 366), hundreds of
525        // px inside the margin; and (b) a known overflower makes its layout box
526        // reflect its max visual extent via the headroom-slot pattern (the
527        // `HB_RING_SLOT` precedent in that same file). See the pre-existing
528        // `AnimatedScale`/`Flex` sibling-layout defect noted at
529        // `interactions.rs`'s `demo_charge_ring` (the `HB_RING_SCALE_MIN`
530        // workaround comment) — a separate, layout-time interaction, cross-
531        // referenced here because it is the other place transform-vs-Flex-box
532        // divergence bites.
533        //
534        // Two exemptions relax the cull (they only ever ADD paints, never remove
535        // one, so offscreen-ANIMATOR suppression is preserved for every other
536        // child — a focused/hero child bypasses it by design):
537        //  1. A FOCUSED child (`pod.is_focused()`, at most one per Flex) always
538        //     paints. Its paint-time `publish_ime_state` is the ONLY resync
539        //     channel for a rebuild-driven (non-event) controlled change to a
540        //     focused field; culling it beyond the warm band would strand a stale
541        //     IME surface until the field re-entered the viewport.
542        //  2. While a hero transition is in flight (`ctx.hero_active()`), NO child
543        //     is culled — a tagged descendant scrolled past the warm band must
544        //     still paint so it reports its rest bounds (`report_hero`) for the
545        //     morph. This is the widest-net form (any child, not just the tagged
546        //     one): Flex cannot cheaply identify which child carries a hero tag
547        //     from its paint context, and the exemption only applies during the
548        //     brief transition, so the extra paints are bounded.
549        let cull = ctx
550            .visible_rect()
551            .map(|vr| vr.inflate(vr.width(), vr.height()));
552        let hero_in_flight = ctx.hero_active();
553        // Paint in child order (first child painted first / bottom-most).
554        for pod in &mut self.children {
555            if let Some(warm) = cull {
556                let exempt = hero_in_flight || pod.is_focused();
557                if !exempt {
558                    let child_abs =
559                        Rect::from_origin_size(ctx.origin() + pod.origin().to_vec2(), pod.size());
560                    if !child_abs.overlaps(warm) {
561                        continue;
562                    }
563                }
564            }
565            pod.paint_child(ctx, scene);
566        }
567    }
568
569    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
570        // Hit-test in reverse paint order (topmost/last-painted child first).
571        crate::authoring::route_event(&mut self.children, ctx, event)
572    }
573
574    fn semantics(&self, ctx: &mut SemanticsCtx) {
575        // A transparent layout container: contribute no node of its own, just
576        // forward each child so their nodes attach to the enclosing node.
577        for pod in &self.children {
578            pod.semantics_child(ctx);
579        }
580    }
581
582    crate::authoring::visit_children!(children);
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::test_support::{RecordingScene, leaf};
589
590    fn ctx(counter: &mut u64) -> BuildCtx<'_> {
591        BuildCtx::new(counter)
592    }
593
594    fn build<S: 'static>(view: &FlexView<S>) -> FlexWidget {
595        let mut counter = 0u64;
596        view.build(&mut ctx(&mut counter))
597    }
598
599    #[test]
600    fn distributes_free_space_by_flex_factors() {
601        // Two flexible children, factors 2:1, under a 300px-wide bound.
602        // free = 300 (no inflexible children) → shares 200 and 100.
603        let view: FlexView<()> = FlexView::new(
604            Axis::Horizontal,
605            vec![
606                flexible(2, leaf(1000.0, 20.0)),
607                flexible(1, leaf(1000.0, 20.0)),
608            ],
609        );
610        let mut w = build(&view);
611        let mut lctx = LayoutCtx::new();
612        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
613
614        assert_eq!(w.children[0].size().width, 200.0);
615        assert_eq!(w.children[1].size().width, 100.0);
616        assert_eq!(w.children[0].origin().x, 0.0);
617        assert_eq!(w.children[1].origin().x, 200.0);
618        // Flexible children present → main axis fills the 300px bound.
619        assert_eq!(size.width, 300.0);
620    }
621
622    #[test]
623    fn mixes_inflexible_and_flexible() {
624        // One inflexible 50px child, one flexible child, under 200px.
625        // free = 200 - 50 = 150 → the flexible child takes all 150.
626        let view: FlexView<()> = FlexView::new(
627            Axis::Horizontal,
628            vec![
629                inflexible(leaf(50.0, 20.0)),
630                flexible(1, leaf(1000.0, 20.0)),
631            ],
632        );
633        let mut w = build(&view);
634        let mut lctx = LayoutCtx::new();
635        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
636
637        assert_eq!(w.children[0].size().width, 50.0);
638        assert_eq!(w.children[1].size().width, 150.0);
639        assert_eq!(w.children[0].origin().x, 0.0);
640        assert_eq!(w.children[1].origin().x, 50.0);
641        assert_eq!(size.width, 200.0);
642    }
643
644    #[test]
645    fn shrink_wraps_main_axis_without_flexible_children() {
646        // No flexible children → main extent is the sum of child widths (60),
647        // not the 300px bound.
648        let view: FlexView<()> = row()
649            .child(leaf(40.0, 10.0).into_any())
650            .child(leaf(20.0, 10.0).into_any());
651        let mut w = build(&view);
652        let mut lctx = LayoutCtx::new();
653        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
654        assert_eq!(size.width, 60.0);
655        assert_eq!(w.children[1].origin().x, 40.0);
656    }
657
658    #[test]
659    fn cross_axis_stretch_tightens_children() {
660        // Stretch → every child gets a tight cross constraint = the 80px bound,
661        // overriding its 10px intrinsic height.
662        let view: FlexView<()> = row()
663            .child(leaf(30.0, 10.0).into_any())
664            .cross_axis(CrossAxisAlignment::Stretch);
665        let mut w = build(&view);
666        let mut lctx = LayoutCtx::new();
667        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 80.0)));
668        assert_eq!(w.children[0].size().height, 80.0);
669        assert_eq!(size.height, 80.0);
670    }
671
672    #[test]
673    fn cross_axis_start_packs_at_zero() {
674        // Start → the shorter child sits at cross 0. cross_size = tallest = 40.
675        let view: FlexView<()> = row()
676            .child(leaf(10.0, 40.0).into_any())
677            .child(leaf(10.0, 20.0).into_any())
678            .cross_axis(CrossAxisAlignment::Start);
679        let mut w = build(&view);
680        let mut lctx = LayoutCtx::new();
681        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
682        assert_eq!(size.height, 40.0);
683        assert_eq!(w.children[1].origin().y, 0.0);
684    }
685
686    #[test]
687    fn cross_axis_center_centers_shorter_children() {
688        // Center → cross_size = 40; the 20px-tall child is centered at (40-20)/2.
689        let view: FlexView<()> = row()
690            .child(leaf(10.0, 40.0).into_any())
691            .child(leaf(10.0, 20.0).into_any())
692            .cross_axis(CrossAxisAlignment::Center);
693        let mut w = build(&view);
694        let mut lctx = LayoutCtx::new();
695        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
696        assert_eq!(w.children[1].origin().y, 10.0);
697    }
698
699    #[test]
700    fn column_lays_out_along_vertical_axis() {
701        // A Column stacks children top-to-bottom: main = height.
702        let view: FlexView<()> = column()
703            .child(leaf(30.0, 15.0).into_any())
704            .child(leaf(30.0, 25.0).into_any());
705        let mut w = build(&view);
706        let mut lctx = LayoutCtx::new();
707        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 300.0)));
708        assert_eq!(size.height, 40.0); // 15 + 25
709        assert_eq!(w.children[0].origin().y, 0.0);
710        assert_eq!(w.children[1].origin().y, 15.0);
711    }
712
713    #[test]
714    fn paints_children_in_order() {
715        let view: FlexView<()> = row()
716            .child(leaf(20.0, 20.0).into_any())
717            .child(leaf(20.0, 20.0).into_any());
718        let mut w = build(&view);
719        let mut lctx = LayoutCtx::new();
720        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
721
722        let mut scene = RecordingScene::default();
723        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 200.0));
724        w.paint(&mut pctx, &mut scene);
725        // Child 0 painted at x=0, child 1 at x=20 — in child order.
726        assert_eq!(scene.rects[0].0, Point::new(0.0, 0.0));
727        assert_eq!(scene.rects[1].0, Point::new(20.0, 0.0));
728    }
729
730    // --- Paint-time visible-rect culling ------------------------------------
731
732    /// Build+lay out a 5-row vertical column of 100x100 leaves (rows at
733    /// y = 0,100,200,300,400) inside a 100x500 box.
734    fn culling_column() -> FlexWidget {
735        let view: FlexView<()> = column()
736            .child(leaf(100.0, 100.0).into_any())
737            .child(leaf(100.0, 100.0).into_any())
738            .child(leaf(100.0, 100.0).into_any())
739            .child(leaf(100.0, 100.0).into_any())
740            .child(leaf(100.0, 100.0).into_any());
741        let mut w = build(&view);
742        let mut lctx = LayoutCtx::new();
743        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 500.0)));
744        w
745    }
746
747    #[test]
748    fn no_visible_rect_paints_every_child() {
749        // Default (no threaded visible rect) = paint everything, unchanged.
750        let mut w = culling_column();
751        let mut scene = RecordingScene::default();
752        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 500.0));
753        w.paint(&mut pctx, &mut scene);
754        assert_eq!(scene.rects.len(), 5, "no cull → all five rows painted");
755    }
756
757    #[test]
758    fn culls_children_fully_outside_visible_rect_plus_margin() {
759        // Visible rect = the top 100px viewport at the origin. The warm margin is
760        // one viewport (100px) on each side, so the warm band is y ∈ [-100, 200].
761        // Rows at y=0/100/200 overlap it (the y=200 row touches the bottom edge,
762        // which `Rect::overlaps` counts as in); rows at y=300/400 are fully outside
763        // and culled.
764        let mut w = culling_column();
765        let mut scene = RecordingScene::default();
766        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 500.0));
767        pctx.constrain_visible_rect(Rect::from_origin_size(Point::ZERO, Size::new(100.0, 100.0)));
768        w.paint(&mut pctx, &mut scene);
769        assert_eq!(scene.rects.len(), 3, "two below-the-warm-band rows culled");
770        assert_eq!(scene.rects[0].0, Point::new(0.0, 0.0));
771        assert_eq!(scene.rects[1].0, Point::new(0.0, 100.0));
772        // The boundary row at the warm-band's exact bottom edge stays warm.
773        assert_eq!(scene.rects[2].0, Point::new(0.0, 200.0));
774    }
775
776    #[test]
777    fn margin_boundary_row_just_past_the_warm_band_is_culled() {
778        // A visible rect one pixel short of the y=200 row's top makes the warm band
779        // y ∈ [-99, 201]... instead pick a rect whose inflated band excludes row 3
780        // (y=300) but includes row 2 (y=200): rect height 50 at origin → warm band
781        // y ∈ [-50, 100]. Row 0 (0..100) and row 1 (100..200 → touches 100) stay;
782        // rows 2/3/4 are culled.
783        let mut w = culling_column();
784        let mut scene = RecordingScene::default();
785        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 500.0));
786        pctx.constrain_visible_rect(Rect::from_origin_size(Point::ZERO, Size::new(100.0, 50.0)));
787        w.paint(&mut pctx, &mut scene);
788        assert_eq!(scene.rects.len(), 2, "only the top band rows survive");
789        assert_eq!(scene.rects[0].0, Point::new(0.0, 0.0));
790        assert_eq!(scene.rects[1].0, Point::new(0.0, 100.0));
791    }
792
793    #[test]
794    fn culled_child_still_receives_events_at_its_layout_geometry() {
795        // Culling is paint-only: events route by layout geometry. Eight capturing
796        // rows (ROW_H each); paint with a tiny visible rect that culls the lower
797        // rows, then prove a tap at a culled row's geometry still captures and
798        // fires on up-inside.
799        let mut counter = 0u64;
800        let view: FlexView<Vec<u32>> = column()
801            .child(captor(0))
802            .child(captor(1))
803            .child(captor(2))
804            .child(captor(3))
805            .child(captor(4))
806            .child(captor(5))
807            .child(captor(6))
808            .child(captor(7));
809        let mut w = view.build(&mut ctx(&mut counter));
810        layout_column(&mut w);
811
812        // Warm band = the top row inflated by one ROW_H each side → y ∈ [-20, 40];
813        // row 7 (y 140..160) is far outside and culled from paint.
814        let mut scene = RecordingScene::default();
815        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(ROW_W, ROW_H * 8.0));
816        pctx.constrain_visible_rect(Rect::from_origin_size(Point::ZERO, Size::new(ROW_W, ROW_H)));
817        w.paint(&mut pctx, &mut scene);
818
819        // A Down at row 7's midpoint still captures despite it being culled, and
820        // the release fires it — event routing is untouched by paint culling.
821        let mut log: Vec<u32> = Vec::new();
822        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(7)));
823        assert!(
824            w.children[7].is_active(),
825            "a culled row still captures on Down"
826        );
827        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(7)));
828        assert_eq!(log, vec![7], "a culled-but-laid-out row still fires on Up");
829    }
830
831    // --- Cull exemptions: focused child + hero-in-flight --------------------
832    //
833    // Both exemptions only ever ADD a paint: an offscreen ANIMATOR with neither
834    // property is still suppressed (the whole point of visible-rect culling),
835    // which the "unfocused sibling still culled" assertions below keep honest
836    // — a focused/hero child bypasses that suppression by design.
837
838    /// A leaf that, on every paint, bumps a shared paint counter, fills a rect,
839    /// and republishes an [`ImeState`] carrying its current `value` — standing in
840    /// for `TextInput`'s paint-time `publish_ime_state`, the only resync channel
841    /// for a rebuild-driven controlled change to a focused field.
842    struct ImeLeaf {
843        value: String,
844        painted: Rc<Cell<u32>>,
845    }
846    /// Retained widget for [`ImeLeaf`].
847    struct ImeLeafWidget {
848        value: String,
849        painted: Rc<Cell<u32>>,
850    }
851
852    impl View<()> for ImeLeaf {
853        type Element = ImeLeafWidget;
854        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ImeLeafWidget {
855            ImeLeafWidget {
856                value: self.value.clone(),
857                painted: self.painted.clone(),
858            }
859        }
860        fn rebuild(
861            &self,
862            _prev: &Self,
863            element: &mut ImeLeafWidget,
864            _ctx: &mut BuildCtx<'_>,
865        ) -> ChangeFlags {
866            // A controlled change threaded in via rebuild (never an event) — an
867            // app-driven IME state update takes exactly this shape.
868            element.value = self.value.clone();
869            ChangeFlags::NONE
870        }
871    }
872
873    impl Widget for ImeLeafWidget {
874        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
875            bc.constrain(Size::new(ROW_W, ROW_H))
876        }
877        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
878            use frust_core::{EditingState, ImeState};
879            self.painted.set(self.painted.get() + 1);
880            scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
881            ctx.publish_ime_state(ImeState {
882                active: true,
883                editing: EditingState {
884                    text: self.value.clone(),
885                    selection_base: -1,
886                    selection_extent: -1,
887                    composing_base: -1,
888                    composing_extent: -1,
889                },
890                caret: None,
891                content_type: Default::default(),
892                suppress_soft_keyboard: false,
893            });
894        }
895    }
896
897    /// Build a 5-row vertical `Column` of [`ImeLeaf`]s (row `i` value `"row{i}"`),
898    /// returning the laid-out widget plus one paint counter per row.
899    fn ime_column() -> (FlexWidget, [Rc<Cell<u32>>; 5]) {
900        let counts: [Rc<Cell<u32>>; 5] = std::array::from_fn(|_| Rc::new(Cell::new(0)));
901        let view: FlexView<()> = Column(
902            (0..5)
903                .map(|i| {
904                    any(ImeLeaf {
905                        value: format!("row{i}"),
906                        painted: counts[i].clone(),
907                    })
908                })
909                .collect::<Vec<_>>(),
910        );
911        let mut counter = 0u64;
912        let mut w = view.build(&mut ctx(&mut counter));
913        layout_column(&mut w);
914        (w, counts)
915    }
916
917    /// A top-row visible rect: warm band = y ∈ [-ROW_H, 2·ROW_H] → rows 0/1/2
918    /// stay, rows 3/4 fall outside. Mirrors the culling tests' geometry.
919    fn top_row_rect() -> Rect {
920        Rect::from_origin_size(Point::ZERO, Size::new(ROW_W, ROW_H))
921    }
922
923    #[test]
924    fn focused_child_beyond_warm_band_still_paints_and_republishes_ime() {
925        // Row 4 (y 80..100) is fully outside the warm band but FOCUSED, so it must
926        // still paint and its IME republish must reach the container's PaintCtx.
927        // Row 3 (also outside) is unfocused → still culled: suppression intact.
928        let (mut w, counts) = ime_column();
929        w.children[4].set_focused(true);
930
931        let mut scene = RecordingScene::default();
932        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(ROW_W, ROW_H * 5.0));
933        pctx.constrain_visible_rect(top_row_rect());
934        w.paint(&mut pctx, &mut scene);
935
936        assert_eq!(counts[0].get(), 1, "warm row 0 paints");
937        assert_eq!(counts[1].get(), 1, "warm row 1 paints");
938        assert_eq!(counts[2].get(), 1, "warm boundary row 2 paints");
939        assert_eq!(
940            counts[3].get(),
941            0,
942            "unfocused offscreen row 3 stays culled (suppression intact)"
943        );
944        assert_eq!(
945            counts[4].get(),
946            1,
947            "focused offscreen row 4 is exempt from culling and paints"
948        );
949        // The focused row painted last, so its republished IME surface is the one
950        // that bubbled up — proving the resync channel is reachable while culled.
951        let ime = pctx
952            .take_ime_state()
953            .expect("focused row republished its IME");
954        assert_eq!(ime.editing.text, "row4");
955    }
956
957    #[test]
958    fn focused_cull_exemption_republishes_a_rebuild_mutation_immediately() {
959        // The stale-then-fixed regression. An offscreen field
960        // whose value is mutated via REBUILD (no event) must republish on the very
961        // next paint. Unfocused: culled → no republish → the shell keeps a stale
962        // surface (the bug). Focused: exempt → republished immediately (the fix).
963        let far = Rect::from_origin_size(Point::new(0.0, 10_000.0), Size::new(ROW_W, ROW_H));
964
965        // --- Stale case: the offscreen row is NOT focused. ---
966        let mut counter = 0u64;
967        let prev: FlexView<()> = column().child(ImeLeaf {
968            value: "v1".to_string(),
969            painted: Rc::new(Cell::new(0)),
970        });
971        let mut w = prev.build(&mut ctx(&mut counter));
972        layout_column(&mut w);
973        // Controlled change via rebuild → "v2", but the row is offscreen+unfocused.
974        let next: FlexView<()> = column().child(ImeLeaf {
975            value: "v2".to_string(),
976            painted: Rc::new(Cell::new(0)),
977        });
978        next.rebuild(&prev, &mut w, &mut ctx(&mut counter));
979        let mut scene = RecordingScene::default();
980        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(ROW_W, ROW_H * 5.0));
981        pctx.constrain_visible_rect(far);
982        w.paint(&mut pctx, &mut scene);
983        assert!(
984            pctx.take_ime_state().is_none(),
985            "an unfocused, culled field never republishes — its IME goes stale"
986        );
987
988        // --- Fixed case: the same offscreen row, now FOCUSED. ---
989        let mut counter = 0u64;
990        let prev: FlexView<()> = column().child(ImeLeaf {
991            value: "v1".to_string(),
992            painted: Rc::new(Cell::new(0)),
993        });
994        let mut w = prev.build(&mut ctx(&mut counter));
995        layout_column(&mut w);
996        w.children[0].set_focused(true);
997        let next: FlexView<()> = column().child(ImeLeaf {
998            value: "v2".to_string(),
999            painted: Rc::new(Cell::new(0)),
1000        });
1001        next.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1002        let mut scene = RecordingScene::default();
1003        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(ROW_W, ROW_H * 5.0));
1004        pctx.constrain_visible_rect(far);
1005        w.paint(&mut pctx, &mut scene);
1006        let ime = pctx
1007            .take_ime_state()
1008            .expect("focused field republishes even while offscreen");
1009        assert_eq!(
1010            ime.editing.text, "v2",
1011            "the rebuild-mutated value republished immediately, not stale 'v1'"
1012        );
1013    }
1014
1015    #[test]
1016    fn animated_scale_child_straddling_the_cull_boundary_does_not_pop() {
1017        // The cull is LAYOUT-BOX-ONLY. An AnimatedScale child
1018        // magnifies its paint far past its layout box (~2.8×), but the cull tests
1019        // the box, so a child whose BOX overlaps the warm band paints regardless
1020        // of scale (no scale-driven pop), and one whose box is fully outside is
1021        // still culled (its transform overflow is dwarfed by the one-viewport
1022        // margin — the safe trade the contract comment documents). Scale is driven
1023        // directly via a zero-duration timing that snaps on the first paint (no
1024        // wall-clock).
1025        use frust_core::Curve;
1026        use std::time::Duration;
1027        let snap = crate::Timing::Duration(Duration::ZERO, Curve::Linear);
1028
1029        // Rows 0..4 at y = i·ROW_H. Row 2 (y 40..60) sits on the warm-band bottom
1030        // edge (band = [-20, 40]) → box overlaps; row 4 (y 80..100) is fully out.
1031        let view: FlexView<()> = column()
1032            .child(leaf(ROW_W, ROW_H).into_any())
1033            .child(leaf(ROW_W, ROW_H).into_any())
1034            .child(crate::motion::AnimatedScale(2.8, leaf(ROW_W, ROW_H)).timing(snap))
1035            .child(leaf(ROW_W, ROW_H).into_any())
1036            .child(crate::motion::AnimatedScale(2.8, leaf(ROW_W, ROW_H)).timing(snap));
1037        let mut counter = 0u64;
1038        let mut w = view.build(&mut ctx(&mut counter));
1039        layout_column(&mut w);
1040
1041        let mut scene = RecordingScene::default();
1042        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(ROW_W, ROW_H * 5.0));
1043        pctx.constrain_visible_rect(top_row_rect());
1044        w.paint(&mut pctx, &mut scene);
1045
1046        // Rows 0,1 (plain) + row 2 (AnimatedScale, box touches the band) painted →
1047        // 3 fills; row 3 (plain) and row 4 (AnimatedScale) are outside → culled.
1048        assert_eq!(
1049            scene.rects.len(),
1050            3,
1051            "the boundary AnimatedScale row paints on its layout box, the two \
1052             fully-outside rows (one of them also AnimatedScale) are culled"
1053        );
1054        // The boundary AnimatedScale actually composited at ~2.8× (a transform was
1055        // pushed) — proving the magnified child painted, not a hairline stand-in.
1056        assert!(
1057            scene.transforms.iter().any(|t| {
1058                let c = t.as_coeffs();
1059                (c[0] - 2.8).abs() < 1e-6 && (c[3] - 2.8).abs() < 1e-6
1060            }),
1061            "the boundary row composited at 2.8× without being culled by its \
1062             transform-overflowed visual bounds"
1063        );
1064    }
1065
1066    #[test]
1067    fn children_vec_diff_adds_removes_and_type_swaps() {
1068        // Start with two Leaf children.
1069        let mut counter = 0u64;
1070        let prev: FlexView<()> = row()
1071            .child(leaf(10.0, 10.0).into_any())
1072            .child(leaf(10.0, 10.0).into_any());
1073        let mut w = prev.build(&mut ctx(&mut counter));
1074        assert_eq!(w.children.len(), 2);
1075
1076        // Grow to three.
1077        let grown: FlexView<()> = row()
1078            .child(leaf(10.0, 10.0).into_any())
1079            .child(leaf(10.0, 10.0).into_any())
1080            .child(leaf(10.0, 10.0).into_any());
1081        let flags = grown.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1082        assert_eq!(w.children.len(), 3);
1083        assert!(flags.needs_layout());
1084
1085        // Shrink to one.
1086        let shrunk: FlexView<()> = row().child(leaf(10.0, 10.0).into_any());
1087        shrunk.rebuild(&grown, &mut w, &mut ctx(&mut counter));
1088        assert_eq!(w.children.len(), 1);
1089        assert_eq!(w.flex.len(), 1);
1090
1091        // Type-swap the sole child (Leaf → the other test widget via AnyView).
1092        let swapped: FlexView<()> = row().child(crate::test_support::swap_leaf().into_any());
1093        swapped.rebuild(&shrunk, &mut w, &mut ctx(&mut counter));
1094        assert_eq!(w.children.len(), 1);
1095        // The swapped widget reports a distinctive size, proving the swap took.
1096        let mut lctx = LayoutCtx::new();
1097        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
1098        assert_eq!(w.children[0].size(), Size::new(7.0, 7.0));
1099    }
1100
1101    // --- Capture-vs-rebuild fixtures ----------------------------------------
1102    //
1103    // A vertical list of fixed 50x20 rows, each of which captures on `Down` and
1104    // "fires" (records its id into the `Vec<u32>` app state) only on an `Up`
1105    // while still armed. `Cancel` disarms WITHOUT touching app state — which is
1106    // what makes the rebuild-path synthetic cancel (driven over a `()` dummy
1107    // state) sound; a Cancel arm that read state would panic on the `()`
1108    // downcast, so these tests also guard that contract.
1109
1110    use std::any::Any;
1111    use std::cell::Cell;
1112    use std::rc::Rc;
1113
1114    use frust_core::{
1115        EventCtx, Key, KeyEvent, Modifiers, PointerButton, PointerEvent, PointerPhase, any,
1116    };
1117
1118    const ROW_W: f64 = 50.0;
1119    const ROW_H: f64 = 20.0;
1120
1121    /// A row that captures on `Down` and fires its id on up-inside.
1122    struct Captor {
1123        id: u32,
1124    }
1125    /// Retained widget for [`Captor`].
1126    struct CaptorWidget {
1127        id: u32,
1128        armed: bool,
1129    }
1130
1131    /// Erase a [`Captor`] tagged `id` into an `AnyView<Vec<u32>>`.
1132    fn captor(id: u32) -> impl View<Vec<u32>> {
1133        Captor { id }
1134    }
1135
1136    impl View<Vec<u32>> for Captor {
1137        type Element = CaptorWidget;
1138        fn build(&self, _ctx: &mut BuildCtx<'_>) -> CaptorWidget {
1139            CaptorWidget {
1140                id: self.id,
1141                armed: false,
1142            }
1143        }
1144        fn rebuild(
1145            &self,
1146            _prev: &Self,
1147            element: &mut CaptorWidget,
1148            _ctx: &mut BuildCtx<'_>,
1149        ) -> ChangeFlags {
1150            element.id = self.id;
1151            ChangeFlags::NONE
1152        }
1153    }
1154
1155    impl Widget for CaptorWidget {
1156        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1157            bc.constrain(Size::new(ROW_W, ROW_H))
1158        }
1159        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1160        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1161            let InputEvent::Pointer(p) = event else {
1162                return EventResult::Ignored;
1163            };
1164            match p.phase {
1165                PointerPhase::Down => {
1166                    self.armed = true;
1167                    ctx.capture_pointer();
1168                    EventResult::Handled
1169                }
1170                PointerPhase::Move => EventResult::Handled,
1171                PointerPhase::Up => {
1172                    if self.armed {
1173                        ctx.state_mut::<Vec<u32>>().push(self.id);
1174                    }
1175                    self.armed = false;
1176                    EventResult::Handled
1177                }
1178                PointerPhase::Cancel => {
1179                    // Clears armed WITHOUT reading app state (g2 contract).
1180                    self.armed = false;
1181                    EventResult::Handled
1182                }
1183            }
1184        }
1185    }
1186
1187    /// A row that records into a shared cell that it saw *any* event — used to
1188    /// prove a freshly type-swapped widget receives nothing until a new `Down`.
1189    struct Recorder {
1190        seen: Rc<Cell<u32>>,
1191    }
1192    /// Retained widget for [`Recorder`].
1193    struct RecorderWidget {
1194        seen: Rc<Cell<u32>>,
1195    }
1196
1197    impl View<Vec<u32>> for Recorder {
1198        type Element = RecorderWidget;
1199        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
1200            RecorderWidget {
1201                seen: self.seen.clone(),
1202            }
1203        }
1204        fn rebuild(
1205            &self,
1206            _prev: &Self,
1207            _element: &mut RecorderWidget,
1208            _ctx: &mut BuildCtx<'_>,
1209        ) -> ChangeFlags {
1210            ChangeFlags::NONE
1211        }
1212    }
1213
1214    impl Widget for RecorderWidget {
1215        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1216            bc.constrain(Size::new(ROW_W, ROW_H))
1217        }
1218        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1219        fn event(&mut self, _ctx: &mut EventCtx, _event: &InputEvent) -> EventResult {
1220            self.seen.set(self.seen.get() + 1);
1221            EventResult::Handled
1222        }
1223    }
1224
1225    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1226        InputEvent::Pointer(PointerEvent {
1227            phase,
1228            position: Point::new(x, y),
1229            button: PointerButton::Primary,
1230        })
1231    }
1232
1233    /// Dispatch one event to the flex over a `Vec<u32>` fire-log state.
1234    ///
1235    /// The log stays a `Vec<u32>` (not a slice) because it is erased as
1236    /// `&mut dyn Any` and recovered by the widgets via `state_mut::<Vec<u32>>()`.
1237    #[allow(clippy::ptr_arg)]
1238    fn dispatch(w: &mut FlexWidget, log: &mut Vec<u32>, event: &InputEvent) {
1239        let state: &mut dyn Any = log;
1240        let mut ectx = EventCtx::new(state, Point::ZERO, Size::new(ROW_W, ROW_H * 8.0));
1241        w.event(&mut ectx, event);
1242    }
1243
1244    /// Lay a Captor/Recorder column out so rows sit at y = i * ROW_H.
1245    fn layout_column(w: &mut FlexWidget) {
1246        let mut lctx = LayoutCtx::new();
1247        w.layout(
1248            &mut lctx,
1249            &BoxConstraints::loose(Size::new(ROW_W, ROW_H * 8.0)),
1250        );
1251    }
1252
1253    /// Y within row `i` (its vertical midpoint).
1254    fn row_y(i: usize) -> f64 {
1255        i as f64 * ROW_H + ROW_H / 2.0
1256    }
1257
1258    #[test]
1259    fn append_after_preserves_captured_drag_before_change() {
1260        // An armed child BEFORE the change point survives
1261        // an append-after: the appended tail is past the stable prefix, so the
1262        // captured row keeps its `active` path and fires on Up as normal. This is
1263        // Flutter's invariant — a sibling structural change must not break an
1264        // unchanged child's in-flight gesture.
1265        let mut counter = 0u64;
1266        let prev: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1)).child(captor(2));
1267        let mut w = prev.build(&mut ctx(&mut counter));
1268        layout_column(&mut w);
1269
1270        let mut log: Vec<u32> = Vec::new();
1271        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1272        assert!(w.children[1].is_active(), "row 1 captured the pointer");
1273
1274        // Append a new row AFTER the captured one → length grows, no type swap.
1275        let appended: FlexView<Vec<u32>> = column()
1276            .child(captor(0))
1277            .child(captor(1))
1278            .child(captor(2))
1279            .child(captor(3));
1280        appended.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1281        assert!(
1282            w.children[1].is_active(),
1283            "append-after preserves the captured row's active path (stable prefix)"
1284        );
1285
1286        // The captured drag completes and fires on the still-armed row.
1287        layout_column(&mut w);
1288        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(1)));
1289        assert_eq!(log, vec![1], "captured row fires on Up as normal");
1290    }
1291
1292    #[test]
1293    fn type_swap_before_armed_index_cancels_with_synthetic_cancel() {
1294        // An armed child at an index PAST the change point
1295        // (a type swap at an earlier index drops the stable prefix to that swap, so
1296        // the armed row sits in the cancelled tail) still receives a synthetic
1297        // `Cancel` — it unwinds its state machine rather than being silently
1298        // dropped or firing on a later hit-tested `Up`.
1299        let mut counter = 0u64;
1300        let prev: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1)).child(captor(2));
1301        let mut w = prev.build(&mut ctx(&mut counter));
1302        layout_column(&mut w);
1303
1304        let mut log: Vec<u32> = Vec::new();
1305        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(2)));
1306        assert!(w.children[2].is_active(), "row 2 captured the pointer");
1307
1308        // Swap row 0 (before the armed index) to a different concrete type → the
1309        // stable prefix ends at index 0, so the armed row 2 is in the cancelled
1310        // tail. A CaptorWidget that received `Cancel` disarms (its Cancel arm sets
1311        // `armed = false`); one that never received it would still fire on Up.
1312        let seen = Rc::new(Cell::new(0u32));
1313        let swapped: FlexView<Vec<u32>> = column()
1314            .child(Recorder { seen })
1315            .child(captor(1))
1316            .child(captor(2));
1317        swapped.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1318        assert!(
1319            w.children.iter().all(|p| !p.is_active()),
1320            "swap before the armed index cancelled the tail's active path"
1321        );
1322
1323        layout_column(&mut w);
1324        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(2)));
1325        assert!(
1326            log.is_empty(),
1327            "no fire on Up — the armed row was synthetically cancelled"
1328        );
1329    }
1330
1331    #[test]
1332    fn structural_truncation_of_active_row_unwinds_without_panic() {
1333        // (Scenario 2) Drag armed in row 2; a rebuild truncates the list to two
1334        // rows, dropping the active row. teardown_child cancels it: no panic, no
1335        // fire.
1336        let mut counter = 0u64;
1337        let prev: FlexView<Vec<u32>> = column()
1338            .child(captor(0))
1339            .child(captor(1))
1340            .child(captor(2))
1341            .child(captor(3));
1342        let mut w = prev.build(&mut ctx(&mut counter));
1343        layout_column(&mut w);
1344
1345        let mut log: Vec<u32> = Vec::new();
1346        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(2)));
1347        assert!(w.children[2].is_active());
1348
1349        // Truncate to two rows — the active row 2 is dropped.
1350        let truncated: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1));
1351        truncated.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1352        assert_eq!(w.children.len(), 2);
1353        assert_eq!(w.flex.len(), 2);
1354        assert!(w.children.iter().all(|p| !p.is_active()));
1355
1356        // A release lands nowhere armed → no fire, no panic.
1357        layout_column(&mut w);
1358        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(2)));
1359        assert!(log.is_empty());
1360    }
1361
1362    #[test]
1363    fn type_swap_at_active_index_clears_without_notifying_fresh_widget() {
1364        // (Scenario 3) A type swap at the active index clears the stale capture
1365        // but does NOT deliver anything to the fresh widget — it must see nothing
1366        // until a new Down.
1367        let mut counter = 0u64;
1368        let prev: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1));
1369        let mut w = prev.build(&mut ctx(&mut counter));
1370        layout_column(&mut w);
1371
1372        let mut log: Vec<u32> = Vec::new();
1373        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1374        assert!(w.children[1].is_active());
1375
1376        // Swap row 1 from Captor to a Recorder (a different concrete type).
1377        let seen = Rc::new(Cell::new(0u32));
1378        let swapped: FlexView<Vec<u32>> = column()
1379            .child(captor(0))
1380            .child(Recorder { seen: seen.clone() });
1381        swapped.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1382
1383        assert!(!w.children[1].is_active(), "stale capture path dropped");
1384        assert_eq!(seen.get(), 0, "fresh widget received no synthetic event");
1385
1386        // A brand-new Down now reaches the fresh widget.
1387        layout_column(&mut w);
1388        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1389        assert_eq!(seen.get(), 1, "fresh widget responds to a new gesture");
1390    }
1391
1392    #[test]
1393    fn content_only_rebuild_preserves_captured_drag() {
1394        // (Scenario 4, the critical negative test) A structural-change-free
1395        // rebuild (same length, same types) must NOT break a captured drag: the
1396        // active path survives and the release still fires on the captured row.
1397        let mut counter = 0u64;
1398        let prev: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1));
1399        let mut w = prev.build(&mut ctx(&mut counter));
1400        layout_column(&mut w);
1401
1402        let mut log: Vec<u32> = Vec::new();
1403        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1404        assert!(w.children[1].is_active());
1405
1406        // An ordinary every-frame rebuild: same structure, content only.
1407        let same: FlexView<Vec<u32>> = column().child(captor(0)).child(captor(1));
1408        same.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1409        assert!(
1410            w.children[1].is_active(),
1411            "content-only rebuild must NOT clear an in-flight capture"
1412        );
1413
1414        // The captured drag completes and fires on the still-armed row.
1415        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(1)));
1416        assert_eq!(log, vec![1], "captured row fires on Up as normal");
1417    }
1418
1419    // --- Positional focus-retention fixtures --------------------------------
1420    //
1421    // The focus analog of the capture tests above: a `FocusRow` requests focus on
1422    // `Down` and records its id on a focus-routed `Key` event, so a test can prove
1423    // both that the pod's `focused` flag survives a sibling structural change (the
1424    // seed the next paint reads into `PaintCtx::has_focus`) and that the container
1425    // still routes a `Key` event to the surviving focused row.
1426
1427    #[test]
1428    fn focus_on_child_survives_append_after() {
1429        // Focus on child 0 survives appending a row after it (a count change beyond
1430        // the focused index): the focused pod stays in the stable prefix, so its
1431        // `focused` flag — and thus the next paint's `PaintCtx::has_focus` and the
1432        // published IME surface — stays live, and a Key event still reaches it.
1433        let mut counter = 0u64;
1434        let prev: FlexView<Vec<u32>> = column().child(FocusRow { id: 0 }).child(FocusRow { id: 1 });
1435        let mut w = prev.build(&mut ctx(&mut counter));
1436        layout_column(&mut w);
1437
1438        let mut log: Vec<u32> = Vec::new();
1439        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1440        assert!(w.children[0].is_focused(), "child 0 took focus");
1441
1442        // Append a third row AFTER the focused one → length grows, no type swap.
1443        let appended: FlexView<Vec<u32>> = column()
1444            .child(FocusRow { id: 0 })
1445            .child(FocusRow { id: 1 })
1446            .child(FocusRow { id: 2 });
1447        appended.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1448        assert!(
1449            w.children[0].is_focused(),
1450            "append-after preserves the focused child's recorded path"
1451        );
1452
1453        // A Key event still routes to the surviving focused child 0.
1454        layout_column(&mut w);
1455        dispatch(&mut w, &mut log, &key_event());
1456        assert_eq!(log, vec![0], "Key still routes to the focused row");
1457    }
1458
1459    #[test]
1460    fn focus_on_child_survives_remove_after() {
1461        // Symmetric to the append case: removing a row AFTER the focused index
1462        // (a shrink beyond it) leaves the focused pod in the stable prefix.
1463        let mut counter = 0u64;
1464        let prev: FlexView<Vec<u32>> = column()
1465            .child(FocusRow { id: 0 })
1466            .child(FocusRow { id: 1 })
1467            .child(FocusRow { id: 2 });
1468        let mut w = prev.build(&mut ctx(&mut counter));
1469        layout_column(&mut w);
1470
1471        let mut log: Vec<u32> = Vec::new();
1472        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1473        assert!(w.children[0].is_focused());
1474
1475        // Remove the last row → shrink beyond the focused index.
1476        let removed: FlexView<Vec<u32>> =
1477            column().child(FocusRow { id: 0 }).child(FocusRow { id: 1 });
1478        removed.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1479        assert!(
1480            w.children[0].is_focused(),
1481            "remove-after preserves the focused child's recorded path"
1482        );
1483
1484        layout_column(&mut w);
1485        dispatch(&mut w, &mut log, &key_event());
1486        assert_eq!(log, vec![0], "Key still routes to the focused row");
1487    }
1488
1489    #[test]
1490    fn focus_cleared_when_focused_index_type_swaps() {
1491        // When the focused index itself type-swaps, its widget identity breaks →
1492        // the focus path is cleared and a subsequent Key event reaches nobody.
1493        let mut counter = 0u64;
1494        let prev: FlexView<Vec<u32>> = column().child(FocusRow { id: 0 }).child(FocusRow { id: 1 });
1495        let mut w = prev.build(&mut ctx(&mut counter));
1496        layout_column(&mut w);
1497
1498        let mut log: Vec<u32> = Vec::new();
1499        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1500        assert!(w.children[1].is_focused(), "child 1 took focus");
1501
1502        // Swap the focused index 1 to a different concrete type (a Captor).
1503        let swapped: FlexView<Vec<u32>> = column().child(FocusRow { id: 0 }).child(captor(9));
1504        swapped.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1505        assert!(
1506            !w.children[1].is_focused(),
1507            "a type swap at the focused index clears its focus path"
1508        );
1509
1510        // No focused pod remains → the Key event is dropped.
1511        layout_column(&mut w);
1512        dispatch(&mut w, &mut log, &key_event());
1513        assert!(
1514            log.is_empty(),
1515            "Key reaches nobody after the focused index swaps"
1516        );
1517    }
1518
1519    // --- Keyed reconciliation fixtures --------------------------------------
1520    //
1521    // A stateful probe row: `CounterWidget` holds an internal `count` that starts
1522    // at 0 on build and increments on every `Down`, pushing the post-increment
1523    // value into the `Vec<u32>` app state. Its rebuild deliberately does NOT reset
1524    // `count`, so the pushed sequence reveals whether a reconciliation *relocated*
1525    // the live widget (count continues) or *rebuilt* it from scratch (count resets
1526    // to 1). This is the probe the reorder-preserves-state test turns on.
1527
1528    /// A stateful counter row view tagged with `id`.
1529    struct Counter {
1530        id: u32,
1531    }
1532    /// Retained widget for [`Counter`]: `count` survives an in-place rebuild.
1533    struct CounterWidget {
1534        id: u32,
1535        count: u32,
1536    }
1537
1538    impl View<Vec<u32>> for Counter {
1539        type Element = CounterWidget;
1540        fn build(&self, _ctx: &mut BuildCtx<'_>) -> CounterWidget {
1541            CounterWidget {
1542                id: self.id,
1543                count: 0,
1544            }
1545        }
1546        fn rebuild(
1547            &self,
1548            _prev: &Self,
1549            element: &mut CounterWidget,
1550            _ctx: &mut BuildCtx<'_>,
1551        ) -> ChangeFlags {
1552            // Adopt the new id but preserve the accumulated count — a relocated
1553            // widget must keep its internal state.
1554            element.id = self.id;
1555            ChangeFlags::NONE
1556        }
1557    }
1558
1559    impl Widget for CounterWidget {
1560        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1561            bc.constrain(Size::new(ROW_W, ROW_H))
1562        }
1563        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1564        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1565            let InputEvent::Pointer(p) = event else {
1566                return EventResult::Ignored;
1567            };
1568            if p.phase == PointerPhase::Down {
1569                self.count += 1;
1570                ctx.state_mut::<Vec<u32>>().push(self.count);
1571                EventResult::Handled
1572            } else {
1573                EventResult::Ignored
1574            }
1575        }
1576    }
1577
1578    /// A keyed inflexible counter child.
1579    fn kcounter(key: u64, id: u32) -> FlexChild<Vec<u32>> {
1580        keyed(key, Counter { id })
1581    }
1582
1583    /// Build a vertical keyed column of counter rows.
1584    fn keyed_column(children: Vec<FlexChild<Vec<u32>>>) -> FlexView<Vec<u32>> {
1585        FlexView::new(Axis::Vertical, children)
1586    }
1587
1588    #[test]
1589    fn keyed_reorder_preserves_widget_state() {
1590        // THE CRITICAL TEST. Two keyed counter rows; drive row A's internal count
1591        // up, reorder the list, then drive A again — its count must continue from
1592        // where it left off, proving the reorder relocated A's live widget rather
1593        // than rebuilding whatever now sits at A's old index.
1594        let mut counter = 0u64;
1595        let prev = keyed_column(vec![kcounter(1, 1), kcounter(2, 2)]);
1596        let mut w = prev.build(&mut ctx(&mut counter));
1597        layout_column(&mut w);
1598
1599        let mut log: Vec<u32> = Vec::new();
1600        // Row A (key 1) at index 0: three Downs → its internal count reaches 3.
1601        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1602        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1603        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1604        assert_eq!(log, vec![1, 2, 3], "count accumulates on the original row");
1605        log.clear();
1606
1607        // Reorder: [B, A]. Row A moves to index 1.
1608        let reordered = keyed_column(vec![kcounter(2, 2), kcounter(1, 1)]);
1609        reordered.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1610        assert_eq!(w.children.len(), 2);
1611        layout_column(&mut w);
1612
1613        // Drive row A at its NEW index (1). If its widget was relocated, the count
1614        // continues to 4; a from-scratch rebuild would reset it to 1.
1615        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1616        assert_eq!(
1617            log,
1618            vec![4],
1619            "reordered row kept its internal state (4, not a reset 1)"
1620        );
1621    }
1622
1623    #[test]
1624    fn keyed_insert_above_preserves_existing_widget_state() {
1625        // Inserting a new keyed row above the existing ones must not rebuild them:
1626        // the surviving rows relocate (state preserved), only the new key builds.
1627        let mut counter = 0u64;
1628        let prev = keyed_column(vec![kcounter(1, 1), kcounter(2, 2)]);
1629        let mut w = prev.build(&mut ctx(&mut counter));
1630        layout_column(&mut w);
1631
1632        let mut log: Vec<u32> = Vec::new();
1633        // Row with key 2 (index 1): two Downs → count 2.
1634        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1635        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1636        assert_eq!(log, vec![1, 2]);
1637        log.clear();
1638
1639        // Insert a fresh key 9 at the top: [9, 1, 2]. Key 2 shifts to index 2.
1640        let inserted = keyed_column(vec![kcounter(9, 9), kcounter(1, 1), kcounter(2, 2)]);
1641        inserted.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1642        assert_eq!(w.children.len(), 3);
1643        layout_column(&mut w);
1644
1645        // Key 2 at its new index (2) continues its count to 3, not a reset 1.
1646        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(2)));
1647        assert_eq!(log, vec![3], "shifted row preserved its state");
1648        log.clear();
1649
1650        // The freshly-built key 9 (index 0) starts its own count at 1.
1651        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1652        assert_eq!(log, vec![1], "newly-inserted key builds a fresh widget");
1653    }
1654
1655    #[test]
1656    fn keyed_swap_preserves_both_widgets() {
1657        // A straight two-row swap must preserve *both* rows' state.
1658        let mut counter = 0u64;
1659        let prev = keyed_column(vec![kcounter(1, 1), kcounter(2, 2)]);
1660        let mut w = prev.build(&mut ctx(&mut counter));
1661        layout_column(&mut w);
1662
1663        let mut log: Vec<u32> = Vec::new();
1664        // A (key 1, idx 0) → count 1; B (key 2, idx 1) → count 1 then 2.
1665        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1666        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1667        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1668        assert_eq!(log, vec![1, 1, 2]);
1669        log.clear();
1670
1671        // Swap → [B, A].
1672        let swapped = keyed_column(vec![kcounter(2, 2), kcounter(1, 1)]);
1673        swapped.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1674        layout_column(&mut w);
1675
1676        // B now at idx 0 continues to 3; A now at idx 1 continues to 2.
1677        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1678        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1679        assert_eq!(log, vec![3, 2], "both swapped rows kept their state");
1680    }
1681
1682    #[test]
1683    fn keyed_same_order_rebuild_is_not_structural() {
1684        // A same-keys, same-order keyed rebuild is the content-only case: it must
1685        // NOT clear an in-flight capture (mirrors the positional negative test).
1686        let mut counter = 0u64;
1687        let prev: FlexView<Vec<u32>> = FlexView::new(
1688            Axis::Vertical,
1689            vec![keyed(1u64, Captor { id: 0 }), keyed(2u64, Captor { id: 1 })],
1690        );
1691        let mut w = prev.build(&mut ctx(&mut counter));
1692        layout_column(&mut w);
1693
1694        let mut log: Vec<u32> = Vec::new();
1695        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1696        assert!(w.children[1].is_active());
1697
1698        // Same keys, same order → not structural.
1699        let same: FlexView<Vec<u32>> = FlexView::new(
1700            Axis::Vertical,
1701            vec![keyed(1u64, Captor { id: 0 }), keyed(2u64, Captor { id: 1 })],
1702        );
1703        same.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1704        assert!(
1705            w.children[1].is_active(),
1706            "same-order keyed rebuild must not clear an in-flight capture"
1707        );
1708
1709        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(1)));
1710        assert_eq!(log, vec![1], "captured row fires on Up as normal");
1711    }
1712
1713    #[test]
1714    fn keyed_reorder_preserves_captured_drag() {
1715        // A key-matched row's identity is intact across a reorder, so its
1716        // in-flight capture is CARRIED with the relocated pod — not cancelled. The
1717        // captured drag completes and fires on the row at its new index.
1718        let mut counter = 0u64;
1719        let prev: FlexView<Vec<u32>> = FlexView::new(
1720            Axis::Vertical,
1721            vec![keyed(1u64, Captor { id: 0 }), keyed(2u64, Captor { id: 1 })],
1722        );
1723        let mut w = prev.build(&mut ctx(&mut counter));
1724        layout_column(&mut w);
1725
1726        let mut log: Vec<u32> = Vec::new();
1727        // Arm the capture in the key-1 row (index 0).
1728        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1729        assert!(w.children[0].is_active());
1730
1731        // Reorder → [key2, key1]. The key-1 row relocates to index 1 with its
1732        // `active` flag intact.
1733        let reordered: FlexView<Vec<u32>> = FlexView::new(
1734            Axis::Vertical,
1735            vec![keyed(2u64, Captor { id: 1 }), keyed(1u64, Captor { id: 0 })],
1736        );
1737        reordered.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1738        assert!(
1739            w.children[1].is_active(),
1740            "keyed reorder carries the captured row's active path to its new index"
1741        );
1742
1743        // The captured drag completes and fires on the relocated key-1 row (id 0).
1744        layout_column(&mut w);
1745        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(1)));
1746        assert_eq!(log, vec![0], "the relocated captured row fires on Up");
1747    }
1748
1749    #[test]
1750    fn keyed_removed_active_key_unwinds_without_fire() {
1751        // Removing a key whose row holds an in-flight capture tears it down via
1752        // the cancel-if-active path: no fire, no panic.
1753        let mut counter = 0u64;
1754        let prev: FlexView<Vec<u32>> = FlexView::new(
1755            Axis::Vertical,
1756            vec![
1757                keyed(1u64, Captor { id: 0 }),
1758                keyed(2u64, Captor { id: 1 }),
1759                keyed(3u64, Captor { id: 2 }),
1760            ],
1761        );
1762        let mut w = prev.build(&mut ctx(&mut counter));
1763        layout_column(&mut w);
1764
1765        let mut log: Vec<u32> = Vec::new();
1766        // Arm the key-2 row (index 1).
1767        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1768        assert!(w.children[1].is_active());
1769
1770        // Drop key 2 → [key1, key3].
1771        let removed: FlexView<Vec<u32>> = FlexView::new(
1772            Axis::Vertical,
1773            vec![keyed(1u64, Captor { id: 0 }), keyed(3u64, Captor { id: 2 })],
1774        );
1775        removed.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1776        assert_eq!(w.children.len(), 2);
1777        assert_eq!(w.flex.len(), 2);
1778        assert!(w.children.iter().all(|p| !p.is_active()));
1779
1780        layout_column(&mut w);
1781        dispatch(&mut w, &mut log, &ev(PointerPhase::Up, 10.0, row_y(1)));
1782        assert!(log.is_empty(), "removed active row does not fire on Up");
1783    }
1784
1785    #[test]
1786    fn keyed_reorder_preserves_focus_and_key_routing() {
1787        // Focus is the second recorded path and rides along with the
1788        // relocated pod: a key-matched focused row keeps its focus across a reorder,
1789        // and the container routes a subsequent Key event to it at its new index.
1790        let mut counter = 0u64;
1791        let prev: FlexView<Vec<u32>> = FlexView::new(
1792            Axis::Vertical,
1793            vec![
1794                keyed(1u64, FocusRow { id: 1 }),
1795                keyed(2u64, FocusRow { id: 2 }),
1796            ],
1797        );
1798        let mut w = prev.build(&mut ctx(&mut counter));
1799        layout_column(&mut w);
1800
1801        let mut log: Vec<u32> = Vec::new();
1802        // Focus the key-1 row (index 0).
1803        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(0)));
1804        assert!(w.children[0].is_focused());
1805
1806        // Reorder → [key2, key1]. The key-1 row relocates to index 1, carrying its
1807        // focus flag with it.
1808        let reordered: FlexView<Vec<u32>> = FlexView::new(
1809            Axis::Vertical,
1810            vec![
1811                keyed(2u64, FocusRow { id: 2 }),
1812                keyed(1u64, FocusRow { id: 1 }),
1813            ],
1814        );
1815        reordered.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1816        assert!(
1817            w.children[1].is_focused(),
1818            "the focused key-1 row keeps focus at its new index"
1819        );
1820        assert!(!w.children[0].is_focused());
1821
1822        // A Key event routes to the relocated focused row (id 1).
1823        layout_column(&mut w);
1824        dispatch(&mut w, &mut log, &key_event());
1825        assert_eq!(log, vec![1], "Key routes to the relocated focused row");
1826    }
1827
1828    #[test]
1829    fn keyed_removed_focused_key_clears_focus() {
1830        // Removing the focused keyed row breaks its identity: the pod is
1831        // torn down, so no focused pod remains and a subsequent Key reaches nobody.
1832        let mut counter = 0u64;
1833        let prev: FlexView<Vec<u32>> = FlexView::new(
1834            Axis::Vertical,
1835            vec![
1836                keyed(1u64, FocusRow { id: 1 }),
1837                keyed(2u64, FocusRow { id: 2 }),
1838            ],
1839        );
1840        let mut w = prev.build(&mut ctx(&mut counter));
1841        layout_column(&mut w);
1842
1843        let mut log: Vec<u32> = Vec::new();
1844        // Focus the key-2 row (index 1).
1845        dispatch(&mut w, &mut log, &ev(PointerPhase::Down, 10.0, row_y(1)));
1846        assert!(w.children[1].is_focused());
1847
1848        // Remove key 2 → only key 1 survives, and it never held focus.
1849        let removed: FlexView<Vec<u32>> =
1850            FlexView::new(Axis::Vertical, vec![keyed(1u64, FocusRow { id: 1 })]);
1851        removed.rebuild(&prev, &mut w, &mut ctx(&mut counter));
1852        assert_eq!(w.children.len(), 1);
1853        assert!(
1854            w.children.iter().all(|p| !p.is_focused()),
1855            "the removed focused row leaves no focus path behind"
1856        );
1857
1858        layout_column(&mut w);
1859        dispatch(&mut w, &mut log, &key_event());
1860        assert!(
1861            log.is_empty(),
1862            "Key reaches nobody after the focused key is removed"
1863        );
1864    }
1865
1866    /// A focus-taking row: requests focus on `Down`, records `id` on a Key event
1867    /// (so a test can prove a focus-routed event reaches it at its current index).
1868    struct FocusRow {
1869        id: u32,
1870    }
1871    /// Retained widget for [`FocusRow`].
1872    struct FocusRowWidget {
1873        id: u32,
1874    }
1875
1876    impl View<Vec<u32>> for FocusRow {
1877        type Element = FocusRowWidget;
1878        fn build(&self, _ctx: &mut BuildCtx<'_>) -> FocusRowWidget {
1879            FocusRowWidget { id: self.id }
1880        }
1881        fn rebuild(
1882            &self,
1883            _prev: &Self,
1884            element: &mut FocusRowWidget,
1885            _ctx: &mut BuildCtx<'_>,
1886        ) -> ChangeFlags {
1887            element.id = self.id;
1888            ChangeFlags::NONE
1889        }
1890    }
1891
1892    impl Widget for FocusRowWidget {
1893        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1894            bc.constrain(Size::new(ROW_W, ROW_H))
1895        }
1896        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1897        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1898            match event {
1899                InputEvent::Pointer(p) if p.phase == PointerPhase::Down => {
1900                    ctx.request_focus();
1901                    EventResult::Handled
1902                }
1903                // A focus-routed Key event records this row's id — how a test
1904                // observes which row the container routes focus to.
1905                InputEvent::Key(_) => {
1906                    ctx.state_mut::<Vec<u32>>().push(self.id);
1907                    EventResult::Handled
1908                }
1909                _ => EventResult::Ignored,
1910            }
1911        }
1912    }
1913
1914    /// Build a focus-routed `Key` event (an "a" keypress).
1915    fn key_event() -> InputEvent {
1916        InputEvent::Key(KeyEvent {
1917            key: Key::Character("a".to_string()),
1918            modifiers: Modifiers::default(),
1919            repeat: false,
1920        })
1921    }
1922}