Skip to main content

frus_widgets/
layoutbuilder.rs

1//! [`LayoutBuilder`]: builds its content **from the box actually available** — the
2//! most powerful responsive primitive, since a component adapts wherever it is placed,
3//! in a narrow sidebar or full screen, and not merely to the window.
4//!
5//! As with a virtualised list item, the content is **built on the fly** every frame, so
6//! it has **no retained state**: hover and clicks work, but persistent keyboard focus
7//! and deferred overlays do not.
8//!
9//! The sizing contract: **as big as what it built**, on any axis its style leaves open.
10//! That is the reference's rule — `size = constraints.constrain(child.size)` — and it is
11//! what lets a `LayoutBuilder` be dropped into a column without having to guess a height
12//! for it first.
13//!
14//! It works because the widget is a **measured** leaf rather than a plain one: the
15//! layout engine calls back during the computation, with the space actually available,
16//! at the same point the reference runs its layout callback. The closure builds the
17//! subtree, lays it out in a tree of its own, and returns its size.
18//!
19//! Three consequences worth knowing:
20//!
21//! - **A pinned axis stays pinned.** `width`, `height` and `flex` still win, because a
22//!   dimension the style already gives is one the engine never asks about.
23//! - **The closure runs more than once a frame** — once for the measurement, once for
24//!   the paint, and again for each intrinsic question the engine asks. It must therefore
25//!   be cheap and free of side effects, which it must be anyway: it is called with no
26//!   retained state.
27//! - **A subtree holding one is not layout-cached.** The relayout cache reuses geometry
28//!   when a fingerprint of the styles and the structure has not moved, and a closure has
29//!   no fingerprint: two frames that look identical to it can still want different
30//!   boxes. So the root is recomputed every frame rather than risk a stale one.
31
32use frus_core::{Rect, Scene, Size};
33use frus_layout::{Dimension, Style};
34
35use crate::interaction::Status;
36use crate::theme::Theme;
37use crate::widget::Widget;
38
39/// Builds its child from the available size (`size → widget`).
40pub struct LayoutBuilder<Msg = crate::callback::Callback> {
41    width: Dimension,
42    height: Dimension,
43    flex_grow: f32,
44    build: Box<dyn Fn(Size) -> Box<dyn Widget<Msg>>>,
45}
46
47impl<Msg> LayoutBuilder<Msg> {
48    /// Builds from a `size → widget` factory. The size received is the **real box**
49    /// allocated to this `LayoutBuilder`, in logical px.
50    pub fn new<W: Widget<Msg> + 'static>(build: impl Fn(Size) -> W + 'static) -> Self {
51        Self {
52            width: Dimension::Auto,
53            height: Dimension::Auto,
54            flex_grow: 0.0,
55            build: Box::new(move |size| Box::new(build(size)) as Box<dyn Widget<Msg>>),
56        }
57    }
58
59    /// Sets the width, in logical pixels.
60    pub fn width(mut self, width: f32) -> Self {
61        self.width = Dimension::Length(width);
62        self
63    }
64
65    /// Sets the height, in logical pixels.
66    pub fn height(mut self, height: f32) -> Self {
67        self.height = Dimension::Length(height);
68        self
69    }
70
71    /// Flex growth factor along the parent's main axis.
72    pub fn flex(mut self, grow: f32) -> Self {
73        self.flex_grow = grow;
74        self
75    }
76}
77
78impl<Msg> Widget<Msg> for LayoutBuilder<Msg> {
79    fn style(&self) -> Style {
80        Style {
81            width: self.width,
82            height: self.height,
83            flex_grow: self.flex_grow,
84            ..Default::default()
85        }
86    }
87
88    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
89        &[]
90    }
91
92    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
93
94    fn on_click(&self) -> Option<Msg> {
95        None
96    }
97
98    fn layout_builder(&self) -> Option<&dyn Fn(Size) -> Box<dyn Widget<Msg>>> {
99        Some(&*self.build)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{build_ui, Container, Runtime, SizeClass};
107    use frus_core::{Color, Primitive};
108
109    #[test]
110    fn receives_its_real_box() {
111        use std::cell::Cell;
112        use std::rc::Rc;
113
114        let seen = Rc::new(Cell::new(Size::new(0.0, 0.0)));
115        let probe = seen.clone();
116        let lb = LayoutBuilder::<()>::new(move |size| {
117            probe.set(size);
118            Container::<()>::new().color(Color::rgb(1.0, 0.0, 0.0))
119        })
120        .width(320.0)
121        .height(48.0);
122
123        let _ui = build_ui(
124            &lb,
125            Size::new(800.0, 600.0),
126            &Runtime::default(),
127            &Theme::default(),
128        );
129        // The factory receives the LayoutBuilder's box, not the window's.
130        assert_eq!(seen.get(), Size::new(320.0, 48.0));
131    }
132
133    /// The reference's rule: with no height of its own, the box is as tall as what the
134    /// closure built. Before milestone 355 this laid out 0 px tall and the widget's own
135    /// documentation told you to go and find a number for it.
136    #[test]
137    fn it_is_as_big_as_what_it_built() {
138        let lb = LayoutBuilder::<()>::new(|_| {
139            Container::<()>::new()
140                .height(60.0)
141                .color(Color::rgb(1.0, 0.0, 0.0))
142        });
143        let root = crate::Flex::<()>::column()
144            .width(200.0)
145            .height(400.0)
146            .child(lb);
147        let ui = build_ui(
148            &root,
149            Size::new(200.0, 400.0),
150            &Runtime::default(),
151            &Theme::default(),
152        );
153        let painted = ui
154            .scene()
155            .primitives()
156            .iter()
157            .find_map(|p| match p {
158                Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
159                    Some(*rect)
160                }
161                _ => None,
162            })
163            .expect("the built content is painted");
164        assert_eq!(
165            painted.height, 60.0,
166            "the box is its content's: {painted:?}"
167        );
168    }
169
170    /// A sibling after it is pushed down by what it built — which is the point: the box
171    /// is real, not a zero-height hole the rest of the column lays out through.
172    #[test]
173    fn what_it_built_pushes_its_siblings_down() {
174        let column = crate::Flex::<()>::column()
175            .width(200.0)
176            .height(400.0)
177            .child(LayoutBuilder::<()>::new(|_| {
178                Container::<()>::new().height(60.0)
179            }))
180            .child(
181                Container::<()>::new()
182                    .height(20.0)
183                    .color(Color::rgb(0.0, 1.0, 0.0)),
184            );
185        let ui = build_ui(
186            &column,
187            Size::new(200.0, 400.0),
188            &Runtime::default(),
189            &Theme::default(),
190        );
191        let sibling = ui
192            .scene()
193            .primitives()
194            .iter()
195            .find_map(|p| match p {
196                Primitive::Rect { rect, color, .. } if color.g > 0.9 && color.r < 0.1 => {
197                    Some(*rect)
198                }
199                _ => None,
200            })
201            .expect("the sibling is painted");
202        assert_eq!(sibling.y, 60.0, "it starts below the built content");
203    }
204
205    /// An axis the style pins is still the style's: the engine never asks about a
206    /// dimension it already knows, so every existing `height(...)` behaves as it did.
207    #[test]
208    fn a_pinned_axis_is_still_the_style_s() {
209        let lb = LayoutBuilder::<()>::new(|_| {
210            Container::<()>::new()
211                .height(60.0)
212                .color(Color::rgb(1.0, 0.0, 0.0))
213        })
214        .height(120.0);
215        let root = crate::Flex::<()>::column()
216            .width(200.0)
217            .height(400.0)
218            .child(lb)
219            .child(
220                Container::<()>::new()
221                    .height(20.0)
222                    .color(Color::rgb(0.0, 1.0, 0.0)),
223            );
224        let ui = build_ui(
225            &root,
226            Size::new(200.0, 400.0),
227            &Runtime::default(),
228            &Theme::default(),
229        );
230        let sibling = ui
231            .scene()
232            .primitives()
233            .iter()
234            .find_map(|p| match p {
235                Primitive::Rect { rect, color, .. } if color.g > 0.9 && color.r < 0.1 => {
236                    Some(*rect)
237                }
238                _ => None,
239            })
240            .expect("the sibling is painted");
241        assert_eq!(sibling.y, 120.0, "the style's 120, not the content's 60");
242    }
243
244    #[test]
245    fn adapts_content_to_available_width() {
246        // Picks a number of tiles from the box's size class.
247        let count_tiles = |window_w: f32| {
248            let lb = LayoutBuilder::<()>::new(|size| {
249                let tiles = match SizeClass::from_width(size.width) {
250                    SizeClass::Compact => 1,
251                    SizeClass::Medium => 2,
252                    SizeClass::Expanded => 3,
253                };
254                let mut row = crate::Flex::<()>::row();
255                for _ in 0..tiles {
256                    row = row.child(
257                        Container::<()>::new()
258                            .width(10.0)
259                            .height(10.0)
260                            .color(Color::WHITE),
261                    );
262                }
263                row
264            })
265            .height(20.0);
266            // The root column is sized, so the stretch gives the LayoutBuilder the
267            // window's width and its box reflects the available width.
268            let root = crate::Flex::<()>::column()
269                .width(window_w)
270                .height(200.0)
271                .child(lb);
272            let ui = build_ui(
273                &root,
274                Size::new(window_w, 200.0),
275                &Runtime::default(),
276                &Theme::default(),
277            );
278            ui.scene()
279                .primitives()
280                .iter()
281                .filter(|p| matches!(p, Primitive::Rect { color, .. } if *color == Color::WHITE))
282                .count()
283        };
284        assert_eq!(count_tiles(400.0), 1);
285        assert_eq!(count_tiles(700.0), 2);
286        assert_eq!(count_tiles(1000.0), 3);
287    }
288}