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