Skip to main content

frust_widgets/
sized.rs

1//! SizedBox layout container: forces a specific size on the given
2//! axes, passing the others through.
3//!
4//! [`SizedBoxView`]/[`SizedBoxWidget`] tighten each axis for which a `width`/
5//! `height` is set (clamped into the incoming constraints) and leave the other
6//! axis untouched. A `SizedBox` may be childless — a fixed-size spacer.
7
8use frust_core::{
9    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
10    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
11};
12use kurbo::{Point, Size};
13
14/// A declarative fixed-size box, optionally wrapping a child. See the
15/// [module docs](self).
16pub struct SizedBoxView<State: 'static> {
17    width: Option<f64>,
18    height: Option<f64>,
19    child: Option<AnyView<State>>,
20}
21
22/// A box that forces `width`/`height` where `Some`, passing through where `None`.
23///
24/// Childless by default (a spacer); attach content with [`SizedBoxView::child`].
25#[allow(non_snake_case)]
26pub fn SizedBox<State: 'static>(width: Option<f64>, height: Option<f64>) -> SizedBoxView<State> {
27    SizedBoxView {
28        width,
29        height,
30        child: None,
31    }
32}
33
34impl<State: 'static> SizedBoxView<State> {
35    /// Attach a child sized by this box.
36    pub fn child<V: View<State>>(mut self, child: V) -> Self {
37        self.child = Some(any(child));
38        self
39    }
40}
41
42/// The retained widget for a [`SizedBoxView`].
43pub struct SizedBoxWidget {
44    width: Option<f64>,
45    height: Option<f64>,
46    child: Option<ChildPod>,
47}
48
49impl<State: 'static> View<State> for SizedBoxView<State> {
50    type Element = SizedBoxWidget;
51
52    fn build(&self, ctx: &mut BuildCtx<'_>) -> SizedBoxWidget {
53        SizedBoxWidget {
54            width: self.width,
55            height: self.height,
56            child: self
57                .child
58                .as_ref()
59                .map(|view| crate::authoring::build_child(view, ctx)),
60        }
61    }
62
63    fn rebuild(
64        &self,
65        prev: &Self,
66        element: &mut SizedBoxWidget,
67        ctx: &mut BuildCtx<'_>,
68    ) -> ChangeFlags {
69        let mut flags = ChangeFlags::NONE;
70        if prev.width != self.width || prev.height != self.height {
71            element.width = self.width;
72            element.height = self.height;
73            flags |= ChangeFlags::LAYOUT;
74        }
75        match (&prev.child, &self.child, &mut element.child) {
76            (Some(prev_view), Some(next_view), Some(pod)) => {
77                flags |= crate::authoring::rebuild_child(prev_view, next_view, pod, ctx);
78            }
79            (None, Some(next_view), _) => {
80                element.child = Some(crate::authoring::build_child(next_view, ctx));
81                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
82            }
83            (Some(prev_view), None, Some(pod)) => {
84                crate::authoring::teardown_child(prev_view, pod, ctx);
85                element.child = None;
86                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
87            }
88            _ => {}
89        }
90        flags
91    }
92
93    fn teardown(&self, element: &mut SizedBoxWidget, ctx: &mut BuildCtx<'_>) {
94        if let (Some(view), Some(pod)) = (&self.child, &mut element.child) {
95            crate::authoring::teardown_child(view, pod, ctx);
96        }
97    }
98}
99
100impl Widget for SizedBoxWidget {
101    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
102        // Resolve each requested axis, clamped into the incoming constraints.
103        let target_w = self.width.map(|w| w.clamp(bc.min().width, bc.max().width));
104        let target_h = self
105            .height
106            .map(|h| h.clamp(bc.min().height, bc.max().height));
107
108        // Tighten the requested axes; pass the rest of the constraint through.
109        let child_bc = BoxConstraints::new(
110            Size::new(
111                target_w.unwrap_or(bc.min().width),
112                target_h.unwrap_or(bc.min().height),
113            ),
114            Size::new(
115                target_w.unwrap_or(bc.max().width),
116                target_h.unwrap_or(bc.max().height),
117            ),
118        );
119
120        let size = if let Some(pod) = &mut self.child {
121            let child_size = pod.layout_child(ctx, &child_bc);
122            pod.set_origin(Point::ZERO);
123            // A tightened axis wins; an untightened axis takes the child's choice.
124            Size::new(
125                target_w.unwrap_or(child_size.width),
126                target_h.unwrap_or(child_size.height),
127            )
128        } else {
129            // Childless spacer: tightened axes, else collapse to the minimum.
130            Size::new(
131                target_w.unwrap_or(bc.min().width),
132                target_h.unwrap_or(bc.min().height),
133            )
134        };
135        bc.constrain(size)
136    }
137
138    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
139        if let Some(pod) = &mut self.child {
140            pod.paint_child(ctx, scene);
141        }
142    }
143
144    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
145        match &mut self.child {
146            Some(pod) => crate::authoring::route_event_single(pod, ctx, event),
147            None => EventResult::Ignored,
148        }
149    }
150
151    fn semantics(&self, ctx: &mut SemanticsCtx) {
152        // Transparent fixed-size wrapper: forward to the child if any (a
153        // childless spacer contributes nothing).
154        if let Some(pod) = &self.child {
155            pod.semantics_child(ctx);
156        }
157    }
158
159    crate::authoring::visit_children!(child);
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::test_support::leaf;
166    use frust_core::{BuildCtx, PointerButton, PointerEvent, PointerPhase};
167    use std::any::Any;
168
169    fn build<S: 'static>(view: &SizedBoxView<S>) -> SizedBoxWidget {
170        let mut counter = 0u64;
171        view.build(&mut BuildCtx::new(&mut counter))
172    }
173
174    #[test]
175    fn tightens_both_axes_over_child_intrinsic() {
176        // Both axes forced to 40x40, overriding the child's 200x10 intrinsic.
177        let view: SizedBoxView<()> = SizedBox(Some(40.0), Some(40.0)).child(leaf(200.0, 10.0));
178        let mut w = build(&view);
179        let mut lctx = LayoutCtx::new();
180        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
181        assert_eq!(size, Size::new(40.0, 40.0));
182        assert_eq!(w.child.as_ref().unwrap().size(), Size::new(40.0, 40.0));
183    }
184
185    #[test]
186    fn passes_through_the_unspecified_axis() {
187        // Only width is forced (to 40); height passes through to the child's 30.
188        let view: SizedBoxView<()> = SizedBox(Some(40.0), None).child(leaf(200.0, 30.0));
189        let mut w = build(&view);
190        let mut lctx = LayoutCtx::new();
191        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
192        assert_eq!(size, Size::new(40.0, 30.0));
193    }
194
195    #[test]
196    fn childless_box_is_a_fixed_size_spacer() {
197        let view: SizedBoxView<()> = SizedBox(Some(16.0), Some(24.0));
198        let mut w = build(&view);
199        let mut lctx = LayoutCtx::new();
200        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
201        assert_eq!(size, Size::new(16.0, 24.0));
202    }
203
204    // -- Capture routing (review R1: a captured child must keep receiving
205    // events regardless of hit geometry, not just while the point is still
206    // over it) --
207
208    #[derive(Default)]
209    struct Counter {
210        presses: u32,
211    }
212
213    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
214        InputEvent::Pointer(PointerEvent {
215            phase,
216            position: Point::new(x, y),
217            button: PointerButton::Primary,
218        })
219    }
220
221    /// Build a `SizedBox(Button)` with a forced width wider than the child
222    /// needs, laid out with a real text context.
223    fn button_sized_box() -> SizedBoxWidget {
224        let view: SizedBoxView<Counter> =
225            SizedBox(Some(300.0), None)
226                .child(crate::button::<Counter, _>("go", |s: &mut Counter| {
227                    s.presses += 1
228                }));
229        let mut w = build(&view);
230        let mut text_ctx = frust_text::TextContext::new();
231        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
232        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
233        w
234    }
235
236    fn dispatch<S: 'static>(
237        w: &mut SizedBoxWidget,
238        state: &mut S,
239        event: &InputEvent,
240    ) -> EventResult {
241        let state_any: &mut dyn Any = state;
242        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(500.0, 500.0));
243        w.event(&mut ctx, event)
244    }
245
246    #[test]
247    fn captured_button_receives_move_and_up_outside_its_bounds() {
248        let mut w = button_sized_box();
249        let mut state = Counter::default();
250        let size = w.child.as_ref().unwrap().size();
251        assert!(size.width > 0.0 && size.height > 0.0);
252
253        assert_eq!(
254            dispatch(
255                &mut w,
256                &mut state,
257                &pointer_ev(PointerPhase::Down, 2.0, 2.0)
258            ),
259            EventResult::Handled
260        );
261        assert!(
262            w.child.as_ref().unwrap().is_active(),
263            "down captures the pointer"
264        );
265
266        // Move well past the child's bounds — the capture must still route it
267        // there.
268        let outside = Point::new(size.width + 100.0, size.height + 100.0);
269        assert_eq!(
270            dispatch(
271                &mut w,
272                &mut state,
273                &pointer_ev(PointerPhase::Move, outside.x, outside.y)
274            ),
275            EventResult::Handled,
276            "a captured Move outside the child's bounds must still route to it"
277        );
278
279        assert_eq!(
280            dispatch(
281                &mut w,
282                &mut state,
283                &pointer_ev(PointerPhase::Up, outside.x, outside.y)
284            ),
285            EventResult::Handled,
286            "the capturing Up must still route to the child even though it's outside"
287        );
288        assert_eq!(
289            state.presses, 0,
290            "up outside the button must not fire on_press"
291        );
292        assert!(
293            !w.child.as_ref().unwrap().is_active(),
294            "capture releases on Up"
295        );
296    }
297
298    #[test]
299    fn captured_button_fires_on_move_back_inside_then_up() {
300        let mut w = button_sized_box();
301        let mut state = Counter::default();
302        let size = w.child.as_ref().unwrap().size();
303
304        dispatch(
305            &mut w,
306            &mut state,
307            &pointer_ev(PointerPhase::Down, 2.0, 2.0),
308        );
309
310        let outside = Point::new(size.width + 100.0, 2.0);
311        dispatch(
312            &mut w,
313            &mut state,
314            &pointer_ev(PointerPhase::Move, outside.x, outside.y),
315        );
316
317        dispatch(
318            &mut w,
319            &mut state,
320            &pointer_ev(PointerPhase::Move, 4.0, 4.0),
321        );
322        dispatch(&mut w, &mut state, &pointer_ev(PointerPhase::Up, 4.0, 4.0));
323        assert_eq!(state.presses, 1, "up back inside must fire on_press");
324    }
325
326    #[test]
327    fn active_clears_on_up_so_a_later_down_elsewhere_is_not_routed() {
328        let mut w = button_sized_box();
329        let mut state = Counter::default();
330
331        dispatch(
332            &mut w,
333            &mut state,
334            &pointer_ev(PointerPhase::Down, 2.0, 2.0),
335        );
336        dispatch(&mut w, &mut state, &pointer_ev(PointerPhase::Up, 2.0, 2.0));
337        assert!(
338            !w.child.as_ref().unwrap().is_active(),
339            "capture releases on Up"
340        );
341
342        // A Down far away, outside the child's bounds, must now be ignored —
343        // not routed to the (no-longer-active) child.
344        assert_eq!(
345            dispatch(
346                &mut w,
347                &mut state,
348                &pointer_ev(PointerPhase::Down, 400.0, 400.0)
349            ),
350            EventResult::Ignored
351        );
352    }
353
354    #[test]
355    fn childless_box_ignores_events_without_panicking() {
356        let view: SizedBoxView<()> = SizedBox(Some(16.0), Some(24.0));
357        let mut w = build(&view);
358        let mut state = ();
359        assert_eq!(
360            dispatch(
361                &mut w,
362                &mut state,
363                &pointer_ev(PointerPhase::Down, 5.0, 5.0)
364            ),
365            EventResult::Ignored
366        );
367        assert_eq!(
368            dispatch(
369                &mut w,
370                &mut state,
371                &pointer_ev(PointerPhase::Move, 5.0, 5.0)
372            ),
373            EventResult::Ignored
374        );
375        assert_eq!(
376            dispatch(&mut w, &mut state, &pointer_ev(PointerPhase::Up, 5.0, 5.0)),
377            EventResult::Ignored
378        );
379    }
380
381    // -- Type-swap capture clearing: `rebuild_child` must
382    // clear a stale capture on an AnyView type swap, matching
383    // `rebuild_children`'s semantics for `Flex`/`Stack` --
384
385    #[test]
386    fn type_swap_at_captured_child_clears_active_and_stops_routing() {
387        let mut counter = 0u64;
388        let prev: SizedBoxView<Counter> =
389            SizedBox(Some(300.0), None)
390                .child(crate::button::<Counter, _>("go", |s: &mut Counter| {
391                    s.presses += 1
392                }));
393        let mut w = prev.build(&mut BuildCtx::new(&mut counter));
394        let mut text_ctx = frust_text::TextContext::new();
395        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
396        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
397
398        let mut state = Counter::default();
399        dispatch(
400            &mut w,
401            &mut state,
402            &pointer_ev(PointerPhase::Down, 2.0, 2.0),
403        );
404        assert!(
405            w.child.as_ref().unwrap().is_active(),
406            "down captures the pointer"
407        );
408
409        // Rebuild, swapping the child's concrete type (Button -> Checkbox).
410        let swapped: SizedBoxView<Counter> =
411            SizedBox(Some(300.0), None).child(crate::checkbox::<Counter, _>(
412                false,
413                "swapped",
414                |_s: &mut Counter, _v: bool| {},
415            ));
416        swapped.rebuild(&prev, &mut w, &mut BuildCtx::new(&mut counter));
417        assert!(
418            !w.child.as_ref().unwrap().is_active(),
419            "the type swap must clear the stale capture"
420        );
421
422        // A Down well outside the child's bounds must now be Ignored — not
423        // routed to the fresh widget via a stale `active` flag.
424        assert_eq!(
425            dispatch(
426                &mut w,
427                &mut state,
428                &pointer_ev(PointerPhase::Down, 400.0, 400.0)
429            ),
430            EventResult::Ignored,
431            "a swap must clear active so an out-of-bounds Down is not delivered"
432        );
433    }
434}