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