Skip to main content

frus_widgets/
boxes.rs

1//! The **single-purpose boxes**: [`Padding`], [`ColoredBox`], [`DecoratedBox`] and [`ClipRect`].
2//!
3//! A [`Container`] does all four and more, and is the right widget when a box needs several of
4//! them at once. These say one thing each, so that a tree reads as what it does: a padding is a
5//! padding, not a container that happens to have one setting.
6
7// `BorderRadius`, `Curve` and `Size` are named by `forward_to_container!`'s expansion.
8use frus_core::{
9    BorderRadius, BoxDecoration, ClipShape, Color, Curve, Insets, InsetsGeometry, Rect, Scene, Size,
10};
11use frus_layout::Style;
12
13use crate::container::Container;
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18/// **Insets its child** by `padding` on each side: the box is the child's size plus the
19/// padding, and the child is handed what is left of the room once the padding is taken out.
20///
21/// The padding is physical ([`Insets`](frus_core::Insets), whose left is the left in any
22/// script), directional ([`InsetsDirectional`](frus_core::InsetsDirectional), whose `start` is
23/// the left in a left-to-right script and the right in a right-to-left one), or one number for
24/// all four sides.
25///
26/// ```
27/// use frus_core::{Insets, InsetsDirectional};
28/// use frus_widgets::{Padding, Text};
29///
30/// let _all: Padding<()> = Padding::new(16.0, Text::new("Sixteen all round"));
31/// let _sides: Padding<()> = Padding::new(Insets::symmetric(24.0, 8.0), Text::new("Wide"));
32/// let _lead: Padding<()> = Padding::new(InsetsDirectional::only_start(72.0), Text::new("Indented"));
33/// ```
34pub struct Padding<Msg = crate::callback::Callback> {
35    padding: InsetsGeometry,
36    children: Vec<Box<dyn Widget<Msg>>>,
37}
38
39impl<Msg> Padding<Msg> {
40    /// Insets `child` by `padding`.
41    pub fn new(padding: impl Into<InsetsGeometry>, child: impl Widget<Msg> + 'static) -> Self {
42        Self {
43            padding: padding.into(),
44            children: vec![Box::new(child)],
45        }
46    }
47}
48
49impl<Msg> Padding<Msg> {
50    /// The insets **in layout space**. The frame is laid out left to right and mirrored as a
51    /// whole in a right-to-left script, so a directional inset is resolved as if left to right
52    /// (the mirror puts its start on the right), and a physical one is swapped beforehand so
53    /// that the mirror puts it back on the side it names.
54    fn laid_out(&self, direction: frus_core::TextDirection) -> Insets {
55        self.padding.laid_out(direction)
56    }
57}
58
59impl<Msg: Clone> Widget<Msg> for Padding<Msg> {
60    fn style(&self) -> Style {
61        Style {
62            padding: self.laid_out(frus_core::TextDirection::Ltr),
63            ..Style::default()
64        }
65    }
66
67    fn style_themed(&self, theme: &Theme) -> Style {
68        Style {
69            padding: self.laid_out(theme.direction),
70            ..Style::default()
71        }
72    }
73
74    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
75        &self.children
76    }
77
78    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
79        // A pure layout widget: it draws nothing of its own.
80    }
81
82    fn on_click(&self) -> Option<Msg> {
83        None
84    }
85
86    fn debug_name(&self) -> &'static str {
87        "Padding"
88    }
89}
90
91/// **Paints its box in one colour** behind its child — a [`Container`] with a colour and
92/// nothing else, for the tree that only wants a fill.
93///
94/// ```
95/// use frus_core::Color;
96/// use frus_widgets::{ColoredBox, Text};
97///
98/// let _band: ColoredBox<()> = ColoredBox::new(Color::rgb(0.95, 0.95, 0.9), Text::new("Note"));
99/// ```
100pub struct ColoredBox<Msg = crate::callback::Callback> {
101    inner: Container<Msg>,
102}
103
104impl<Msg: Clone + 'static> ColoredBox<Msg> {
105    /// Fills the box behind `child` with `color`.
106    pub fn new(color: Color, child: impl Widget<Msg> + 'static) -> Self {
107        Self {
108            inner: Container::new().color(color).child(child),
109        }
110    }
111}
112
113crate::animated::forward_to_container!(ColoredBox);
114
115/// **Paints a decoration** — a fill or a gradient, a border, corners, a shadow — behind its
116/// child, or [in front of it](Self::foreground).
117///
118/// ```
119/// use frus_core::{Border, BorderRadius, BoxDecoration, Color};
120/// use frus_widgets::{DecoratedBox, Text};
121///
122/// let frame = BoxDecoration::default()
123///     .border(Border::new(1.0, Color::rgb(0.8, 0.8, 0.8)))
124///     .radius(BorderRadius::uniform(8.0));
125/// let _framed: DecoratedBox<()> = DecoratedBox::new(frame, Text::new("Framed"));
126/// ```
127pub struct DecoratedBox<Msg = crate::callback::Callback> {
128    inner: Container<Msg>,
129}
130
131impl<Msg: Clone + 'static> DecoratedBox<Msg> {
132    /// Paints `decoration` behind `child`.
133    pub fn new(decoration: BoxDecoration, child: impl Widget<Msg> + 'static) -> Self {
134        Self {
135            inner: Container::new().decoration(decoration).child(child),
136        }
137    }
138
139    /// Paints `decoration` **in front of** `child` instead — a border or a tint over a picture,
140    /// which a decoration behind the picture would be hidden by.
141    pub fn foreground(decoration: BoxDecoration, child: impl Widget<Msg> + 'static) -> Self {
142        Self {
143            inner: Container::new().foreground(decoration).child(child),
144        }
145    }
146}
147
148crate::animated::forward_to_container!(DecoratedBox);
149
150/// **Clips its child to its box**: whatever the child paints past the box's edges is cut off —
151/// a picture larger than its frame, content sliding in from outside, a transform that moves
152/// something past the edge. Same layout rules as [`ClipRRect`](crate::ClipRRect), with square
153/// corners.
154///
155/// ```
156/// use frus_widgets::{ClipRect, Text};
157///
158/// let _cropped: ClipRect<()> = ClipRect::new().child(Text::new("Cut at the edge"));
159/// ```
160pub struct ClipRect<Msg = crate::callback::Callback> {
161    children: Vec<Box<dyn Widget<Msg>>>,
162}
163
164impl<Msg> ClipRect<Msg> {
165    /// A clip with no child yet.
166    pub fn new() -> Self {
167        Self {
168            children: Vec::new(),
169        }
170    }
171
172    /// Sets the clipped child.
173    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
174        self.children.clear();
175        self.children.push(Box::new(child));
176        self
177    }
178}
179
180impl<Msg> Default for ClipRect<Msg> {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186impl<Msg: Clone> Widget<Msg> for ClipRect<Msg> {
187    fn style(&self) -> Style {
188        Style::default()
189    }
190
191    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
192        &self.children
193    }
194
195    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
196
197    fn on_click(&self) -> Option<Msg> {
198        None
199    }
200
201    fn clip_shape(&self) -> Option<ClipShape> {
202        Some(ClipShape::Rect)
203    }
204
205    fn debug_name(&self) -> &'static str {
206        "ClipRect"
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::{build_ui, Runtime};
214    use frus_core::{Border, InsetsDirectional, Primitive};
215
216    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
217    const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
218
219    /// A 20×20 red square.
220    fn square() -> Container<()> {
221        Container::new().width(20.0).height(20.0).color(RED)
222    }
223
224    fn frame(root: &dyn Widget<()>, theme: &Theme) -> Vec<Primitive> {
225        build_ui(root, Size::new(100.0, 100.0), &Runtime::default(), theme)
226            .scene()
227            .primitives()
228            .to_vec()
229    }
230
231    /// Every filled rectangle of `color`, in painted order, looking inside layers.
232    fn rects_of(primitives: &[Primitive], color: Color) -> Vec<Rect> {
233        let mut found = Vec::new();
234        for p in primitives {
235            match p {
236                Primitive::Rect { rect, color: c, .. } if *c == color => found.push(*rect),
237                Primitive::Layer { primitives, .. } => found.extend(rects_of(primitives, color)),
238                _ => {}
239            }
240        }
241        found
242    }
243
244    /// A 100×100 box holding `child`.
245    fn boxed(child: impl Widget<()> + 'static) -> Container<()> {
246        Container::new().width(100.0).height(100.0).child(child)
247    }
248
249    /// **The padding moves the child in**: ten all round puts the square at (10, 10), and a
250    /// symmetric one puts it at its horizontal and vertical insets.
251    #[test]
252    fn padding_insets_the_child() {
253        let theme = Theme::default();
254        let at = rects_of(&frame(&boxed(Padding::new(10.0, square())), &theme), RED);
255        assert_eq!(at.first().map(|r| (r.x, r.y)), Some((10.0, 10.0)), "{at:?}");
256        let symmetric = Padding::new(Insets::symmetric(24.0, 8.0), square());
257        let at = rects_of(&frame(&boxed(symmetric), &theme), RED);
258        assert_eq!(at.first().map(|r| (r.x, r.y)), Some((24.0, 8.0)), "{at:?}");
259    }
260
261    /// **The style says the padding** when asked without a theme too — left to right, as a
262    /// frame with no theme is laid out.
263    #[test]
264    fn the_style_without_a_theme_carries_the_padding() {
265        let padded = Padding::<()>::new(InsetsDirectional::only_start(12.0), square());
266        assert_eq!(
267            Widget::<()>::style(&padded).padding,
268            Insets::new(0.0, 0.0, 0.0, 12.0)
269        );
270    }
271
272    /// **A directional padding follows the reading direction**: a start inset is on the left in
273    /// a left-to-right script and on the right in a right-to-left one. (The centred square shows
274    /// where the room left over is: 70 px wide, starting at 30 or at 0.)
275    #[test]
276    fn a_directional_padding_mirrors_in_rtl() {
277        let lead = || {
278            boxed(Padding::new(
279                InsetsDirectional::only_start(30.0),
280                crate::Center::new(square()),
281            ))
282        };
283        let ltr = rects_of(&frame(&lead(), &Theme::default()), RED);
284        assert_eq!(ltr.first().map(|r| r.x), Some(55.0), "{ltr:?}");
285        let rtl = rects_of(&frame(&lead(), &Theme::default().rtl()), RED);
286        assert_eq!(rtl.first().map(|r| r.x), Some(25.0), "{rtl:?}");
287    }
288
289    /// **A physical padding stays on the side it names** in either direction: a left inset is
290    /// on the left in a right-to-left script too.
291    #[test]
292    fn a_physical_padding_stays_put_in_rtl() {
293        let left = || {
294            boxed(Padding::new(
295                Insets::new(0.0, 0.0, 0.0, 30.0),
296                crate::Center::new(square()),
297            ))
298        };
299        for theme in [Theme::default(), Theme::default().rtl()] {
300            let at = rects_of(&frame(&left(), &theme), RED);
301            assert_eq!(at.first().map(|r| r.x), Some(55.0), "{at:?}");
302        }
303    }
304
305    /// **A coloured box paints its whole box, behind the child** — as big as its child, like
306    /// any box, and the whole room when the child asks for it, as a centred child does.
307    #[test]
308    fn a_colored_box_fills_behind_its_child() {
309        let hugging = frame(&boxed(ColoredBox::new(BLUE, square())), &Theme::default());
310        let fill = rects_of(&hugging, BLUE);
311        assert_eq!(
312            fill.first().map(|r| r.width),
313            Some(20.0),
314            "as wide as the square: {fill:?}"
315        );
316        let centred = boxed(ColoredBox::new(BLUE, crate::Center::new(square())));
317        let primitives = frame(&centred, &Theme::default());
318        let fill = rects_of(&primitives, BLUE);
319        assert_eq!(fill, vec![Rect::new(0.0, 0.0, 100.0, 100.0)]);
320        let fill_at = primitives
321            .iter()
322            .position(|p| matches!(p, Primitive::Rect { color, .. } if *color == BLUE));
323        let child_at = primitives
324            .iter()
325            .position(|p| matches!(p, Primitive::Rect { color, .. } if *color == RED));
326        assert!(fill_at < child_at, "the fill is behind the child");
327    }
328
329    /// **A decoration is painted behind the child, or in front of it** when it is a
330    /// foreground.
331    #[test]
332    fn a_decoration_goes_behind_or_in_front() {
333        let frame_of = |decorated: DecoratedBox<()>| frame(&boxed(decorated), &Theme::default());
334        let order = |primitives: &[Primitive]| {
335            let blue = primitives
336                .iter()
337                .position(|p| matches!(p, Primitive::Rect { color, .. } if *color == BLUE));
338            let red = primitives
339                .iter()
340                .position(|p| matches!(p, Primitive::Rect { color, .. } if *color == RED));
341            (blue.expect("the decoration"), red.expect("the child"))
342        };
343        let (decoration, child) = order(&frame_of(DecoratedBox::new(
344            BoxDecoration::filled(BLUE),
345            square(),
346        )));
347        assert!(decoration < child, "behind");
348        let tint = BoxDecoration::filled(BLUE);
349        let (decoration, child) = order(&frame_of(DecoratedBox::foreground(tint, square())));
350        assert!(decoration > child, "in front");
351        // A border is part of a decoration too.
352        let bordered = BoxDecoration::default().border(Border::new(2.0, BLUE));
353        assert!(!frame_of(DecoratedBox::new(bordered, square())).is_empty());
354    }
355
356    /// **A clip cuts the child at the box's edges**: a square larger than its box is painted
357    /// in a layer clipped to the box.
358    #[test]
359    fn a_clip_rect_cuts_at_the_box() {
360        let big = Container::<()>::new().width(200.0).height(200.0).color(RED);
361        let clipped = Container::<()>::new()
362            .width(50.0)
363            .height(50.0)
364            .child(ClipRect::new().child(big));
365        let primitives = frame(&clipped, &Theme::default());
366        let layer = primitives.iter().find_map(|p| match p {
367            Primitive::Layer {
368                clip, primitives, ..
369            } if !rects_of(primitives, RED).is_empty() => Some(*clip),
370            _ => None,
371        });
372        let clip = layer.expect("the child is painted in a clipped layer");
373        assert!(clip.width <= 50.0 && clip.height <= 50.0, "{clip:?}");
374    }
375}
376
377/// Physical sides stay put in a right-to-left script, and directional ones move
378/// (milestone 588). Each square is centred in what the padding leaves, so where it lands
379/// says which side the padding is on: at 55 with 30 on the left, at 25 with 30 on the right.
380#[cfg(test)]
381mod rtl_sides {
382    use super::*;
383    use crate::{build_ui, Center, Flex, Runtime, SafeArea};
384    use frus_core::{Insets, InsetsDirectional, Primitive, Size};
385
386    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
387
388    fn square() -> Center<()> {
389        Center::new(Container::new().width(20.0).height(20.0).color(RED))
390    }
391
392    fn red_x(root: &dyn Widget<()>, theme: &Theme) -> f32 {
393        build_ui(root, Size::new(100.0, 100.0), &Runtime::default(), theme)
394            .scene()
395            .primitives()
396            .iter()
397            .find_map(|p| match p {
398                Primitive::Rect { rect, color, .. } if *color == RED => Some(rect.x),
399                _ => None,
400            })
401            .expect("the square")
402    }
403
404    fn boxed(child: impl Widget<()> + 'static) -> Container<()> {
405        Container::new().width(100.0).height(100.0).child(child)
406    }
407
408    /// **A container's left padding is on the left**, in either direction; a start padding
409    /// is on the right in a right-to-left script. A margin likewise.
410    #[test]
411    fn a_containers_physical_padding_stays_and_its_start_padding_moves() {
412        let (ltr, rtl) = (Theme::default(), Theme::default().rtl());
413        let left = || {
414            boxed(
415                Container::new()
416                    .padding_each(0.0, 0.0, 0.0, 30.0)
417                    .child(square()),
418            )
419        };
420        assert_eq!(red_x(&left(), &ltr), 55.0);
421        assert_eq!(red_x(&left(), &rtl), 55.0, "still on the left");
422        let start = || {
423            boxed(
424                Container::new()
425                    .padding_insets(InsetsDirectional::only_start(30.0))
426                    .child(square()),
427            )
428        };
429        assert_eq!(red_x(&start(), &ltr), 55.0);
430        assert_eq!(red_x(&start(), &rtl), 25.0, "the start is the right");
431        let margin = || {
432            boxed(
433                Container::new()
434                    .margin_each(0.0, 0.0, 0.0, 30.0)
435                    .width(70.0)
436                    .height(100.0)
437                    .child(square()),
438            )
439        };
440        assert_eq!(red_x(&margin(), &ltr), 55.0);
441        assert_eq!(red_x(&margin(), &rtl), 55.0, "the margin stays on the left");
442    }
443
444    /// **A row's left padding is on the left too**, and its start padding moves.
445    #[test]
446    fn a_flexs_physical_padding_stays() {
447        let rtl = Theme::default().rtl();
448        let left = boxed(
449            Flex::column()
450                .padding_each(0.0, 0.0, 0.0, 30.0)
451                .child(square()),
452        );
453        assert_eq!(red_x(&left, &rtl), 55.0);
454        let start = boxed(
455            Flex::column()
456                .padding_insets(InsetsDirectional::only_start(30.0))
457                .child(square()),
458        );
459        assert_eq!(red_x(&start, &rtl), 25.0);
460    }
461
462    /// **A safe area keeps a cutout on the left clear on the left** in a right-to-left
463    /// script. It used to pad the right, leaving the cutout over the content.
464    #[test]
465    fn a_safe_area_pads_the_side_the_cutout_is_on() {
466        let (ltr, rtl) = (Theme::default(), Theme::default().rtl());
467        let surface = crate::MediaQuery::new(Size::new(100.0, 100.0))
468            .with_insets(crate::WindowInsets::bars(Insets::new(0.0, 0.0, 0.0, 30.0)));
469        let area = surface.scope(|| boxed(SafeArea::new(square())));
470        let _installed = surface.install();
471        assert_eq!(red_x(&area, &ltr), 55.0);
472        assert_eq!(red_x(&area, &rtl), 55.0, "clear of the cutout, on the left");
473    }
474}