Skip to main content

waterui_layout/containers/
frame.rs

1//! Reactive frame constraints for overriding a child's incoming proposal.
2
3use alloc::{collections::BTreeSet, vec, vec::Vec};
4use nami::{Computed, Signal, SignalExt};
5use waterui_core::{
6    AnyView, IntoSignalF32, View,
7    layout::{LayoutInvalidationCallback, StretchAxis},
8};
9
10use crate::{
11    Layout, PlacedSubview, Point, ProposalSize, Rect, Size, SubView, ViewDimensions,
12    container::FixedContainer,
13    stack::{Alignment, HorizontalAlignment, VerticalAlignment},
14};
15
16/// Layout that clamps a single child's proposal to reactive frame constraints.
17#[derive(Debug, Clone, Default)]
18pub struct FrameLayout {
19    min_width: Option<Computed<f32>>,
20    ideal_width: Option<Computed<f32>>,
21    max_width: Option<Computed<f32>>,
22    min_height: Option<Computed<f32>>,
23    ideal_height: Option<Computed<f32>>,
24    max_height: Option<Computed<f32>>,
25    alignment: Alignment,
26}
27
28#[derive(Clone, Copy)]
29struct ResolvedFrameLayout {
30    min_width: Option<f32>,
31    ideal_width: Option<f32>,
32    max_width: Option<f32>,
33    min_height: Option<f32>,
34    ideal_height: Option<f32>,
35    max_height: Option<f32>,
36}
37
38impl FrameLayout {
39    fn resolved(&self) -> ResolvedFrameLayout {
40        ResolvedFrameLayout {
41            min_width: self.min_width.as_ref().map(Signal::get),
42            ideal_width: self.ideal_width.as_ref().map(Signal::get),
43            max_width: self.max_width.as_ref().map(Signal::get),
44            min_height: self.min_height.as_ref().map(Signal::get),
45            ideal_height: self.ideal_height.as_ref().map(Signal::get),
46            max_height: self.max_height.as_ref().map(Signal::get),
47        }
48    }
49}
50
51impl Layout for FrameLayout {
52    /// A frame whose `max` on an axis is infinite is greedy on that axis —
53    /// `SwiftUI`'s `.frame(maxWidth: .infinity)`. With stacks content-sized,
54    /// this (plus `Spacer`/`Color`) is how a view opts into filling its
55    /// container.
56    fn stretch_axis(&self, _children: &[StretchAxis]) -> StretchAxis {
57        let resolved = self.resolved();
58        let horizontal = resolved.max_width.is_some_and(f32::is_infinite);
59        let vertical = resolved.max_height.is_some_and(f32::is_infinite);
60        match (horizontal, vertical) {
61            (true, true) => StretchAxis::Both,
62            (true, false) => StretchAxis::Horizontal,
63            (false, true) => StretchAxis::Vertical,
64            (false, false) => StretchAxis::None,
65        }
66    }
67
68    fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
69        let resolved = self.resolved();
70        // A Frame proposes a modified size to its single child. The parent's
71        // proposal is what the child hears; an ideal only fills in a dimension
72        // the parent left unspecified. Either way the frame's own min/max limit
73        // what it passes down.
74        let child_proposal = ProposalSize {
75            width: frame_child_proposal_axis(
76                proposal.width,
77                resolved.min_width,
78                resolved.ideal_width,
79                resolved.max_width,
80            ),
81            height: frame_child_proposal_axis(
82                proposal.height,
83                resolved.min_height,
84                resolved.ideal_height,
85                resolved.max_height,
86            ),
87        };
88
89        // Measure the child with our constrained proposal
90        let child_dimensions = children
91            .first()
92            .map_or(ViewDimensions::new(Size::zero()), |c| {
93                c.measure(child_proposal)
94            });
95        let child_size = child_dimensions.size;
96
97        // Resolve the frame size on each axis. With no bound on an axis the
98        // frame is exactly as big as the child it just measured; a maximum is
99        // something to grow into, so there the frame takes the extent it was
100        // offered instead. `place` proposes that resolved extent back to the
101        // child, so the child is measured at the extent it will be placed in.
102        let final_width = frame_resolved_axis(
103            proposal.width,
104            child_size.width,
105            resolved.min_width,
106            resolved.ideal_width,
107            resolved.max_width,
108        );
109        let final_height = frame_resolved_axis(
110            proposal.height,
111            child_size.height,
112            resolved.min_height,
113            resolved.ideal_height,
114            resolved.max_height,
115        );
116
117        Size::new(final_width, final_height)
118    }
119
120    fn place(&self, bounds: Rect, children: &[&dyn SubView]) -> Vec<Rect> {
121        if children.is_empty() {
122            return vec![];
123        }
124
125        let resolved = self.resolved();
126        // The frame's own extent is already settled by the time it is placed:
127        // `size_that_fits` resolved it and the parent handed it back as
128        // `bounds`. The child is therefore proposed that resolved extent, which
129        // is the extent it was measured at — a resizable child already reported
130        // it, and a rigid one reports the same natural size either way. The
131        // ideal gets no second say: it answers an unspecified proposal, it is
132        // not a cap on the child, so a frame that grew past its ideal
133        // (`.frame(idealWidth: 24, maxWidth: .infinity)`, or a parent that
134        // stretched it) has a child that fills it. Min/max still bind here
135        // because the frame keeps its own constraints even when a parent offers
136        // bounds that violate them.
137        let child_proposal = ProposalSize {
138            width: Some(clamp_frame_axis(
139                bounds.width(),
140                resolved.min_width,
141                resolved.max_width,
142            )),
143            height: Some(clamp_frame_axis(
144                bounds.height(),
145                resolved.min_height,
146                resolved.max_height,
147            )),
148        };
149
150        let child_dimensions = children
151            .first()
152            .map_or(ViewDimensions::new(Size::zero()), |c| {
153                c.measure(child_proposal)
154            });
155        let child_size = child_dimensions.size;
156
157        // Handle infinite dimensions (axis-expanding views)
158        let child_width = if child_size.width.is_infinite() {
159            bounds.width()
160        } else {
161            child_size.width
162        };
163
164        let child_height = if child_size.height.is_infinite() {
165            bounds.height()
166        } else {
167            child_size.height
168        };
169
170        let final_child_size = Size::new(child_width, child_height);
171        let mut adjusted_dimensions = child_dimensions;
172        adjusted_dimensions.size = final_child_size;
173
174        // Calculate the child's origin point (top-left) based on alignment.
175        let horizontal = self.alignment.horizontal();
176        let horizontal_target = if horizontal == HorizontalAlignment::Leading {
177            0.0
178        } else if horizontal == HorizontalAlignment::Trailing {
179            bounds.width()
180        } else if horizontal == HorizontalAlignment::Center {
181            bounds.width() * 0.5
182        } else {
183            adjusted_dimensions
184                .horizontal(horizontal)
185                .clamp(0.0, final_child_size.width)
186        };
187        let child_x = bounds.x() + horizontal_target
188            - adjusted_dimensions
189                .horizontal(horizontal)
190                .clamp(0.0, final_child_size.width);
191
192        let vertical = self.alignment.vertical();
193        let vertical_target = if vertical == VerticalAlignment::Top {
194            0.0
195        } else if vertical == VerticalAlignment::Bottom {
196            bounds.height()
197        } else if vertical == VerticalAlignment::Center {
198            bounds.height() * 0.5
199        } else {
200            adjusted_dimensions
201                .vertical(vertical)
202                .clamp(0.0, final_child_size.height)
203        };
204        let child_y = bounds.y() + vertical_target
205            - adjusted_dimensions
206                .vertical(vertical)
207                .clamp(0.0, final_child_size.height);
208
209        vec![Rect::new(Point::new(child_x, child_y), final_child_size)]
210    }
211
212    fn explicit_horizontal(
213        &self,
214        alignment: HorizontalAlignment,
215        _bounds: Rect,
216        children: &[PlacedSubview<'_>],
217    ) -> Option<f32> {
218        children
219            .first()
220            .and_then(|child| child.explicit_horizontal(alignment))
221    }
222
223    fn explicit_vertical(
224        &self,
225        alignment: VerticalAlignment,
226        _bounds: Rect,
227        children: &[PlacedSubview<'_>],
228    ) -> Option<f32> {
229        children
230            .first()
231            .and_then(|child| child.explicit_vertical(alignment))
232    }
233
234    fn watch_invalidation(
235        &self,
236        invalidate: LayoutInvalidationCallback,
237    ) -> Vec<nami::watcher::BoxWatcherGuard> {
238        let signals = [
239            self.min_width.as_ref(),
240            self.ideal_width.as_ref(),
241            self.max_width.as_ref(),
242            self.min_height.as_ref(),
243            self.ideal_height.as_ref(),
244            self.max_height.as_ref(),
245        ];
246        let mut identities = BTreeSet::new();
247        let mut guards = Vec::new();
248        for signal in signals.into_iter().flatten() {
249            if signal
250                .identity()
251                .is_some_and(|identity| !identities.insert(identity))
252            {
253                continue;
254            }
255            let invalidate = invalidate.clone();
256            guards.push(signal.watch(move |_| invalidate()));
257        }
258        guards
259    }
260}
261
262#[inline]
263fn clamp_frame_axis(value: f32, min: Option<f32>, max: Option<f32>) -> f32 {
264    value
265        .max(min.unwrap_or(f32::NEG_INFINITY))
266        .min(max.unwrap_or(f32::INFINITY))
267}
268
269/// The proposal a frame hands its child on one axis.
270///
271/// `SwiftUI`'s flexible frame proposes the size proposed to the frame, limited
272/// by any constraints, with any ideal dimensions replacing the *unspecified*
273/// dimensions of that proposal. So a parent that has a size in mind is the one
274/// the child hears, the ideal answers only the axes nobody had an opinion on,
275/// and the frame's own `min`/`max` bind whichever of the two came through.
276///
277/// An ideal that outranked the proposal would be a cap on the child, and a cap
278/// is what `max` is for: it would leave a resizable drawing unable to fill the
279/// frame it had already grown into.
280#[inline]
281fn frame_child_proposal_axis(
282    parent_proposal: Option<f32>,
283    min: Option<f32>,
284    ideal: Option<f32>,
285    max: Option<f32>,
286) -> Option<f32> {
287    parent_proposal
288        .or(ideal)
289        .map(|value| clamp_frame_axis(value, min, max))
290}
291
292/// Resolves the frame's own extent on one axis.
293///
294/// A frame grows into the space offered *up to* the maximum it was given, which
295/// is how `.frame(maxWidth: .infinity)` fills and `.frame(maxWidth: 100)` fills
296/// to a hundred points. The extent it grows into is the one it was offered —
297/// the parent's proposal, or the ideal when the parent proposed nothing.
298///
299/// Without a maximum there is nothing to grow into, so the frame adopts its
300/// child's sizing behaviour on that axis: it is exactly as big as the child it
301/// measured, clamped up by any minimum. That is what keeps `.frame(minHeight:
302/// 44)` on a label a 44pt-tall label rather than one stretched over whatever
303/// height the parent happened to propose.
304///
305/// It is also why an *ideal* is an answer to an unspecified proposal rather
306/// than a pin. The ideal reaches the child through
307/// [`frame_child_proposal_axis`] and comes back as the child's own answer: a
308/// child that takes what it is offered reports the ideal, a rigid one reports
309/// its natural size, and neither is overridden here. `min`/`max` are how a
310/// caller asks for a pin.
311#[inline]
312fn frame_resolved_axis(
313    parent_proposal: Option<f32>,
314    child_size: f32,
315    min: Option<f32>,
316    ideal: Option<f32>,
317    max: Option<f32>,
318) -> f32 {
319    let content = if max.is_some() {
320        parent_proposal.or(ideal).unwrap_or(child_size)
321    } else {
322        child_size
323    };
324    clamp_frame_axis(content, min, max)
325}
326
327/// A view that provides a frame with optional size constraints and alignment for its child.
328///
329/// The Frame view allows you to specify minimum, ideal, and maximum dimensions
330/// for width and height, and controls how the child is aligned within the frame.
331#[derive(Debug)]
332pub struct Frame {
333    layout: FrameLayout,
334    content: AnyView,
335}
336
337impl Frame {
338    /// Creates a new Frame with the specified content and alignment.
339    ///
340    /// # Arguments
341    /// * `content` - The child view to be contained within the frame
342    /// * `alignment` - How the child should be aligned within the frame
343    #[must_use]
344    pub fn new(content: impl View) -> Self {
345        Self {
346            layout: FrameLayout::default(),
347            content: AnyView::new(content),
348        }
349    }
350
351    /// Sets the alignment of the child within the frame.
352    ///
353    /// # Arguments
354    /// * `alignment` - The alignment to apply to the child view
355    #[must_use]
356    pub const fn alignment(mut self, alignment: Alignment) -> Self {
357        self.layout.alignment = alignment;
358        self
359    }
360
361    /// Sets the ideal width of the frame.
362    ///
363    /// Accepts any numeric literal or signal of f32 (`f32`, `f64`, `i32`,
364    /// `Computed<f32>`, `Binding<f32>`, …). Signal changes invalidate only this
365    /// frame's native layout.
366    #[must_use]
367    pub fn width(mut self, width: impl IntoSignalF32 + 'static) -> Self {
368        let width = width.into_signal_f32().computed();
369        self.layout.min_width = Some(width.clone());
370        self.layout.ideal_width = Some(width.clone());
371        self.layout.max_width = Some(width);
372        self
373    }
374
375    /// Sets the ideal height of the frame.
376    #[must_use]
377    pub fn height(mut self, height: impl IntoSignalF32 + 'static) -> Self {
378        let height = height.into_signal_f32().computed();
379        self.layout.min_height = Some(height.clone());
380        self.layout.ideal_height = Some(height.clone());
381        self.layout.max_height = Some(height);
382        self
383    }
384
385    /// Sets the width the frame asks for when its parent proposes nothing.
386    ///
387    /// This is `SwiftUI`'s `.frame(idealWidth:)`. Unlike [`width`](Self::width),
388    /// which pins all three constraints, it leaves the frame free to be smaller
389    /// or larger when the parent does have a size in mind.
390    #[must_use]
391    pub fn ideal_width(mut self, width: impl IntoSignalF32 + 'static) -> Self {
392        self.layout.ideal_width = Some(width.into_signal_f32().computed());
393        self
394    }
395
396    /// Sets the height the frame asks for when its parent proposes nothing.
397    ///
398    /// This is `SwiftUI`'s `.frame(idealHeight:)`; see [`ideal_width`](Self::ideal_width).
399    #[must_use]
400    pub fn ideal_height(mut self, height: impl IntoSignalF32 + 'static) -> Self {
401        self.layout.ideal_height = Some(height.into_signal_f32().computed());
402        self
403    }
404
405    /// Sets the minimum width of the frame.
406    #[must_use]
407    pub fn min_width(mut self, width: impl IntoSignalF32 + 'static) -> Self {
408        self.layout.min_width = Some(width.into_signal_f32().computed());
409        self
410    }
411
412    /// Sets the maximum width of the frame.
413    #[must_use]
414    pub fn max_width(mut self, width: impl IntoSignalF32 + 'static) -> Self {
415        self.layout.max_width = Some(width.into_signal_f32().computed());
416        self
417    }
418
419    /// Sets the minimum height of the frame.
420    #[must_use]
421    pub fn min_height(mut self, height: impl IntoSignalF32 + 'static) -> Self {
422        self.layout.min_height = Some(height.into_signal_f32().computed());
423        self
424    }
425
426    /// Sets the maximum height of the frame.
427    #[must_use]
428    pub fn max_height(mut self, height: impl IntoSignalF32 + 'static) -> Self {
429        self.layout.max_height = Some(height.into_signal_f32().computed());
430        self
431    }
432}
433
434impl View for Frame {
435    fn body(self, _env: &waterui_core::Environment) -> impl View {
436        // The Frame view's body is just a Container with our custom layout and the child content.
437        FixedContainer::new(self.layout, vec![self.content])
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::StretchAxis;
445    use alloc::rc::Rc;
446    use core::cell::Cell;
447    use nami::{SignalExt, binding};
448
449    struct MockSubView {
450        size: Size,
451    }
452
453    impl SubView for MockSubView {
454        fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
455            ViewDimensions::new(self.size)
456        }
457        fn stretch_axis(&self) -> StretchAxis {
458            StretchAxis::None
459        }
460        fn priority(&self) -> i32 {
461            0
462        }
463    }
464
465    /// A greedy child: it takes whatever extent it is offered, the way a
466    /// `Color` or a fill-scaled image does, and falls back to its intrinsic
467    /// size on an axis nobody proposed.
468    struct FillingSubView {
469        intrinsic: Size,
470    }
471
472    impl SubView for FillingSubView {
473        fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
474            ViewDimensions::new(Size::new(
475                proposal.width.unwrap_or(self.intrinsic.width),
476                proposal.height.unwrap_or(self.intrinsic.height),
477            ))
478        }
479        fn stretch_axis(&self) -> StretchAxis {
480            StretchAxis::Both
481        }
482        fn priority(&self) -> i32 {
483            0
484        }
485    }
486
487    /// Wraps a child so a test can see the proposal the frame handed it.
488    struct RecordingSubView<C> {
489        inner: C,
490        proposal: Cell<Option<ProposalSize>>,
491    }
492
493    impl<C> RecordingSubView<C> {
494        fn new(inner: C) -> Self {
495            Self {
496                inner,
497                proposal: Cell::new(None),
498            }
499        }
500
501        fn proposal(&self) -> ProposalSize {
502            self.proposal
503                .get()
504                .expect("the frame never measured its child")
505        }
506    }
507
508    impl<C: SubView> SubView for RecordingSubView<C> {
509        fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
510            self.proposal.set(Some(proposal));
511            self.inner.measure(proposal)
512        }
513        fn stretch_axis(&self) -> StretchAxis {
514            self.inner.stretch_axis()
515        }
516        fn priority(&self) -> i32 {
517            self.inner.priority()
518        }
519    }
520
521    fn assert_extent(actual: f32, expected: f32, what: &str) {
522        assert!(
523            (actual - expected).abs() < f32::EPSILON,
524            "expected {what} to be {expected}, got {actual}"
525        );
526    }
527
528    #[test]
529    fn an_ideal_frame_still_adopts_a_rigid_child_size() {
530        // `.frame(idealWidth: 100, idealHeight: 50)` with nothing proposed. The
531        // ideal is what the child is asked for, but with neither a minimum nor
532        // a maximum the frame has no size of its own — it is whatever the child
533        // answered, and a rigid 30x20 drawing answers 30x20.
534        let layout = FrameLayout {
535            ideal_width: Some(Computed::constant(100.0)),
536            ideal_height: Some(Computed::constant(50.0)),
537            ..Default::default()
538        };
539
540        let child = RecordingSubView::new(MockSubView {
541            size: Size::new(30.0, 20.0),
542        });
543
544        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
545
546        assert_eq!(
547            child.proposal(),
548            ProposalSize::new(Some(100.0), Some(50.0)),
549            "the ideal should answer the dimensions the parent left unspecified"
550        );
551        assert_extent(size.width, 30.0, "the frame's width");
552        assert_extent(size.height, 20.0, "the frame's height");
553    }
554
555    #[test]
556    fn an_ideal_answers_only_the_dimensions_the_parent_left_unspecified() {
557        // `.frame(idealWidth: 24, idealHeight: 24)` under a 48x36 proposal. The
558        // parent has an opinion on both dimensions, so the ideal has nothing to
559        // say: the child hears 48x36. With neither a minimum nor a maximum the
560        // frame then adopts its child's sizing behaviour — a resizable drawing
561        // takes the whole 48x36, a rigid one stays at its own 24x24 — which is
562        // what `SwiftUI` gives for the same frame.
563        let layout = FrameLayout {
564            ideal_width: Some(Computed::constant(24.0)),
565            ideal_height: Some(Computed::constant(24.0)),
566            ..Default::default()
567        };
568        let proposal = ProposalSize::new(Some(48.0), Some(36.0));
569
570        let resizable = RecordingSubView::new(FillingSubView {
571            intrinsic: Size::new(24.0, 24.0),
572        });
573        let filled = layout.size_that_fits(proposal, &[&resizable]);
574        assert_eq!(
575            resizable.proposal(),
576            proposal,
577            "the child should hear the parent's proposal, not the ideal"
578        );
579        assert_extent(filled.width, 48.0, "a resizable child's frame width");
580        assert_extent(filled.height, 36.0, "a resizable child's frame height");
581
582        // Placement agrees with measurement by construction: the frame proposes
583        // the extent it resolved to, which is the extent the child was measured
584        // at and reported back.
585        let rects = layout.place(Rect::new(Point::new(0.0, 0.0), filled), &[&resizable]);
586        assert_eq!(
587            resizable.proposal(),
588            proposal,
589            "placement should re-propose the extent the child was measured at"
590        );
591        assert_extent(rects[0].width(), 48.0, "the resizable child's width");
592        assert_extent(rects[0].height(), 36.0, "the resizable child's height");
593
594        let rigid = RecordingSubView::new(MockSubView {
595            size: Size::new(24.0, 24.0),
596        });
597        let hugged = layout.size_that_fits(proposal, &[&rigid]);
598        assert_eq!(
599            rigid.proposal(),
600            proposal,
601            "a rigid child hears the proposal too; it just declines it"
602        );
603        assert_extent(hugged.width, 24.0, "a rigid child's frame width");
604        assert_extent(hugged.height, 24.0, "a rigid child's frame height");
605    }
606
607    #[test]
608    fn a_rigid_child_does_not_grow_into_a_larger_proposal() {
609        // The same frame around a rigid 24pt drawing offered 400pt: the drawing
610        // declines, and with no maximum the frame has nothing to grow into. A
611        // resizable child would take the 400 — see the test above.
612        let layout = FrameLayout {
613            ideal_width: Some(Computed::constant(24.0)),
614            ..Default::default()
615        };
616
617        let child = MockSubView {
618            size: Size::new(24.0, 24.0),
619        };
620
621        let size = layout.size_that_fits(ProposalSize::new(Some(400.0), None), &[&child]);
622
623        assert_extent(size.width, 24.0, "the frame's width");
624    }
625
626    #[test]
627    fn test_frame_alignment() {
628        let layout = FrameLayout {
629            alignment: Alignment::BottomTrailing,
630            ..Default::default()
631        };
632
633        let mut child = MockSubView {
634            size: Size::new(30.0, 20.0),
635        };
636        let children: Vec<&dyn SubView> = vec![&mut child];
637
638        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(100.0, 100.0));
639        let rects = layout.place(bounds, &children);
640
641        // Child should be at bottom-trailing corner
642        assert!((rects[0].x() - 70.0).abs() < f32::EPSILON); // 100 - 30
643        assert!((rects[0].y() - 80.0).abs() < f32::EPSILON); // 100 - 20
644    }
645
646    #[test]
647    fn test_fixed_width_resists_zero_min_query() {
648        let layout = FrameLayout {
649            min_width: Some(Computed::constant(120.0)),
650            ideal_width: Some(Computed::constant(120.0)),
651            max_width: Some(Computed::constant(120.0)),
652            ..Default::default()
653        };
654
655        let mut child = MockSubView {
656            size: Size::new(30.0, 20.0),
657        };
658        let children: Vec<&dyn SubView> = vec![&mut child];
659
660        let size = layout.size_that_fits(ProposalSize::ZERO, &children);
661        assert!((size.width - 120.0).abs() < f32::EPSILON);
662    }
663
664    #[test]
665    fn test_min_width_clamps_zero_min_query() {
666        let layout = FrameLayout {
667            min_width: Some(Computed::constant(64.0)),
668            ..Default::default()
669        };
670
671        let mut child = MockSubView {
672            size: Size::new(10.0, 10.0),
673        };
674        let children: Vec<&dyn SubView> = vec![&mut child];
675
676        let size = layout.size_that_fits(ProposalSize::ZERO, &children);
677        assert!((size.width - 64.0).abs() < f32::EPSILON);
678    }
679
680    #[test]
681    fn reactive_width_invalidates_and_updates_measurement() {
682        let width = binding(80.0_f32);
683        let signal = width.computed();
684        let layout = FrameLayout {
685            min_width: Some(signal.clone()),
686            ideal_width: Some(signal.clone()),
687            max_width: Some(signal),
688            ..Default::default()
689        };
690        let invalidations = Rc::new(Cell::new(0));
691        let callback_invalidations = Rc::clone(&invalidations);
692        let _guards = layout.watch_invalidation(Rc::new(move || {
693            callback_invalidations.set(callback_invalidations.get() + 1);
694        }));
695
696        width.set(120.0);
697        assert_eq!(invalidations.get(), 1);
698
699        let child = MockSubView {
700            size: Size::new(10.0, 10.0),
701        };
702        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
703        assert_eq!(size.width, 120.0);
704    }
705
706    #[test]
707    fn a_minimum_alone_does_not_make_the_frame_greedy() {
708        // `.frame(minWidth: 50)` on a 30pt child inside a 300pt parent is a 50pt
709        // frame, not a 300pt one: with no maximum there is nothing to grow into.
710        let layout = FrameLayout {
711            min_width: Some(Computed::constant(50.0)),
712            ..Default::default()
713        };
714
715        let child = MockSubView {
716            size: Size::new(30.0, 20.0),
717        };
718        let size = layout.size_that_fits(ProposalSize::new(Some(300.0), None), &[&child]);
719
720        assert!(
721            (size.width - 50.0).abs() < f32::EPSILON,
722            "expected the frame to stay at its minimum, got {}",
723            size.width
724        );
725    }
726
727    #[test]
728    fn a_bounded_maximum_fills_up_to_that_maximum() {
729        // `.frame(maxWidth: 100)` does grow into the offer, stopping at 100.
730        let layout = FrameLayout {
731            max_width: Some(Computed::constant(100.0)),
732            ..Default::default()
733        };
734
735        let child = MockSubView {
736            size: Size::new(30.0, 20.0),
737        };
738        let size = layout.size_that_fits(ProposalSize::new(Some(300.0), None), &[&child]);
739
740        assert!(
741            (size.width - 100.0).abs() < f32::EPSILON,
742            "expected the frame to fill to its maximum, got {}",
743            size.width
744        );
745    }
746
747    #[test]
748    fn an_infinite_maximum_takes_the_whole_offer() {
749        let layout = FrameLayout {
750            max_width: Some(Computed::constant(f32::INFINITY)),
751            ..Default::default()
752        };
753
754        let child = MockSubView {
755            size: Size::new(30.0, 20.0),
756        };
757        let size = layout.size_that_fits(ProposalSize::new(Some(300.0), None), &[&child]);
758
759        assert!(
760            (size.width - 300.0).abs() < f32::EPSILON,
761            "expected the frame to fill the proposal, got {}",
762            size.width
763        );
764    }
765
766    #[test]
767    fn an_ideal_size_is_what_an_unconstrained_parent_gets() {
768        let layout = FrameLayout {
769            ideal_width: Some(Computed::constant(120.0)),
770            ..Default::default()
771        };
772
773        let child = FillingSubView {
774            intrinsic: Size::new(30.0, 20.0),
775        };
776
777        // Nothing proposed: the ideal is what the child is asked for, and a
778        // child that takes what it is offered hands the ideal back.
779        let unconstrained = layout.size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
780        assert_extent(
781            unconstrained.width,
782            120.0,
783            "the unconstrained frame's width",
784        );
785
786        // A parent that does have a size in mind is heard instead: the ideal
787        // replaces unspecified dimensions only, and it is no more a cap on the
788        // child than it is a pin, so a resizable child fills the 300 offered.
789        let proposed = layout.size_that_fits(ProposalSize::new(Some(300.0), Some(90.0)), &[&child]);
790        assert_extent(proposed.width, 300.0, "the proposed frame's width");
791        assert_extent(proposed.height, 90.0, "the proposed frame's height");
792    }
793
794    #[test]
795    fn an_ideal_does_not_cap_the_child_once_the_frame_has_grown() {
796        // `.frame(idealWidth: 100, idealHeight: 50, maxWidth: .infinity,
797        // maxHeight: .infinity)` in a 320x180 slot. The ideal answered the
798        // sizing question; the maximum then let the frame fill, and the child
799        // is proposed the frame it actually got, not the ideal.
800        let layout = FrameLayout {
801            ideal_width: Some(Computed::constant(100.0)),
802            ideal_height: Some(Computed::constant(50.0)),
803            max_width: Some(Computed::constant(f32::INFINITY)),
804            max_height: Some(Computed::constant(f32::INFINITY)),
805            ..Default::default()
806        };
807
808        let child = RecordingSubView::new(FillingSubView {
809            intrinsic: Size::new(30.0, 20.0),
810        });
811
812        let size = layout.size_that_fits(ProposalSize::new(Some(320.0), Some(180.0)), &[&child]);
813        assert_extent(size.width, 320.0, "the frame's width");
814        assert_extent(size.height, 180.0, "the frame's height");
815
816        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(320.0, 180.0));
817        let rects = layout.place(bounds, &[&child]);
818
819        assert_eq!(
820            child.proposal(),
821            ProposalSize::new(Some(320.0), Some(180.0)),
822            "the child should be proposed the frame's resolved bounds"
823        );
824        assert_extent(rects[0].width(), 320.0, "the child's width");
825        assert_extent(rects[0].height(), 180.0, "the child's height");
826    }
827
828    #[test]
829    fn an_unconstrained_frame_takes_its_ideal_and_hands_that_to_the_child() {
830        // Nobody proposes anything, so the ideal is the frame — and the child
831        // hears the ideal because that is what the frame resolved to.
832        let layout = FrameLayout {
833            ideal_width: Some(Computed::constant(100.0)),
834            ideal_height: Some(Computed::constant(50.0)),
835            ..Default::default()
836        };
837
838        let child = RecordingSubView::new(FillingSubView {
839            intrinsic: Size::new(30.0, 20.0),
840        });
841
842        let size = layout.size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
843        assert_extent(size.width, 100.0, "the frame's width");
844        assert_extent(size.height, 50.0, "the frame's height");
845
846        let rects = layout.place(Rect::new(Point::new(0.0, 0.0), size), &[&child]);
847        assert_eq!(
848            child.proposal(),
849            ProposalSize::new(Some(100.0), Some(50.0)),
850            "the child should be proposed the ideal the frame resolved to"
851        );
852        assert_extent(rects[0].width(), 100.0, "the child's width");
853        assert_extent(rects[0].height(), 50.0, "the child's height");
854    }
855
856    #[test]
857    fn placement_holds_the_frames_own_minimum_and_maximum() {
858        // `.frame(minWidth: 120, maxHeight: 40)` handed an 80x300 slot: the
859        // frame's own constraints still bind on the proposal it passes down,
860        // so the child hears 120 wide and 40 tall rather than the raw bounds.
861        let layout = FrameLayout {
862            min_width: Some(Computed::constant(120.0)),
863            max_height: Some(Computed::constant(40.0)),
864            ..Default::default()
865        };
866
867        let child = RecordingSubView::new(FillingSubView {
868            intrinsic: Size::new(30.0, 20.0),
869        });
870
871        let bounds = Rect::new(Point::new(0.0, 0.0), Size::new(80.0, 300.0));
872        layout.place(bounds, &[&child]);
873
874        assert_eq!(
875            child.proposal(),
876            ProposalSize::new(Some(120.0), Some(40.0)),
877            "the child's proposal should be the bounds clamped by the frame"
878        );
879    }
880
881    #[test]
882    fn an_ideal_that_is_also_the_maximum_is_a_natural_size_that_only_shrinks() {
883        // The shape `waterui-svg` carries an icon's intrinsic size in:
884        // `.frame(idealWidth: 24, idealHeight: 24, maxWidth: 24, maxHeight: 24)`
885        // around a scene that takes whatever it is proposed. The ideal answers
886        // the axes nobody proposed, the maximum keeps the drawing from growing
887        // past its natural size, and the absence of a minimum leaves it free to
888        // shrink into a smaller box.
889        let layout = FrameLayout {
890            ideal_width: Some(Computed::constant(24.0)),
891            ideal_height: Some(Computed::constant(24.0)),
892            max_width: Some(Computed::constant(24.0)),
893            max_height: Some(Computed::constant(24.0)),
894            ..Default::default()
895        };
896        let scene = || {
897            RecordingSubView::new(FillingSubView {
898                intrinsic: Size::zero(),
899            })
900        };
901
902        // Nothing proposed: the natural size.
903        let child = scene();
904        let natural = layout.size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
905        assert_extent(natural.width, 24.0, "the unproposed width");
906        assert_extent(natural.height, 24.0, "the unproposed height");
907
908        // A row that proposes its own height — `HStack` hands a non-stretching
909        // child `(None, bounds.height())` — leaves the icon at 24, not 44.
910        let child = scene();
911        let in_a_row = layout.size_that_fits(ProposalSize::new(None, Some(44.0)), &[&child]);
912        assert_eq!(
913            child.proposal(),
914            ProposalSize::new(Some(24.0), Some(24.0)),
915            "the scene should never be offered more than the natural size"
916        );
917        assert_extent(in_a_row.width, 24.0, "the width in a taller row");
918        assert_extent(in_a_row.height, 24.0, "the height in a taller row");
919
920        // `.size(8, 10)` still gets through: there is no minimum to stop it.
921        let child = scene();
922        let resized = layout.size_that_fits(ProposalSize::new(Some(8.0), Some(10.0)), &[&child]);
923        assert_eq!(
924            child.proposal(),
925            ProposalSize::new(Some(8.0), Some(10.0)),
926            "a smaller box should reach the scene unaltered"
927        );
928        assert_extent(resized.width, 8.0, "the resized width");
929        assert_extent(resized.height, 10.0, "the resized height");
930    }
931
932    #[test]
933    fn a_filling_frame_offers_its_whole_width_to_a_rigid_child() {
934        // `.frame(maxWidth: .infinity)` around a 30x20 icon in a 320x180 slot.
935        // The frame fills the width and stays the icon's height; the icon is
936        // offered all of that width, declines it, and is centred in it.
937        let layout = FrameLayout {
938            max_width: Some(Computed::constant(f32::INFINITY)),
939            ..Default::default()
940        };
941
942        let child = RecordingSubView::new(MockSubView {
943            size: Size::new(30.0, 20.0),
944        });
945
946        let size = layout.size_that_fits(ProposalSize::new(Some(320.0), Some(180.0)), &[&child]);
947        assert_extent(size.width, 320.0, "the frame's width");
948        assert_extent(size.height, 20.0, "the frame's height");
949
950        let rects = layout.place(Rect::new(Point::new(0.0, 0.0), size), &[&child]);
951        assert_eq!(
952            child.proposal(),
953            ProposalSize::new(Some(320.0), Some(20.0)),
954            "a rigid child is still offered the frame it sits in"
955        );
956        assert_extent(rects[0].width(), 30.0, "the child's width");
957        assert_extent(rects[0].x(), 145.0, "the child's x");
958    }
959}