1use 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#[derive(Debug, Clone)]
24pub struct MeasuredHeightMotionOutput {
25 pub state_id: GlobalElementId,
26 pub open: bool,
28 pub open_for_motion: bool,
33 pub wants_measurement: bool,
35 pub transition: TransitionOutput,
37 pub should_render: bool,
39 pub wrapper_refinement: LayoutRefinement,
41 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
75pub 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
83pub 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
91pub 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
124pub 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 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
161pub 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
173pub 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#[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 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#[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
462pub 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 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 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 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 render(&mut ui, &mut app, &mut services);
723
724 let _ = app.models_mut().update(&open, |v| *v = true);
725
726 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 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}