Skip to main content

frus_widgets/
clip.rs

1//! [`ClipRRect`] and [`ClipOval`]: clip their child to a **shape** (rounded
2//! corners, an ellipse) at paint time.
3
4use frus_core::{BorderRadius, ClipShape, Path, Rect, Scene};
5use frus_layout::Style;
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// Clips its child to a **rounded rectangle** — a radius **per corner**. The subtree
12/// is painted in a layer whose shape erases whatever overflows the corners
13/// (antialiased edges) — the building block of a thumbnail, a squircle avatar, a card
14/// with soft corners (or only the top rounded, like a rising sheet) whose content (an
15/// image, a gradient…) hugs the rounding exactly.
16///
17/// A pass-through in layout: the box takes the size the parent gives it (like its
18/// child), and the rounding is **inscribed** in that box. Each radius is clamped to
19/// half the smaller dimension (beyond that the corners meet — a stadium).
20///
21/// ```ignore
22/// ClipRRect::new(12.0).child(Image::asset("photo.png"))              // uniform
23/// ClipRRect::rounded(BorderRadius::top(16.0)).child(header)          // top rounded
24/// ```
25pub struct ClipRRect<Msg = crate::callback::Callback> {
26    radius: BorderRadius,
27    children: Vec<Box<dyn Widget<Msg>>>,
28}
29
30impl<Msg> ClipRRect<Msg> {
31    /// Clips the child to a rounded rectangle of `radius` logical pixels, **uniform**
32    /// across all four corners.
33    pub fn new(radius: f32) -> Self {
34        Self::rounded(BorderRadius::uniform(radius))
35    }
36
37    /// Clips the child to a rounded rectangle **per corner** (distinct radii).
38    pub fn rounded(radius: BorderRadius) -> Self {
39        Self {
40            radius,
41            children: Vec::new(),
42        }
43    }
44
45    /// Sets the clipped child.
46    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
47        self.children.clear();
48        self.children.push(Box::new(child));
49        self
50    }
51}
52
53impl<Msg: Clone> Widget<Msg> for ClipRRect<Msg> {
54    fn style(&self) -> Style {
55        // A pass-through: the box takes its size from the context, like the child;
56        // the clipping only acts at paint time (see `clip_shape`).
57        Style::default()
58    }
59
60    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
61        &self.children
62    }
63
64    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
65        // A pure clipping widget: no decoration of its own.
66    }
67
68    fn on_click(&self) -> Option<Msg> {
69        None
70    }
71
72    fn clip_shape(&self) -> Option<ClipShape> {
73        Some(ClipShape::RRect(self.radius.clamped()))
74    }
75}
76
77/// Clips its child to an **ellipse** inscribed in its box (a circle if the box is
78/// square): the building block of a round avatar, a dot, a circular gauge whose
79/// content is cropped to the disc. Same layout rules as [`ClipRRect`] (a
80/// pass-through, with the shape inscribed in the box).
81///
82/// ```ignore
83/// ClipOval::new().child(Image::asset("avatar.png"))
84/// ```
85pub struct ClipOval<Msg = crate::callback::Callback> {
86    children: Vec<Box<dyn Widget<Msg>>>,
87}
88
89impl<Msg> ClipOval<Msg> {
90    /// Clips the child to the ellipse inscribed in the box.
91    pub fn new() -> Self {
92        Self {
93            children: Vec::new(),
94        }
95    }
96
97    /// Sets the clipped child.
98    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
99        self.children.clear();
100        self.children.push(Box::new(child));
101        self
102    }
103}
104
105impl<Msg> Default for ClipOval<Msg> {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl<Msg: Clone> Widget<Msg> for ClipOval<Msg> {
112    fn style(&self) -> Style {
113        Style::default()
114    }
115
116    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
117        &self.children
118    }
119
120    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
121
122    fn on_click(&self) -> Option<Msg> {
123        None
124    }
125
126    fn clip_shape(&self) -> Option<ClipShape> {
127        Some(ClipShape::Oval)
128    }
129}
130
131/// Clips its child to an **arbitrary path**: the subtree is painted in a layer where
132/// a **mask** (the path, rendered by the GPU) erases everything outside it — stars,
133/// pointed cut-outs, speech bubbles, free-form shapes, with antialiased edges.
134///
135/// The path is given in **local coordinates** (the origin at the top-left corner of
136/// the widget's box); the walk offsets it to the screen position. A layout
137/// pass-through, like [`ClipRRect`].
138///
139/// ```ignore
140/// // A diamond inscribed in a 100×100 box.
141/// let diamond = Path::new()
142///     .move_to(Point::new(50.0, 0.0))
143///     .line_to(Point::new(100.0, 50.0))
144///     .line_to(Point::new(50.0, 100.0))
145///     .line_to(Point::new(0.0, 50.0))
146///     .close();
147/// ClipPath::new(diamond).child(Image::asset("photo.png"))
148/// ```
149pub struct ClipPath<Msg = crate::callback::Callback> {
150    path: Path,
151    children: Vec<Box<dyn Widget<Msg>>>,
152}
153
154impl<Msg> ClipPath<Msg> {
155    /// Clips the child to `path` (coordinates local to the box).
156    pub fn new(path: Path) -> Self {
157        Self {
158            path,
159            children: Vec::new(),
160        }
161    }
162
163    /// Sets the clipped child.
164    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
165        self.children.clear();
166        self.children.push(Box::new(child));
167        self
168    }
169}
170
171impl<Msg: Clone> Widget<Msg> for ClipPath<Msg> {
172    fn style(&self) -> Style {
173        Style::default()
174    }
175
176    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
177        &self.children
178    }
179
180    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
181
182    fn on_click(&self) -> Option<Msg> {
183        None
184    }
185
186    fn clip_path(&self) -> Option<&Path> {
187        Some(&self.path)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::Container;
195    use frus_core::{Color, Point, Primitive, Size};
196
197    /// Wrapping the scene in a `ClipRRect`: a layer with an `RRect` shape is emitted,
198    /// and the child's background is painted **inside** it (in the layer's primitives).
199    #[test]
200    fn clip_rrect_wraps_child_in_a_rounded_layer() {
201        let red = Color::rgb(1.0, 0.0, 0.0);
202        let root = crate::Flex::<()>::column()
203            .width(100.0)
204            .child(ClipRRect::new(8.0).child(Container::new().width(40.0).height(40.0).color(red)));
205        let rt = crate::runtime::Runtime::default();
206        let theme = crate::Theme::dark();
207        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
208        let layer = ui
209            .scene()
210            .primitives()
211            .iter()
212            .find_map(|p| match p {
213                Primitive::Layer {
214                    clip_shape,
215                    primitives,
216                    ..
217                } => Some((clip_shape.clone(), primitives.clone())),
218                _ => None,
219            })
220            .expect("a clipping layer");
221        assert_eq!(
222            layer.0,
223            ClipShape::RRect(BorderRadius::uniform(8.0)),
224            "a rounded shape of radius 8"
225        );
226        assert!(
227            layer
228                .1
229                .iter()
230                .any(|p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.5)),
231            "the child's red background is painted in the layer"
232        );
233    }
234
235    /// `ClipOval` emits a layer with an `Oval` shape.
236    #[test]
237    fn clip_oval_emits_an_oval_layer() {
238        let blue = Color::rgb(0.0, 0.0, 1.0);
239        let root = crate::Flex::<()>::column()
240            .width(100.0)
241            .child(ClipOval::new().child(Container::new().width(40.0).height(40.0).color(blue)));
242        let rt = crate::runtime::Runtime::default();
243        let theme = crate::Theme::dark();
244        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
245        let shape = ui.scene().primitives().iter().find_map(|p| match p {
246            Primitive::Layer { clip_shape, .. } => Some(clip_shape.clone()),
247            _ => None,
248        });
249        assert_eq!(shape, Some(ClipShape::Oval), "an elliptical shape");
250    }
251
252    /// `ClipPath` emits a layer with a `Path` shape, **offset to the screen position**
253    /// of the box (the local path is translated by the box's origin).
254    #[test]
255    fn clip_path_emits_a_translated_path_layer() {
256        // A 40×40 diamond in local coordinates.
257        let diamond = Path::new()
258            .move_to(Point::new(20.0, 0.0))
259            .line_to(Point::new(40.0, 20.0))
260            .line_to(Point::new(20.0, 40.0))
261            .line_to(Point::new(0.0, 20.0))
262            .close();
263        let root = crate::Flex::<()>::column()
264            .width(100.0)
265            .padding(10.0)
266            .child(
267                ClipPath::new(diamond).child(
268                    Container::new()
269                        .width(40.0)
270                        .height(40.0)
271                        .color(Color::rgb(1.0, 0.0, 0.0)),
272                ),
273            );
274        let rt = crate::runtime::Runtime::default();
275        let theme = crate::Theme::dark();
276        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
277        let shape = ui.scene().primitives().iter().find_map(|p| match p {
278            Primitive::Layer {
279                clip_shape: ClipShape::Path(path),
280                ..
281            } => Some(path.clone()),
282            _ => None,
283        });
284        let path = shape.expect("a path clipping layer");
285        // The local vertex (20, 0) is translated by the box's origin (padding 10)
286        // → (30, 10) on screen.
287        let first = path.verbs().first().copied().expect("at least one verb");
288        match first {
289            frus_core::PathVerb::MoveTo(p) => assert!(
290                (p.x - 30.0).abs() < 0.6 && (p.y - 10.0).abs() < 0.6,
291                "vertex offset on screen to (30, 10): {p:?}"
292            ),
293            other => panic!("expected MoveTo as the first verb, got {other:?}"),
294        }
295    }
296
297    /// Clipping is a layout **pass-through**: a sibling placed after a clipped child
298    /// keeps its position (the `ClipRRect` does not grow its box).
299    #[test]
300    fn clip_is_layout_passthrough() {
301        let red = Color::rgb(1.0, 0.0, 0.0);
302        let green = Color::rgb(0.0, 1.0, 0.0);
303        let root = crate::Flex::<()>::column()
304            .width(100.0)
305            .child(ClipRRect::new(6.0).child(Container::new().height(20.0).color(red)))
306            .child(Container::new().height(20.0).color(green));
307        let rt = crate::runtime::Runtime::default();
308        let theme = crate::Theme::dark();
309        let ui = crate::ui::build_ui(&root, Size::new(100.0, 200.0), &rt, &theme);
310        // The 2nd child follows at y = 20 (the clipped child takes 20px of height).
311        let green_y = ui
312            .scene()
313            .primitives()
314            .iter()
315            .flat_map(|p| match p {
316                Primitive::Layer { primitives, .. } => primitives.clone(),
317                other => vec![other.clone()],
318            })
319            .find_map(|p| match p {
320                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
321                    Some(rect.y)
322                }
323                _ => None,
324            })
325            .expect("the 2nd child's green background");
326        assert!(
327            (green_y - 20.0).abs() < 0.5,
328            "sibling in its layout place: y = {green_y}"
329        );
330    }
331}