Skip to main content

frust_widgets/
padding.rs

1//! Padding layout container: insets a single child.
2//!
3//! [`PaddingView`]/[`PaddingWidget`] deflate the incoming constraints by the
4//! [`EdgeInsets`], lay the child out in the reduced space, offset it by the
5//! top-left insets, and report a size of `child + insets` (clamped to the
6//! original constraints).
7
8use frust_core::{
9    BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent, LayoutCtx,
10    PaintCtx, PaintScene, SemanticsCtx, View, Widget, any,
11};
12use kurbo::{Point, Size};
13
14/// Per-edge inset amounts, in logical pixels.
15///
16/// Negative insets clamp to zero; intentional overflow/bleed is a decorated-box concern,
17/// not Padding's.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct EdgeInsets {
20    /// Inset from the left edge.
21    pub left: f64,
22    /// Inset from the top edge.
23    pub top: f64,
24    /// Inset from the right edge.
25    pub right: f64,
26    /// Inset from the bottom edge.
27    pub bottom: f64,
28}
29
30impl EdgeInsets {
31    /// Equal inset on all four edges.
32    pub fn all(value: f64) -> Self {
33        Self {
34            left: value,
35            top: value,
36            right: value,
37            bottom: value,
38        }
39    }
40
41    /// Symmetric horizontal (`left == right`) and vertical (`top == bottom`) insets.
42    pub fn symmetric(horizontal: f64, vertical: f64) -> Self {
43        Self {
44            left: horizontal,
45            top: vertical,
46            right: horizontal,
47            bottom: vertical,
48        }
49    }
50
51    /// Total horizontal inset (`left + right`).
52    #[allow(dead_code)]
53    fn horizontal(&self) -> f64 {
54        self.left + self.right
55    }
56
57    /// Total vertical inset (`top + bottom`).
58    #[allow(dead_code)]
59    fn vertical(&self) -> f64 {
60        self.top + self.bottom
61    }
62}
63
64/// A declarative padding container. See the [module docs](self).
65pub struct PaddingView<State: 'static> {
66    insets: EdgeInsets,
67    child: frust_core::AnyView<State>,
68}
69
70/// Inset `child` by `insets` on each edge.
71#[allow(non_snake_case)]
72pub fn Padding<State: 'static, V: View<State>>(insets: EdgeInsets, child: V) -> PaddingView<State> {
73    PaddingView {
74        insets,
75        child: any(child),
76    }
77}
78
79/// The retained widget for a [`PaddingView`].
80pub struct PaddingWidget {
81    insets: EdgeInsets,
82    child: ChildPod,
83}
84
85impl<State: 'static> View<State> for PaddingView<State> {
86    type Element = PaddingWidget;
87
88    fn build(&self, ctx: &mut BuildCtx<'_>) -> PaddingWidget {
89        PaddingWidget {
90            insets: self.insets,
91            child: crate::authoring::build_child(&self.child, ctx),
92        }
93    }
94
95    fn rebuild(
96        &self,
97        prev: &Self,
98        element: &mut PaddingWidget,
99        ctx: &mut BuildCtx<'_>,
100    ) -> ChangeFlags {
101        let mut flags = ChangeFlags::NONE;
102        if prev.insets != self.insets {
103            element.insets = self.insets;
104            flags |= ChangeFlags::LAYOUT;
105        }
106        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
107        flags
108    }
109
110    fn teardown(&self, element: &mut PaddingWidget, ctx: &mut BuildCtx<'_>) {
111        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
112    }
113}
114
115impl Widget for PaddingWidget {
116    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
117        // Clamp insets to zero to prevent negative values from expanding child
118        // constraints or placing the child at negative coordinates.
119        let left = self.insets.left.max(0.0);
120        let top = self.insets.top.max(0.0);
121        let right = self.insets.right.max(0.0);
122        let bottom = self.insets.bottom.max(0.0);
123        let h = left + right;
124        let v = top + bottom;
125        // Deflate the constraints by the insets (never below zero — insets larger
126        // than the available space collapse the child to nothing).
127        let child_bc = BoxConstraints::new(
128            Size::new(
129                (bc.min().width - h).max(0.0),
130                (bc.min().height - v).max(0.0),
131            ),
132            Size::new(
133                (bc.max().width - h).max(0.0),
134                (bc.max().height - v).max(0.0),
135            ),
136        );
137        let child_size = self.child.layout_child(ctx, &child_bc);
138        self.child.set_origin(Point::new(left, top));
139        bc.constrain(Size::new(child_size.width + h, child_size.height + v))
140    }
141
142    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
143        self.child.paint_child(ctx, scene);
144    }
145
146    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
147        crate::authoring::route_event_single(&mut self.child, ctx, event)
148    }
149
150    fn semantics(&self, ctx: &mut SemanticsCtx) {
151        // Transparent inset wrapper: forward to the single child.
152        self.child.semantics_child(ctx);
153    }
154
155    crate::authoring::visit_children!(child);
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::test_support::leaf;
162    use frust_core::{BuildCtx, PointerButton, PointerEvent, PointerPhase};
163    use std::any::Any;
164
165    fn build<S: 'static>(view: &PaddingView<S>) -> PaddingWidget {
166        let mut counter = 0u64;
167        view.build(&mut BuildCtx::new(&mut counter))
168    }
169
170    #[test]
171    fn adds_insets_around_child() {
172        // 40x20 child, insets (l=5, t=10, r=15, b=20) → size 60x50, child at (5,10).
173        let view: PaddingView<()> = Padding(
174            EdgeInsets {
175                left: 5.0,
176                top: 10.0,
177                right: 15.0,
178                bottom: 20.0,
179            },
180            leaf(40.0, 20.0),
181        );
182        let mut w = build(&view);
183        let mut lctx = LayoutCtx::new();
184        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
185        assert_eq!(size, Size::new(60.0, 50.0));
186        assert_eq!(w.child.origin(), Point::new(5.0, 10.0));
187        assert_eq!(w.child.size(), Size::new(40.0, 20.0));
188    }
189
190    #[test]
191    fn clamps_when_insets_exceed_max() {
192        // Uniform 100px inset but only 50x50 available: the child collapses to
193        // zero and the padding's own size clamps back to the 50x50 max.
194        let view: PaddingView<()> = Padding(EdgeInsets::all(100.0), leaf(30.0, 30.0));
195        let mut w = build(&view);
196        let mut lctx = LayoutCtx::new();
197        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
198        assert_eq!(w.child.size(), Size::ZERO);
199        assert_eq!(size, Size::new(50.0, 50.0));
200    }
201
202    #[test]
203    fn negative_insets_clamp_to_zero() {
204        // Negative insets should behave identically to zero insets: the child size
205        // stays the same and is placed at the origin.
206        let view_negative: PaddingView<()> = Padding(
207            EdgeInsets {
208                left: -5.0,
209                top: -10.0,
210                right: -15.0,
211                bottom: -20.0,
212            },
213            leaf(40.0, 20.0),
214        );
215        let view_zero: PaddingView<()> = Padding(EdgeInsets::all(0.0), leaf(40.0, 20.0));
216
217        let mut w_negative = build(&view_negative);
218        let mut w_zero = build(&view_zero);
219        let mut lctx = LayoutCtx::new();
220
221        let size_negative =
222            w_negative.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
223        let size_zero = w_zero.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
224
225        assert_eq!(
226            size_negative, size_zero,
227            "negative and zero insets should produce the same size"
228        );
229        assert_eq!(
230            w_negative.child.origin(),
231            w_zero.child.origin(),
232            "negative and zero insets should place child at the same origin"
233        );
234        assert_eq!(
235            w_negative.child.size(),
236            w_zero.child.size(),
237            "negative and zero insets should give child the same size"
238        );
239    }
240
241    // -- Capture routing (review R1: a captured child must keep receiving
242    // events regardless of hit geometry, not just while the point is still
243    // over it) --
244
245    #[derive(Default)]
246    struct Counter {
247        presses: u32,
248    }
249
250    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
251        InputEvent::Pointer(PointerEvent {
252            phase,
253            position: Point::new(x, y),
254            button: PointerButton::Primary,
255        })
256    }
257
258    /// Build a `Padding(Button)`, laid out under generous constraints so the
259    /// child has real geometry to hit-test/capture against.
260    fn button_padding(insets: EdgeInsets) -> PaddingWidget {
261        let view: PaddingView<Counter> = Padding(
262            insets,
263            crate::button::<Counter, _>("go", |s: &mut Counter| s.presses += 1),
264        );
265        let mut w = build(&view);
266        let mut text_ctx = frust_text::TextContext::new();
267        let mut lctx = LayoutCtx::with_text_context(&mut text_ctx);
268        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
269        w
270    }
271
272    fn dispatch<S: 'static>(
273        w: &mut PaddingWidget,
274        state: &mut S,
275        event: &InputEvent,
276    ) -> EventResult {
277        let state_any: &mut dyn Any = state;
278        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(500.0, 500.0));
279        w.event(&mut ctx, event)
280    }
281
282    #[test]
283    fn captured_button_receives_move_and_up_outside_its_bounds() {
284        let mut w = button_padding(EdgeInsets::all(10.0));
285        let mut state = Counter::default();
286        let origin = w.child.origin();
287        let size = w.child.size();
288        assert!(
289            size.width > 0.0 && size.height > 0.0,
290            "button should have real geometry from layout"
291        );
292
293        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
294        assert_eq!(
295            dispatch(
296                &mut w,
297                &mut state,
298                &pointer_ev(PointerPhase::Down, inside.x, inside.y)
299            ),
300            EventResult::Handled
301        );
302        assert!(w.child.is_active(), "down captures the pointer");
303
304        // Move far outside the child's bounds — must still reach the child
305        // because it holds the capture, not because the point is over it.
306        let outside = Point::new(
307            origin.x + size.width + 100.0,
308            origin.y + size.height + 100.0,
309        );
310        assert_eq!(
311            dispatch(
312                &mut w,
313                &mut state,
314                &pointer_ev(PointerPhase::Move, outside.x, outside.y)
315            ),
316            EventResult::Handled,
317            "a captured Move outside the child's bounds must still route to it"
318        );
319
320        assert_eq!(
321            dispatch(
322                &mut w,
323                &mut state,
324                &pointer_ev(PointerPhase::Up, outside.x, outside.y)
325            ),
326            EventResult::Handled,
327            "the capturing Up must still route to the child even though it's outside"
328        );
329        assert_eq!(
330            state.presses, 0,
331            "up outside the button must not fire on_press"
332        );
333        assert!(!w.child.is_active(), "capture releases on Up");
334    }
335
336    #[test]
337    fn captured_button_fires_on_move_back_inside_then_up() {
338        let mut w = button_padding(EdgeInsets::all(10.0));
339        let mut state = Counter::default();
340        let origin = w.child.origin();
341        let size = w.child.size();
342
343        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
344        dispatch(
345            &mut w,
346            &mut state,
347            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
348        );
349
350        let outside = Point::new(origin.x + size.width + 100.0, origin.y);
351        dispatch(
352            &mut w,
353            &mut state,
354            &pointer_ev(PointerPhase::Move, outside.x, outside.y),
355        );
356
357        let back_inside = Point::new(origin.x + 4.0, origin.y + 4.0);
358        dispatch(
359            &mut w,
360            &mut state,
361            &pointer_ev(PointerPhase::Move, back_inside.x, back_inside.y),
362        );
363
364        dispatch(
365            &mut w,
366            &mut state,
367            &pointer_ev(PointerPhase::Up, back_inside.x, back_inside.y),
368        );
369        assert_eq!(state.presses, 1, "up back inside must fire on_press");
370    }
371
372    #[test]
373    fn captured_slider_move_outside_insets_still_reports_value() {
374        // Padding(Slider) with insets: the inset margin is space the padding
375        // owns but the child does not — a captured drag that strays into it
376        // must still reach the slider.
377        #[derive(Default)]
378        struct Val {
379            changes: u32,
380        }
381
382        let view: PaddingView<Val> = Padding(
383            EdgeInsets::all(20.0),
384            crate::slider::<Val, _>(0.0, |s: &mut Val, _v: f64| s.changes += 1),
385        );
386        let mut w = build(&view);
387        let mut lctx = LayoutCtx::new(); // Slider's layout needs no text context.
388        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
389        let origin = w.child.origin();
390
391        let mut state = Val::default();
392        dispatch(
393            &mut w,
394            &mut state,
395            &pointer_ev(PointerPhase::Down, origin.x + 10.0, origin.y + 5.0),
396        );
397        assert_eq!(state.changes, 1);
398        assert!(w.child.is_active());
399
400        // A captured Move landing inside the inset margin (outside the child's
401        // origin.x) must still fire on_change.
402        let outside = Point::new(5.0, origin.y + 5.0);
403        assert!(
404            !w.child.contains(outside),
405            "sanity: the point is outside the child"
406        );
407        assert_eq!(
408            dispatch(
409                &mut w,
410                &mut state,
411                &pointer_ev(PointerPhase::Move, outside.x, outside.y)
412            ),
413            EventResult::Handled
414        );
415        assert_eq!(
416            state.changes, 2,
417            "captured Move outside the child must still fire on_change"
418        );
419    }
420
421    #[test]
422    fn active_clears_on_up_so_a_later_down_elsewhere_is_not_routed() {
423        let mut w = button_padding(EdgeInsets::all(10.0));
424        let mut state = Counter::default();
425        let origin = w.child.origin();
426
427        let inside = Point::new(origin.x + 2.0, origin.y + 2.0);
428        dispatch(
429            &mut w,
430            &mut state,
431            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
432        );
433        dispatch(
434            &mut w,
435            &mut state,
436            &pointer_ev(PointerPhase::Up, inside.x, inside.y),
437        );
438        assert!(!w.child.is_active(), "capture releases on Up");
439
440        // A Down far away, outside the child's bounds, must now be ignored —
441        // not routed to the (no-longer-active) child.
442        assert_eq!(
443            dispatch(
444                &mut w,
445                &mut state,
446                &pointer_ev(PointerPhase::Down, 400.0, 400.0)
447            ),
448            EventResult::Ignored
449        );
450    }
451
452    // -- Type-swap capture clearing: `rebuild_child` must
453    // clear a stale capture on an AnyView type swap the same way
454    // `rebuild_children` does for `Flex`/`Stack`, or a captured single child's
455    // fresh replacement wrongly keeps routing after the swap --
456
457    use std::cell::Cell;
458    use std::rc::Rc;
459
460    const CHILD_W: f64 = 50.0;
461    const CHILD_H: f64 = 20.0;
462
463    /// A child that captures the pointer on `Down` and fires its id into the
464    /// `Vec<u32>` app state on an up-inside.
465    struct Captor {
466        id: u32,
467    }
468    struct CaptorWidget {
469        id: u32,
470        armed: bool,
471    }
472
473    impl View<Vec<u32>> for Captor {
474        type Element = CaptorWidget;
475        fn build(&self, _ctx: &mut BuildCtx<'_>) -> CaptorWidget {
476            CaptorWidget {
477                id: self.id,
478                armed: false,
479            }
480        }
481        fn rebuild(
482            &self,
483            _prev: &Self,
484            element: &mut CaptorWidget,
485            _ctx: &mut BuildCtx<'_>,
486        ) -> ChangeFlags {
487            element.id = self.id;
488            ChangeFlags::NONE
489        }
490    }
491
492    impl Widget for CaptorWidget {
493        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
494            bc.constrain(Size::new(CHILD_W, CHILD_H))
495        }
496        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
497        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
498            let InputEvent::Pointer(p) = event else {
499                return EventResult::Ignored;
500            };
501            match p.phase {
502                PointerPhase::Down => {
503                    self.armed = true;
504                    ctx.capture_pointer();
505                    EventResult::Handled
506                }
507                PointerPhase::Move => EventResult::Handled,
508                PointerPhase::Up => {
509                    if self.armed {
510                        ctx.state_mut::<Vec<u32>>().push(self.id);
511                    }
512                    self.armed = false;
513                    EventResult::Handled
514                }
515                PointerPhase::Cancel => {
516                    self.armed = false;
517                    EventResult::Handled
518                }
519            }
520        }
521    }
522
523    /// A child that records into a shared cell that it saw *any* event — used
524    /// to prove a freshly type-swapped widget receives nothing.
525    struct Recorder {
526        seen: Rc<Cell<u32>>,
527    }
528    struct RecorderWidget {
529        seen: Rc<Cell<u32>>,
530    }
531
532    impl View<Vec<u32>> for Recorder {
533        type Element = RecorderWidget;
534        fn build(&self, _ctx: &mut BuildCtx<'_>) -> RecorderWidget {
535            RecorderWidget {
536                seen: self.seen.clone(),
537            }
538        }
539        fn rebuild(
540            &self,
541            _prev: &Self,
542            _element: &mut RecorderWidget,
543            _ctx: &mut BuildCtx<'_>,
544        ) -> ChangeFlags {
545            ChangeFlags::NONE
546        }
547    }
548
549    impl Widget for RecorderWidget {
550        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
551            bc.constrain(Size::new(CHILD_W, CHILD_H))
552        }
553        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
554        fn event(&mut self, _ctx: &mut EventCtx, _event: &InputEvent) -> EventResult {
555            self.seen.set(self.seen.get() + 1);
556            EventResult::Handled
557        }
558    }
559
560    #[test]
561    fn type_swap_at_captured_child_stops_gutter_misrouting() {
562        // Full geometry scenario: Padding(captor) with real insets; a Down
563        // inside the child arms/captures it; a rebuild swaps the child's
564        // concrete type; then a Down landing in the padding's GUTTER (inside
565        // the padding's own bounds, outside the fresh child's bounds) must
566        // reach nothing — the stale `active` flag must not have survived the
567        // swap to bypass `contains()` and misroute the gutter Down into the
568        // fresh (unrelated) widget.
569        let insets = EdgeInsets::all(20.0);
570        let prev: PaddingView<Vec<u32>> = Padding(insets, Captor { id: 0 });
571        let mut counter = 0u64;
572        let mut w = prev.build(&mut BuildCtx::new(&mut counter));
573        let mut lctx = LayoutCtx::new();
574        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
575
576        let mut log: Vec<u32> = Vec::new();
577        let origin = w.child.origin();
578        let inside = Point::new(origin.x + 5.0, origin.y + 5.0);
579        dispatch(
580            &mut w,
581            &mut log,
582            &pointer_ev(PointerPhase::Down, inside.x, inside.y),
583        );
584        assert!(
585            w.child.is_active(),
586            "down inside the child arms/captures it"
587        );
588
589        // Rebuild: swap the child's concrete type (Captor -> Recorder).
590        let seen = Rc::new(Cell::new(0u32));
591        let swapped: PaddingView<Vec<u32>> = Padding(insets, Recorder { seen: seen.clone() });
592        swapped.rebuild(&prev, &mut w, &mut BuildCtx::new(&mut counter));
593        w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
594        assert!(
595            !w.child.is_active(),
596            "the type swap must clear the stale capture"
597        );
598
599        // A Down landing in the gutter — inside the padding's own bounds,
600        // outside the fresh child's — must be ignored entirely.
601        let gutter = Point::new(5.0, 5.0);
602        assert!(
603            !w.child.contains(gutter),
604            "sanity: the gutter point is outside the child"
605        );
606        let result = dispatch(
607            &mut w,
608            &mut log,
609            &pointer_ev(PointerPhase::Down, gutter.x, gutter.y),
610        );
611        assert_eq!(
612            result,
613            EventResult::Ignored,
614            "a gutter Down must not be routed anywhere"
615        );
616        assert_eq!(
617            seen.get(),
618            0,
619            "the fresh widget must receive nothing from the stale-capture path"
620        );
621    }
622}