Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified masonry/screenshots/label_line_break_modes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions masonry/src/tests/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ fn paint_clipping() {
let parent = NewWidget::new(
ModularWidget::new(())
.measure_fn(|_, _, _, _, _, _| SQUARE_SIZE)
.layout_fn(|_, ctx, _, size| {
ctx.set_clip_path(size.to_rect());
.layout_fn(|_, ctx, _, _size| {
ctx.set_clips_contents(true);
})
.paint_fn(move |_, ctx, _, scene| {
fill(scene, &ctx.content_box(), Color::WHITE);
Expand Down
2 changes: 1 addition & 1 deletion masonry/src/widgets/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ impl Widget for Canvas {
ctx.submit_action::<Self::Action>(CanvasSizeChanged { size });
}
// We clip the contents we draw.
ctx.set_clip_path(size.to_rect());
ctx.set_clips_contents(true);
}

fn paint(&mut self, _: &mut PaintCtx<'_>, _props: &PropertiesRef<'_>, scene: &mut Scene) {
Expand Down
8 changes: 2 additions & 6 deletions masonry/src/widgets/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,12 +430,8 @@ impl Widget for Label {
let baseline = 0.; // TODO: Use actual baseline, at least for single line text
ctx.set_baseline_offset(baseline);

if *line_break_mode == LineBreaking::Clip {
let border_box = size.to_rect() + ctx.border_box_insets();
ctx.set_clip_path(border_box);
} else {
ctx.clear_clip_path();
}
let needs_clip = *line_break_mode == LineBreaking::Clip;
ctx.set_clips_contents(needs_clip);
}

fn paint(&mut self, ctx: &mut PaintCtx<'_>, props: &PropertiesRef<'_>, scene: &mut Scene) {
Expand Down
2 changes: 1 addition & 1 deletion masonry/src/widgets/portal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
self.set_viewport_pos_raw(size, content_size, self.viewport_pos);
// TODO - recompute portal progress

ctx.set_clip_path(size.to_rect());
ctx.set_clips_contents(true);

ctx.place_child(&mut self.child, Point::ZERO);

Expand Down
7 changes: 1 addition & 6 deletions masonry/src/widgets/prose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,7 @@ impl Widget for Prose {
ctx.run_layout(&mut self.text, size);
ctx.place_child(&mut self.text, Point::ORIGIN);

if self.clip {
let border_box = size.to_rect() + ctx.border_box_insets();
ctx.set_clip_path(border_box);
} else {
ctx.clear_clip_path();
}
ctx.set_clips_contents(self.clip);
}

fn paint(&mut self, _ctx: &mut PaintCtx<'_>, _props: &PropertiesRef<'_>, _scene: &mut Scene) {
Expand Down
6 changes: 1 addition & 5 deletions masonry/src/widgets/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,7 @@ impl Widget for TextInput {
ctx.place_child(&mut self.placeholder, child_origin);
}

if self.clip {
ctx.set_clip_path(size.to_rect());
} else {
ctx.clear_clip_path();
}
ctx.set_clips_contents(self.clip);
}

fn pre_paint(&mut self, ctx: &mut PaintCtx<'_>, props: &PropertiesRef<'_>, scene: &mut Scene) {
Expand Down
2 changes: 1 addition & 1 deletion masonry/src/widgets/virtual_scroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ impl Widget for VirtualScroll {
}

fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, size: Size) {
ctx.set_clip_path(size.to_rect());
ctx.set_clips_contents(true);
// The number of loaded items before the anchor
let mut height_before_anchor = 0.;
let mut total_height = 0.;
Expand Down
55 changes: 19 additions & 36 deletions masonry_core/src/core/contexts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -941,45 +941,32 @@ impl LayoutCtx<'_> {
self.get_child_state(child).layout_border_box_size
}

/// Sets the widget's clip path in the widget's content-box coordinate space.
/// Sets whether the [clip shape] is applied to the widget and its children.
///
/// A widget's clip path will have two effects:
/// A widget's clip shape will have two effects:
/// - It serves as a mask for painting operations of this widget and its children.
/// Note that while all painting done by children will be clipped by this path,
/// only the painting done in [`paint`] by this widget itself will be clipped.
/// The remaining painting done in [`pre_paint`] and [`post_paint`] will not be clipped.
/// - Pointer events must be inside this path to reach the widget's children.
/// - Pointer events must be inside that shape to reach the widget's children.
///
/// [`paint`]: Widget::paint
/// [`pre_paint`]: Widget::pre_paint
/// [`post_paint`]: Widget::post_paint
pub fn set_clip_path(&mut self, path: Rect) {
// Translate the clip path to the widget's border-box coordinate space.
let path = path + self.widget_state.border_box_translation();
// We intentionally always log this because clip paths are:
// 1) Relatively rare in the tree
// 2) An easy potential source of items not being visible when expected
trace!("set_clip_path {path:?}");
self.widget_state.clip_path = Some(path);
// TODO - Updating the clip path may have
// other knock-on effects we'd need to document.
self.widget_state.request_accessibility = true;
self.widget_state.needs_accessibility = true;
self.widget_state.needs_paint = true;
}
/// [clip shape]: crate::doc::masonry_concepts#clip-shape.
pub fn set_clips_contents(&mut self, clips: bool) {
if self.widget_state.clips_contents != clips {
self.widget_state.clips_contents = clips;

/// Removes the widget's clip path.
///
/// See [`LayoutCtx::set_clip_path`] for details.
pub fn clear_clip_path(&mut self) {
trace!("clear_clip_path");
self.widget_state.clip_path = None;
// TODO - Updating the clip path may have
// other knock-on effects we'd need to document.
self.widget_state.request_accessibility = true;
self.widget_state.needs_accessibility = true;
self.widget_state.needs_paint = true;
self.widget_state.request_accessibility = true;
self.widget_state.needs_accessibility = true;
self.widget_state.needs_paint = true;
self.global_state.needs_pointer_pass = true;
}
}

// TODO - Add set_clip_shape(impl Shape) method
// TODO - Add clear_clip_shape() method
}

impl ComposeCtx<'_> {
Expand Down Expand Up @@ -1139,16 +1126,12 @@ impl_context_method!(
border_box_baseline - self.widget_state.border_box_insets.y1
}

/// The clip path of the widget, if any was set.
///
/// The returned clip path will be in this widget's content-box coordinate space.
/// Whether the widget clips its own contents and that of its children.
///
/// For more information, see
/// [`LayoutCtx::set_clip_path`](crate::core::LayoutCtx::set_clip_path).
pub fn clip_path(&self) -> Option<Rect> {
// Translate the clip path to the widget's content-box coordinate space.
let translation = self.widget_state.border_box_translation();
self.widget_state.clip_path.map(|path| path - translation)
/// [`LayoutCtx::set_clips_contents`](crate::core::LayoutCtx::set_clips_contents).
pub fn clips_contents(&self) -> bool {
self.widget_state.clips_contents
}

/// Returns the [`Vec2`] for translating between this widget's
Expand Down
17 changes: 10 additions & 7 deletions masonry_core/src/core/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,9 @@ pub trait Widget: AsDynWidget + Any {
///
/// This is where box shadow, background, and borders are painted.
///
/// This method is not constrained by the clip defined in [`LayoutCtx::set_clip_path`],
/// and can paint things outside the clip.
/// This method is not restricted by the [clip shape].
///
/// [clip shape]: crate::doc::masonry_concepts#clip-shape.
fn pre_paint(&mut self, ctx: &mut PaintCtx<'_>, props: &PropertiesRef<'_>, scene: &mut Scene) {
pre_paint(ctx, props, scene);
}
Expand All @@ -381,8 +382,9 @@ pub trait Widget: AsDynWidget + Any {

/// Final paint method, which paints on top of the widget's children.
///
/// This method is not constrained by the clip defined in [`LayoutCtx::set_clip_path`],
/// and can paint things outside the clip.
/// This method is not restricted by the [clip shape].
///
/// [clip shape]: crate::doc::masonry_concepts#clip-shape.
fn post_paint(&mut self, ctx: &mut PaintCtx<'_>, props: &PropertiesRef<'_>, scene: &mut Scene) {
}

Expand Down Expand Up @@ -562,9 +564,9 @@ pub fn find_widget_under_pointer<'c>(

let local_pos = ctx.window_transform().inverse() * pos;

if let Some(clip) = ctx.clip_path()
&& !clip.contains(local_pos)
{
let is_inside_clip_shape = ctx.content_box().contains(local_pos);

if ctx.clips_contents() && !is_inside_clip_shape {
Comment on lines +567 to +569
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of widgets won't have ctx.clips_contents() as true, right? So we could avoid doing the content box fetching and position checking.

Suggested change
let is_inside_clip_shape = ctx.content_box().contains(local_pos);
if ctx.clips_contents() && !is_inside_clip_shape {
if ctx.clips_contents() && !ctx.content_box().contains(local_pos) {

return None;
}

Expand All @@ -580,6 +582,7 @@ pub fn find_widget_under_pointer<'c>(
}
}

// TODO - Use some variant of clip shape instead.
// If no child is under pointer, test the current widget.
if ctx.accepts_pointer_interaction() && ctx.border_box().contains(local_pos) {
Some(WidgetRef { widget, ctx })
Expand Down
66 changes: 45 additions & 21 deletions masonry_core/src/core/widget_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,17 @@ pub(crate) struct WidgetState {
/// The pixel-snapped position of the baseline in the parent's border-box coordinate space.
pub(crate) baseline_y: f64,

// TODO - Use general Shape
// Currently Kurbo doesn't really provide a type that lets us
// efficiently hold an arbitrary shape.
/// The widget's clip path in the widget's border-box coordinate space.
///
/// This clips the painting of `Widget::paint` and all the painting of children.
/// It does not clip this widget's `Widget::pre_paint` nor `Widget::post_paint`.
pub(crate) clip_path: Option<Rect>,
// TODO - Add clip_shape
// TODO - Use more efficient type and avoid allocating by default.
// /// The widget's clip path in the widget's border-box coordinate space.
// ///
// /// This clips the painting of `Widget::paint` and all the painting of children.
// /// It does not clip this widget's `Widget::pre_paint` nor `Widget::post_paint`.
// pub(crate) clip_path: Option<Rect>,
//
/// Whether the widget and its children's scenes are clipped by the
/// [clip shape](crate::doc::masonry_concepts#clip-shape).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this say "clip shape" yet?

Copy link
Copy Markdown
Contributor Author

@PoignardAzur PoignardAzur Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the documentation link is already valid.

pub(crate) clips_contents: bool,

/// Local transform used during the mapping of this widget's border-box coordinate space
/// to the parent's border-box coordinate space.
Expand Down Expand Up @@ -275,7 +278,7 @@ impl WidgetState {
bounding_box: Rect::ZERO,
layout_baseline_offset: 0.0,
baseline_y: 0.0,
clip_path: Option::default(),
clips_contents: false,
transform: options.transform,
window_transform: Affine::IDENTITY,
scroll_translation: Vec2::ZERO,
Expand Down Expand Up @@ -405,24 +408,45 @@ impl WidgetState {
)
}

/// Returns the result of intersecting the widget's clip path (if any) with the given rect.
/// Returns the portion of the given rect, if any, that is within the widget's "clip space".
///
/// Both the argument and the result are in window coordinates.
/// If the widget doesn't clip its children, returns the input rect.
/// If the clip path is a non-rectangle, uses the clip paths' bounding box.
/// If the given rect doesn't intersect with the clipping box, returns `None`.
///
/// Returns `None` if the given rect is clipped out.
pub(crate) fn clip_child(&self, child_rect: Rect) -> Option<Rect> {
if let Some(clip_path) = self.clip_path {
let clip_path_global = self.window_transform.transform_rect_bbox(clip_path);
if clip_path_global.overlaps(child_rect) {
Some(clip_path_global.intersect(child_rect))
} else {
None
}
/// Both the argument and the result are in window coordinates.
pub(crate) fn clipped_child_box(&self, child_box: Rect) -> Option<Rect> {
if !self.clips_contents {
return Some(child_box);
}

let clip_rect = self.border_box_size().to_rect();

let bounding_box_global = self.window_transform.transform_rect_bbox(clip_rect);

if bounding_box_global.overlaps(child_box) {
Some(bounding_box_global.intersect(child_box))
} else {
Some(child_rect)
None
}
}

/// The [clip shape] in border-box space.
///
/// A widget's clip shape will have two effects:
/// - It serves as a mask for painting operations of this widget and its children.
/// Note that while all painting done by children will be clipped by this path,
/// only the painting done in `Widget::paint` by this widget itself will be clipped.
/// The remaining painting done in `Widget::pre_paint` and `Widget::post_paint` will not be clipped.
/// - Pointer events must be inside that shape to reach the widget's children.
///
/// This currently returns the border-box rect if `clips_contents` is true and `None` otherwise, but in the future we may want to support more complex clip shapes, in which case this method would need to be updated.
///
Comment on lines +439 to +444
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to keep these paint methods short and clickable links. Shortness also helps keep the line length under a 100 and links help keep docs fresh.

Similarly, let's break the long sentence into multiple lines so it's below 100.

Suggested change
/// only the painting done in `Widget::paint` by this widget itself will be clipped.
/// The remaining painting done in `Widget::pre_paint` and `Widget::post_paint` will not be clipped.
/// - Pointer events must be inside that shape to reach the widget's children.
///
/// This currently returns the border-box rect if `clips_contents` is true and `None` otherwise, but in the future we may want to support more complex clip shapes, in which case this method would need to be updated.
///
/// only the painting done in [`paint`] by this widget itself will be clipped.
/// The remaining painting done in [`pre_paint`] and [`post_paint`] will not be clipped.
/// - Pointer events must be inside that shape to reach the widget's children.
///
/// This currently returns the border-box rect if `clips_contents` is true and `None` otherwise,
/// but in the future we may want to support more complex clip shapes,
/// in which case this method would need to be updated.
///
/// [`paint`]: crate::core::Widget::paint
/// [`pre_paint`]: crate::core::Widget::pre_paint
/// [`post_paint`]: crate::core::Widget::post_paint

/// [clip shape]: crate::doc::masonry_concepts#clip-shape.
pub(crate) fn clip_shape(&self) -> Rect {
self.border_box_size().to_rect() - self.border_box_translation()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong, that translation means that it returns the border box rect in content-box coordinate space.

To return the border box rect in border-box coordinate space, you just do this:

Suggested change
self.border_box_size().to_rect() - self.border_box_translation()
self.border_box_size().to_rect()

If instead you wanted to actually clip to the content-box rect, then it would be:

Suggested change
self.border_box_size().to_rect() - self.border_box_translation()
self.border_box_size().to_rect() - self.border_box_insets

However, then a bunch of comments would need updating as well.

}

pub(crate) fn needs_rewrite_passes(&self) -> bool {
self.needs_layout
|| self.needs_compose
Expand Down
15 changes: 15 additions & 0 deletions masonry_core/src/doc/masonry_concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ Generally you'll want to convert any window coordinate space geometry into the w
Then easily operate on that geometry and finally convert the results back to the window's coordinate space.


## Clip shape

Widgets have a shape, usually one that matches their visual appearance, which has two purposes:

- Pointer events outside of that shape will not affect the pointer.
- If the widget is set to clip its contents, pointer events outside the clip shape won't affect the children either.
- If the widget is set to clip its contents, its scene and the children's scenes will be painted inside of the clip shape.

Currently, the clip shape is hardcoded to be a rect with the widget's size and position.

<!-- TODO: Rename to "widget shape" instead? -->
<!-- Need a better name. -->
<!-- TODO: Better integrate with box model documentation. -->


## Layers

A Masonry application is composed of layers.
Expand Down
2 changes: 1 addition & 1 deletion masonry_core/src/passes/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fn build_access_node(
if ctx.is_disabled() {
node.set_disabled();
}
if ctx.widget_state.clip_path.is_some() {
if ctx.widget_state.clips_contents {
node.set_clips_children();
}
if ctx.accepts_focus() && !ctx.is_disabled() {
Expand Down
4 changes: 3 additions & 1 deletion masonry_core/src/passes/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ fn compose_widget(
);
let parent_bounding_box = parent_state.bounding_box;

if let Some(child_bounding_box) = parent_state.clip_child(node.item.state.bounding_box) {
if let Some(child_bounding_box) =
parent_state.clipped_child_box(node.item.state.bounding_box)
{
parent_state.bounding_box = parent_bounding_box.union(child_bounding_box);
}

Expand Down
11 changes: 6 additions & 5 deletions masonry_core/src/passes/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fn paint_widget(
let transform = state
.window_transform
.pre_translate(state.border_box_translation());
let has_clip = state.clip_path.is_some();
let clips_contents = state.clips_contents;
if !is_stashed {
let Some((pre_scene, scene, _)) = &mut scene_cache.get(&id) else {
debug_panic!(
Expand All @@ -91,9 +91,10 @@ fn paint_widget(

complete_scene.append(pre_scene, Some(transform));

if let Some(clip) = state.clip_path {
// The clip path is stored in border-box space, so need just window transform.
complete_scene.push_clip_layer(Fill::NonZero, state.window_transform, &clip);
if clips_contents {
let clip_shape = state.clip_shape();
// The clip shape is in border-box space, so need just window transform.
complete_scene.push_clip_layer(Fill::NonZero, state.window_transform, &clip_shape);
}

complete_scene.append(scene, Some(transform));
Expand Down Expand Up @@ -127,7 +128,7 @@ fn paint_widget(
stroke(complete_scene, &rect, color, BORDER_WIDTH);
}

if has_clip {
if clips_contents {
complete_scene.pop_layer();
}

Expand Down