Skip to main content

fret_ui_kit/declarative/
collapsible_motion.rs

1//! Collapsible-style height transitions.
2//!
3//! Upstream Radix Collapsible/Accordion coordinate mount/unmount via `Presence` and expose measured
4//! content dimensions for CSS keyframe animations (e.g. `--radix-collapsible-content-height`).
5//!
6//! Fret does not use CSS variables. Instead, we cache the last known open height and drive a
7//! clipped wrapper height using a `TransitionTimeline` progress value.
8
9use fret_core::{Px, Size};
10use fret_ui::elements::GlobalElementId;
11use fret_ui::theme::CubicBezier;
12use fret_ui::{ElementContext, UiHost};
13
14use crate::headless::transition::TransitionOutput;
15use crate::{LayoutRefinement, Space};
16
17/// Output describing how to render a collapsible-style measured-height wrapper for the current
18/// element root.
19///
20/// This helper is usable by any "open/close with height animation" component (Collapsible,
21/// Accordion items, etc.). It does not build elements; it only describes the wrapper/refinement and
22/// how to update cached measurements once an element id is known.
23#[derive(Debug, Clone)]
24pub struct MeasuredHeightMotionOutput {
25    pub state_id: GlobalElementId,
26    /// The requested open state (source of truth).
27    pub open: bool,
28    /// Whether the transition should be driven as open this frame.
29    ///
30    /// When there is no cached measurement, this is `false` even if `open=true` so that the first
31    /// open can mount an off-flow measurement wrapper before animating.
32    pub open_for_motion: bool,
33    /// Whether an off-flow measurement pass is required this frame.
34    pub wants_measurement: bool,
35    /// Transition timeline output for `open_for_motion`.
36    pub transition: TransitionOutput,
37    /// Whether the content wrapper should be present in the element tree.
38    pub should_render: bool,
39    /// Layout refinement for the wrapper (either a clipped-height wrapper or a measurement wrapper).
40    pub wrapper_refinement: LayoutRefinement,
41    /// Opacity to apply to the wrapper subtree (0.0 for measurement; 1.0 for visible content).
42    pub wrapper_opacity: f32,
43}
44
45#[derive(Debug, Clone, Copy)]
46struct MeasuredSizeState {
47    last: Size,
48}
49
50impl Default for MeasuredSizeState {
51    fn default() -> Self {
52        Self {
53            last: Size::new(Px(0.0), Px(0.0)),
54        }
55    }
56}
57
58#[derive(Debug, Default, Clone, Copy)]
59struct MeasuredHeightEndpointHoldState {
60    initialized: bool,
61    last_open_requested: bool,
62    opening_hold_pending: bool,
63    closing_hold_pending: bool,
64}
65
66fn zero_height_wrapper_refinement() -> LayoutRefinement {
67    LayoutRefinement::default()
68        .w_full()
69        .min_w_0()
70        .min_h(Px(0.0))
71        .h_px(Px(0.0))
72        .overflow_hidden()
73}
74
75/// Read the last cached open height for a collapsible content subtree.
76pub fn last_measured_height_for<H: UiHost>(
77    cx: &mut ElementContext<'_, H>,
78    state_id: GlobalElementId,
79) -> Px {
80    cx.state_for(state_id, MeasuredSizeState::default, |st| st.last.height)
81}
82
83/// Read the last cached open size for a collapsible content subtree.
84pub fn last_measured_size_for<H: UiHost>(
85    cx: &mut ElementContext<'_, H>,
86    state_id: GlobalElementId,
87) -> Size {
88    cx.state_for(state_id, MeasuredSizeState::default, |st| st.last)
89}
90
91/// Update the cached open height from the previously-laid-out bounds of `wrapper_element_id`.
92///
93/// This should be called from the same element scope that renders the wrapper (so the wrapper ID
94/// is stable), but the cached value can be stored on a separate `state_id` (typically the root).
95pub fn update_measured_height_if_open_for<H: UiHost>(
96    cx: &mut ElementContext<'_, H>,
97    state_id: GlobalElementId,
98    wrapper_element_id: GlobalElementId,
99    open: bool,
100    animating: bool,
101) -> Px {
102    let last = last_measured_size_for(cx, state_id);
103    let last_height = last.height;
104
105    if !open || animating {
106        return last_height;
107    }
108
109    let Some(bounds) = cx.last_bounds_for_element(wrapper_element_id) else {
110        return last_height;
111    };
112
113    let h = bounds.size.height;
114    if h.0 <= 0.0 || (h.0 - last_height.0).abs() <= 0.5 {
115        return last_height;
116    }
117
118    cx.state_for(state_id, MeasuredSizeState::default, |st| {
119        st.last = bounds.size;
120    });
121    h
122}
123
124/// Update the cached open size from a "measurement element" that is laid out off-flow.
125///
126/// This is intended to support the first open animation: callers can render a hidden, absolutely
127/// positioned copy of the content that does not affect layout, then read its last-frame bounds.
128pub fn update_measured_size_from_element_if_open_for<H: UiHost>(
129    cx: &mut ElementContext<'_, H>,
130    state_id: GlobalElementId,
131    measure_element_id: GlobalElementId,
132    open: bool,
133) -> Size {
134    let last = last_measured_size_for(cx, state_id);
135    if !open {
136        return last;
137    }
138
139    let Some(bounds) = cx.last_bounds_for_element(measure_element_id) else {
140        return last;
141    };
142
143    if bounds.size.height.0 <= 0.0 {
144        return last;
145    }
146
147    // Avoid churn from layout rounding noise.
148    let dw = (bounds.size.width.0 - last.width.0).abs();
149    let dh = (bounds.size.height.0 - last.height.0).abs();
150    if dw <= 0.5 && dh <= 0.5 {
151        return last;
152    }
153
154    cx.state_for(state_id, MeasuredSizeState::default, |st| {
155        st.last = bounds.size;
156    });
157
158    bounds.size
159}
160
161/// Layout refinement for an off-flow measurement wrapper.
162///
163/// Call sites should typically wrap this in an opacity gate so it does not paint.
164pub fn collapsible_measurement_wrapper_refinement() -> LayoutRefinement {
165    LayoutRefinement::default()
166        .absolute()
167        .top(Space::N0)
168        .left(Space::N0)
169        .right(Space::N0)
170        .overflow_visible()
171}
172
173/// Compute wrapper mounting and layout patches for a collapsible content subtree.
174///
175/// When a measurement exists, the wrapper height is driven using `transition.progress` as an eased
176/// 0..1 progress value. Without a measurement, call sites should avoid "close presence" to prevent
177/// hidden content from affecting layout.
178pub fn collapsible_height_wrapper_refinement(
179    open: bool,
180    force_mount: bool,
181    require_measurement_for_close: bool,
182    transition: TransitionOutput,
183    measured_height: Px,
184) -> (bool, LayoutRefinement) {
185    let has_measurement = measured_height.0 > 0.0;
186    let progress = transition.progress.clamp(0.0, 1.0);
187
188    let keep_mounted_for_close =
189        transition.present && (!require_measurement_for_close || has_measurement);
190    let should_render = force_mount || open || keep_mounted_for_close;
191
192    let wants_height_animation = has_measurement && (transition.animating || !open);
193
194    let mut wrapper = LayoutRefinement::default()
195        .w_full()
196        .min_w_0()
197        .min_h(Px(0.0))
198        .overflow_hidden();
199    if wants_height_animation {
200        wrapper = wrapper.h_px(Px(measured_height.0 * progress));
201    } else if !open && force_mount {
202        wrapper = wrapper.h_px(Px(0.0));
203    }
204
205    (should_render, wrapper)
206}
207
208/// Computes a measured-height motion plan for the current element root.
209///
210/// Call sites should:
211/// 1. Call this during rendering to obtain the wrapper refinement.
212/// 2. Render a wrapper element with a stable id (e.g. using `cx.keyed(...)`).
213/// 3. Call `update_measured_for_motion(...)` with the wrapper element id to update cached size.
214#[allow(clippy::too_many_arguments)]
215pub fn measured_height_motion_for_root<H: UiHost>(
216    cx: &mut ElementContext<'_, H>,
217    open: bool,
218    force_mount: bool,
219    require_measurement_for_close: bool,
220    open_ticks: u64,
221    close_ticks: u64,
222    ease: fn(f32) -> f32,
223) -> MeasuredHeightMotionOutput {
224    let state_id = cx.root_id();
225    let hold_state_slot = cx.slot_id();
226    let last_height = last_measured_height_for(cx, state_id);
227    let has_measurement = last_height.0 > 0.0;
228    let wants_measurement = open && !has_measurement;
229    let open_for_motion = open && has_measurement;
230
231    let (opening_hold_pending, closing_hold_pending) = cx.state_for(
232        hold_state_slot,
233        MeasuredHeightEndpointHoldState::default,
234        |st: &mut MeasuredHeightEndpointHoldState| {
235            let prev_open = st.last_open_requested;
236            if st.initialized {
237                if open && !prev_open {
238                    st.opening_hold_pending = true;
239                }
240                if !open && prev_open {
241                    st.closing_hold_pending = true;
242                }
243            } else {
244                st.initialized = true;
245            }
246            st.last_open_requested = open;
247            (st.opening_hold_pending, st.closing_hold_pending)
248        },
249    );
250
251    let transition = crate::declarative::transition::drive_transition_with_durations_and_easing(
252        cx,
253        open_for_motion,
254        open_ticks,
255        close_ticks,
256        ease,
257    );
258
259    if wants_measurement {
260        cx.request_frame();
261        return MeasuredHeightMotionOutput {
262            state_id,
263            open,
264            open_for_motion,
265            wants_measurement,
266            transition,
267            should_render: true,
268            wrapper_refinement: LayoutRefinement::default()
269                .w_full()
270                .min_w_0()
271                .min_h(Px(0.0))
272                .overflow_hidden(),
273            wrapper_opacity: 1.0,
274        };
275    }
276
277    let (should_render, wrapper_refinement) = collapsible_height_wrapper_refinement(
278        open_for_motion,
279        force_mount,
280        require_measurement_for_close,
281        transition,
282        last_height,
283    );
284
285    // Transition timelines intentionally do not emit an endpoint frame at `progress=0` for closes
286    // (they jump from `1/N` to unmounted). For measured-height wrappers this can be visually
287    // noticeable as a last-moment "snap" when content disappears. Hold a single zero-height frame
288    // at the start of opening and the end of closing to better match CSS keyframe endpoints.
289    if open && opening_hold_pending && has_measurement && transition.animating {
290        cx.state_for(
291            hold_state_slot,
292            MeasuredHeightEndpointHoldState::default,
293            |st: &mut MeasuredHeightEndpointHoldState| st.opening_hold_pending = false,
294        );
295        cx.request_frame();
296        return MeasuredHeightMotionOutput {
297            state_id,
298            open,
299            open_for_motion,
300            wants_measurement,
301            transition,
302            should_render,
303            wrapper_refinement: zero_height_wrapper_refinement(),
304            wrapper_opacity: 1.0,
305        };
306    }
307
308    if !open && closing_hold_pending && !transition.present {
309        cx.state_for(
310            hold_state_slot,
311            MeasuredHeightEndpointHoldState::default,
312            |st: &mut MeasuredHeightEndpointHoldState| st.closing_hold_pending = false,
313        );
314        cx.request_frame();
315        return MeasuredHeightMotionOutput {
316            state_id,
317            open,
318            open_for_motion,
319            wants_measurement,
320            transition,
321            should_render: true,
322            wrapper_refinement: zero_height_wrapper_refinement(),
323            wrapper_opacity: 1.0,
324        };
325    }
326
327    MeasuredHeightMotionOutput {
328        state_id,
329        open,
330        open_for_motion,
331        wants_measurement,
332        transition,
333        should_render,
334        wrapper_refinement,
335        wrapper_opacity: 1.0,
336    }
337}
338
339/// Like [`measured_height_motion_for_root`], but uses a cubic-bezier easing curve.
340#[allow(clippy::too_many_arguments)]
341pub fn measured_height_motion_for_root_with_cubic_bezier<H: UiHost>(
342    cx: &mut ElementContext<'_, H>,
343    open: bool,
344    force_mount: bool,
345    require_measurement_for_close: bool,
346    open_ticks: u64,
347    close_ticks: u64,
348    bezier: CubicBezier,
349) -> MeasuredHeightMotionOutput {
350    let state_id = cx.root_id();
351    let hold_state_slot = cx.slot_id();
352    let last_height = last_measured_height_for(cx, state_id);
353    let has_measurement = last_height.0 > 0.0;
354    let wants_measurement = open && !has_measurement;
355    let open_for_motion = open && has_measurement;
356
357    let (opening_hold_pending, closing_hold_pending) = cx.state_for(
358        hold_state_slot,
359        MeasuredHeightEndpointHoldState::default,
360        |st: &mut MeasuredHeightEndpointHoldState| {
361            let prev_open = st.last_open_requested;
362            if st.initialized {
363                if open && !prev_open {
364                    st.opening_hold_pending = true;
365                }
366                if !open && prev_open {
367                    st.closing_hold_pending = true;
368                }
369            } else {
370                st.initialized = true;
371            }
372            st.last_open_requested = open;
373            (st.opening_hold_pending, st.closing_hold_pending)
374        },
375    );
376
377    let transition =
378        crate::declarative::transition::drive_transition_with_durations_and_cubic_bezier(
379            cx,
380            open_for_motion,
381            open_ticks,
382            close_ticks,
383            bezier,
384        );
385
386    if wants_measurement {
387        cx.request_frame();
388        return MeasuredHeightMotionOutput {
389            state_id,
390            open,
391            open_for_motion,
392            wants_measurement,
393            transition,
394            should_render: true,
395            wrapper_refinement: LayoutRefinement::default()
396                .w_full()
397                .min_w_0()
398                .min_h(Px(0.0))
399                .overflow_hidden(),
400            wrapper_opacity: 1.0,
401        };
402    }
403
404    let (should_render, wrapper_refinement) = collapsible_height_wrapper_refinement(
405        open_for_motion,
406        force_mount,
407        require_measurement_for_close,
408        transition,
409        last_height,
410    );
411
412    if open && opening_hold_pending && has_measurement && transition.animating {
413        cx.state_for(
414            hold_state_slot,
415            MeasuredHeightEndpointHoldState::default,
416            |st: &mut MeasuredHeightEndpointHoldState| st.opening_hold_pending = false,
417        );
418        cx.request_frame();
419        return MeasuredHeightMotionOutput {
420            state_id,
421            open,
422            open_for_motion,
423            wants_measurement,
424            transition,
425            should_render,
426            wrapper_refinement: zero_height_wrapper_refinement(),
427            wrapper_opacity: 1.0,
428        };
429    }
430
431    if !open && closing_hold_pending && !transition.present {
432        cx.state_for(
433            hold_state_slot,
434            MeasuredHeightEndpointHoldState::default,
435            |st: &mut MeasuredHeightEndpointHoldState| st.closing_hold_pending = false,
436        );
437        cx.request_frame();
438        return MeasuredHeightMotionOutput {
439            state_id,
440            open,
441            open_for_motion,
442            wants_measurement,
443            transition,
444            should_render: true,
445            wrapper_refinement: zero_height_wrapper_refinement(),
446            wrapper_opacity: 1.0,
447        };
448    }
449
450    MeasuredHeightMotionOutput {
451        state_id,
452        open,
453        open_for_motion,
454        wants_measurement,
455        transition,
456        should_render,
457        wrapper_refinement,
458        wrapper_opacity: 1.0,
459    }
460}
461
462/// Updates the cached measured size/height based on the wrapper element id.
463///
464/// When `motion.wants_measurement=true`, the wrapper is expected to be an off-flow measurement
465/// wrapper.
466pub fn update_measured_for_motion<H: UiHost>(
467    cx: &mut ElementContext<'_, H>,
468    motion: MeasuredHeightMotionOutput,
469    wrapper_element_id: GlobalElementId,
470) -> Size {
471    if motion.wants_measurement {
472        return update_measured_size_from_element_if_open_for(
473            cx,
474            motion.state_id,
475            wrapper_element_id,
476            motion.open,
477        );
478    }
479
480    let _ = update_measured_height_if_open_for(
481        cx,
482        motion.state_id,
483        wrapper_element_id,
484        motion.open,
485        motion.transition.animating,
486    );
487    last_measured_size_for(cx, motion.state_id)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    use fret_app::App;
495    use fret_core::{
496        AppWindowId, PathCommand, SvgId, SvgService, TextBlobId, TextConstraints, TextInput,
497        TextMetrics, TextService,
498    };
499    use fret_core::{PathConstraints, PathId, PathMetrics, PathService, PathStyle};
500    use fret_core::{Point, Px, Rect};
501    use fret_runtime::{FrameId, TickId};
502    use fret_ui::element::{ContainerProps, LayoutStyle, Length, OpacityProps};
503    use fret_ui::elements::GlobalElementId;
504    use fret_ui::{Theme, UiTree};
505
506    use crate::declarative::model_watch::ModelWatchExt as _;
507    use crate::declarative::style as decl_style;
508    use crate::declarative::transition;
509
510    #[derive(Default)]
511    struct FakeServices;
512
513    impl TextService for FakeServices {
514        fn prepare(
515            &mut self,
516            _input: &TextInput,
517            _constraints: TextConstraints,
518        ) -> (TextBlobId, TextMetrics) {
519            (
520                TextBlobId::default(),
521                TextMetrics {
522                    size: fret_core::Size::new(Px(10.0), Px(10.0)),
523                    baseline: Px(8.0),
524                },
525            )
526        }
527
528        fn release(&mut self, _blob: TextBlobId) {}
529    }
530
531    impl PathService for FakeServices {
532        fn prepare(
533            &mut self,
534            _commands: &[PathCommand],
535            _style: PathStyle,
536            _constraints: PathConstraints,
537        ) -> (PathId, PathMetrics) {
538            (PathId::default(), PathMetrics::default())
539        }
540
541        fn release(&mut self, _path: PathId) {}
542    }
543
544    impl SvgService for FakeServices {
545        fn register_svg(&mut self, _bytes: &[u8]) -> SvgId {
546            SvgId::default()
547        }
548
549        fn unregister_svg(&mut self, _svg: SvgId) -> bool {
550            true
551        }
552    }
553
554    impl fret_core::MaterialService for FakeServices {
555        fn register_material(
556            &mut self,
557            _desc: fret_core::MaterialDescriptor,
558        ) -> Result<fret_core::MaterialId, fret_core::MaterialRegistrationError> {
559            Err(fret_core::MaterialRegistrationError::Unsupported)
560        }
561
562        fn unregister_material(&mut self, _id: fret_core::MaterialId) -> bool {
563            true
564        }
565    }
566
567    #[test]
568    fn collapsible_can_measure_off_flow_then_animate_open() {
569        let window = AppWindowId::default();
570        let mut app = App::new();
571        let mut ui: UiTree<App> = UiTree::new();
572        ui.set_window(window);
573
574        let open = app.models_mut().insert(false);
575
576        let bounds = Rect::new(
577            Point::new(Px(0.0), Px(0.0)),
578            fret_core::Size::new(Px(300.0), Px(200.0)),
579        );
580        let mut services = FakeServices;
581
582        let wrapper_id_out: std::cell::Cell<Option<GlobalElementId>> = std::cell::Cell::new(None);
583
584        let bump_frame = |app: &mut App| {
585            app.set_tick_id(TickId(app.tick_id().0.saturating_add(1)));
586            app.set_frame_id(FrameId(app.frame_id().0.saturating_add(1)));
587        };
588
589        let render = |ui: &mut UiTree<App>, app: &mut App, services: &mut FakeServices| {
590            bump_frame(app);
591            let wrapper_id_out = &wrapper_id_out;
592
593            let root = fret_ui::declarative::render_root(
594                ui,
595                app,
596                services,
597                window,
598                bounds,
599                "collapsible-motion",
600                |cx| {
601                    let state_id = cx.root_id();
602                    let is_open = cx.watch_model(&open).copied_or_default();
603
604                    let measured = last_measured_height_for(cx, state_id);
605                    let has_measurement = measured.0 > 0.0;
606                    let wants_measure = is_open && !has_measurement;
607
608                    let mut probe_layout = LayoutStyle::default();
609                    probe_layout.size.width = Length::Fill;
610                    probe_layout.size.height = Length::Px(Px(1.0));
611
612                    let mut content_layout = LayoutStyle::default();
613                    content_layout.size.width = Length::Fill;
614                    content_layout.size.height = Length::Px(Px(80.0));
615
616                    // Delay the opening transition until we have a non-zero measurement, so the
617                    // animation starts from 0 -> measured height (Radix-like).
618                    let open_for_motion = is_open && has_measurement;
619                    let motion = transition::drive_transition_with_durations_and_easing(
620                        cx,
621                        open_for_motion,
622                        8,
623                        8,
624                        |t| t,
625                    );
626
627                    let (should_render_wrapper, wrapper) = collapsible_height_wrapper_refinement(
628                        open_for_motion,
629                        false,
630                        true,
631                        motion,
632                        measured,
633                    );
634
635                    let mut children = Vec::new();
636
637                    // Ensure the root has a non-zero width so absolutely positioned measurement
638                    // wrappers can resolve `left/right` insets.
639                    children.push(cx.container(
640                        ContainerProps {
641                            layout: probe_layout,
642                            ..Default::default()
643                        },
644                        |_cx| Vec::new(),
645                    ));
646
647                    if wants_measure {
648                        let theme = Theme::global(&*cx.app);
649                        let measure_layout = decl_style::layout_style(
650                            theme,
651                            collapsible_measurement_wrapper_refinement(),
652                        );
653                        let measurer = cx.keyed("collapsible-measure", |cx| {
654                            cx.container(
655                                ContainerProps {
656                                    layout: measure_layout,
657                                    ..Default::default()
658                                },
659                                |cx| {
660                                    vec![cx.opacity_props(
661                                        OpacityProps {
662                                            layout: LayoutStyle::default(),
663                                            opacity: 0.0,
664                                        },
665                                        |cx| {
666                                            vec![cx.container(
667                                                ContainerProps {
668                                                    layout: content_layout,
669                                                    ..Default::default()
670                                                },
671                                                |_cx| Vec::new(),
672                                            )]
673                                        },
674                                    )]
675                                },
676                            )
677                        });
678                        let measurer_id = measurer.id;
679                        children.push(measurer);
680
681                        // Update from last-frame bounds of the measurement element.
682                        let _ = update_measured_size_from_element_if_open_for(
683                            cx,
684                            state_id,
685                            measurer_id,
686                            is_open,
687                        );
688                    }
689
690                    if should_render_wrapper {
691                        let theme = Theme::global(&*cx.app);
692                        let wrapper_layout = decl_style::layout_style(theme, wrapper);
693                        let wrapper_el = cx.keyed("collapsible-wrapper", |cx| {
694                            cx.container(
695                                ContainerProps {
696                                    layout: wrapper_layout,
697                                    ..Default::default()
698                                },
699                                |cx| {
700                                    vec![cx.container(
701                                        ContainerProps {
702                                            layout: content_layout,
703                                            ..Default::default()
704                                        },
705                                        |_cx| Vec::new(),
706                                    )]
707                                },
708                            )
709                        });
710                        wrapper_id_out.set(Some(wrapper_el.id));
711                        children.push(wrapper_el);
712                    }
713
714                    children
715                },
716            );
717            ui.set_root(root);
718            ui.layout_all(app, services, bounds, 1.0);
719        };
720
721        // Closed frame.
722        render(&mut ui, &mut app, &mut services);
723
724        let _ = app.models_mut().update(&open, |v| *v = true);
725
726        // Measurement + early animation frames.
727        let mut wrapper_id = None;
728        let mut saw_partial_height = false;
729        for _ in 0..8 {
730            render(&mut ui, &mut app, &mut services);
731            wrapper_id = wrapper_id.or_else(|| wrapper_id_out.get());
732            let Some(wrapper_id) = wrapper_id else {
733                continue;
734            };
735            let Some(wrapper_bounds) =
736                fret_ui::elements::bounds_for_element(&mut app, window, wrapper_id)
737            else {
738                continue;
739            };
740
741            if wrapper_bounds.size.height.0 > 0.0 && wrapper_bounds.size.height.0 < 80.0 {
742                saw_partial_height = true;
743                break;
744            }
745        }
746        assert!(
747            saw_partial_height,
748            "expected an intermediate animated height"
749        );
750        let wrapper_id = wrapper_id.expect("wrapper id");
751
752        // Advance frames until the wrapper reaches its final height.
753        //
754        // Note: `bounds_for_element` intentionally returns the *previous* frame's bounds. If we
755        // keep producing frames after the animation settles (without any invalidations), the
756        // runtime may stop recording bounds and this query can return `None`. Real apps typically
757        // stop producing frames once the transition settles, so this test stops as soon as it
758        // observes the final height.
759        let mut settled = false;
760        for _ in 0..16 {
761            render(&mut ui, &mut app, &mut services);
762            let Some(wrapper_bounds) =
763                fret_ui::elements::bounds_for_element(&mut app, window, wrapper_id)
764            else {
765                continue;
766            };
767            if (wrapper_bounds.size.height.0 - 80.0).abs() <= 0.5 {
768                settled = true;
769                break;
770            }
771        }
772        assert!(settled, "expected wrapper to reach its final height");
773    }
774}