Skip to content

Commit

Permalink
Support transforms for each widget (#753)
Browse files Browse the repository at this point in the history
Adds basic support for transforms (`kurbo::Affine`) of widgets in
Masonry and Xilem, similarly as CSS transforms.
A new example `transforms` shows this in a playful way.
The pointer-intersection logic needed a core change to handle now
possibly overlapping widgets.

Checkout
[this](https://xi.zulipchat.com/#narrow/channel/317477-masonry/topic/Add.20.60Affine.60.20transform.20to.20.60Widget.60.20trait.3F)
zulip thread for more info/discussion.
  • Loading branch information
Philipp-M authored Jan 14, 2025
1 parent 8fd5bde commit 9bb33dd
Show file tree
Hide file tree
Showing 43 changed files with 730 additions and 201 deletions.
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# LFS settings
# If changing, also change in .github/workflows/ci.yml
masonry/src/widget/screenshots/*.png filter=lfs diff=lfs merge=lfs -text
masonry/src/**/screenshots/*.png filter=lfs diff=lfs merge=lfs -text
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ jobs:
with:
path: .git/lfs
# The files stored in git lfs are all in this folder
key: masonry-lfs-${{ hashFiles('masonry/src/widget/screenshots/*.png') }}
key: masonry-lfs-${{ hashFiles('masonry/src/**/screenshots/*.png') }}
restore-keys: masonry-lfs-
enableCrossOsArchive: true

Expand Down Expand Up @@ -248,7 +248,7 @@ jobs:
with:
path: .git/lfs
# The files stored in git lfs are all in this folder
key: masonry-lfs-${{ hashFiles('masonry/src/widget/screenshots/*.png') }}
key: masonry-lfs-${{ hashFiles('masonry/src/**/screenshots/*.png') }}
enableCrossOsArchive: true

- name: Checkout LFS files
Expand Down
51 changes: 32 additions & 19 deletions masonry/src/contexts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ use dpi::LogicalPosition;
use parley::{FontContext, LayoutContext};
use tracing::{trace, warn};
use tree_arena::{ArenaMutChildren, ArenaRefChildren};
use vello::kurbo::Vec2;
use winit::window::ResizeDirection;

use crate::action::Action;
use crate::passes::layout::run_layout_on;
use crate::render_root::{MutateCallback, RenderRootSignal, RenderRootState};
use crate::text::BrushIndex;
use crate::theme::get_debug_color;
use crate::widget::{WidgetMut, WidgetRef, WidgetState};
use crate::widget::{CreateWidget, WidgetMut, WidgetRef, WidgetState};
use crate::{
AllowRawMut, BoxConstraints, Color, Insets, Point, Rect, Size, Widget, WidgetId, WidgetPod,
Affine, AllowRawMut, BoxConstraints, Color, Insets, Point, Rect, Size, Vec2, Widget, WidgetId,
WidgetPod,
};

// Note - Most methods defined in this file revolve around `WidgetState` fields.
Expand Down Expand Up @@ -482,7 +482,7 @@ impl LayoutCtx<'_> {
}
if origin != self.get_child_state_mut(child).origin {
self.get_child_state_mut(child).origin = origin;
self.get_child_state_mut(child).translation_changed = true;
self.get_child_state_mut(child).transform_changed = true;
}
self.get_child_state_mut(child)
.is_expecting_place_child_call = false;
Expand Down Expand Up @@ -524,7 +524,7 @@ impl LayoutCtx<'_> {
) -> Insets {
self.assert_layout_done(child, "compute_insets_from_child");
self.assert_placed(child, "compute_insets_from_child");
let parent_bounds = Rect::ZERO.with_size(my_size);
let parent_bounds = my_size.to_rect();
let union_paint_rect = self
.get_child_state(child)
.paint_rect()
Expand Down Expand Up @@ -652,10 +652,10 @@ impl ComposeCtx<'_> {
self.widget_state.needs_compose
}

/// Set a translation for the child widget.
/// Set the scroll translation for the child widget.
///
/// The translation is applied on top of the position from [`LayoutCtx::place_child`].
pub fn set_child_translation<W: Widget>(
pub fn set_child_scroll_translation<W: Widget>(
&mut self,
child: &mut WidgetPod<W>,
translation: Vec2,
Expand All @@ -666,17 +666,17 @@ impl ComposeCtx<'_> {
|| translation.y.is_infinite()
{
debug_panic!(
"Error in {}: trying to call 'set_child_translation' with child '{}' {} with invalid translation {:?}",
"Error in {}: trying to call 'set_child_scroll_translation' with child '{}' {} with invalid translation {:?}",
self.widget_id(),
self.get_child(child).short_type_name(),
child.id(),
translation,
);
}
let child = self.get_child_state_mut(child);
if translation != child.translation {
child.translation = translation;
child.translation_changed = true;
if translation != child.scroll_translation {
child.scroll_translation = translation;
child.transform_changed = true;
}
}
}
Expand Down Expand Up @@ -723,11 +723,9 @@ impl_context_method!(
self.widget_state.window_origin()
}

/// The layout rect of the widget in window coordinates.
///
/// Combines the [size](Self::size) and [window origin](Self::window_origin).
pub fn window_layout_rect(&self) -> Rect {
self.widget_state.window_layout_rect()
/// The axis aligned bounding rect of this widget in window coordinates.
pub fn bounding_rect(&self) -> Rect {
self.widget_state.bounding_rect()
}

// TODO - Remove? See above.
Expand All @@ -750,7 +748,7 @@ impl_context_method!(
///
/// The returned point is relative to the content area; it excludes window chrome.
pub fn to_window(&self, widget_point: Point) -> Point {
self.window_origin() + widget_point.to_vec2()
self.widget_state.window_transform * widget_point
}
}
);
Expand Down Expand Up @@ -939,6 +937,13 @@ impl_context_method!(MutateCtx<'_>, EventCtx<'_>, UpdateCtx<'_>, {
self.request_layout();
}

/// Indicate that the transform of this widget has changed.
pub fn transform_changed(&mut self) {
trace!("transform_changed");
self.widget_state.transform_changed = true;
self.request_compose();
}

/// Indicate that a child is about to be removed from the tree.
///
/// Container widgets should avoid dropping `WidgetPod`s. Instead, they should
Expand Down Expand Up @@ -969,6 +974,14 @@ impl_context_method!(MutateCtx<'_>, EventCtx<'_>, UpdateCtx<'_>, {
self.widget_state.needs_update_disabled = true;
self.widget_state.is_explicitly_disabled = disabled;
}

/// Set the transform for this widget.
///
/// It behaves similarly as CSS transforms
pub fn set_transform(&mut self, transform: Affine) {
self.widget_state.transform = transform;
self.transform_changed();
}
});

// --- MARK: OTHER METHODS ---
Expand Down Expand Up @@ -1117,7 +1130,7 @@ impl RegisterCtx<'_> {
/// Container widgets should call this on all their children in
/// their implementation of [`Widget::register_children`].
pub fn register_child(&mut self, child: &mut WidgetPod<impl Widget>) {
let Some(widget) = child.take_inner() else {
let Some(CreateWidget { widget, transform }) = child.take_inner() else {
return;
};

Expand All @@ -1127,7 +1140,7 @@ impl RegisterCtx<'_> {
}

let id = child.id();
let state = WidgetState::new(child.id(), widget.short_type_name());
let state = WidgetState::new(child.id(), widget.short_type_name(), transform);

self.widget_children.insert_child(id, Box::new(widget));
self.widget_state_children.insert_child(id, state);
Expand Down
8 changes: 8 additions & 0 deletions masonry/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use std::path::PathBuf;

use vello::kurbo::Point;
use winit::event::{Force, Ime, KeyEvent, Modifiers};
use winit::keyboard::ModifiersState;

Expand Down Expand Up @@ -430,6 +431,13 @@ impl PointerEvent {
}
}

// TODO Logical/PhysicalPosition as return type instead?
/// Returns the position of this event in local (the widget's) coordinate space.
pub fn local_position(&self, ctx: &crate::EventCtx) -> Point {
let position = self.pointer_state().position;
ctx.widget_state.window_transform.inverse() * Point::new(position.x, position.y)
}

/// Create a [`PointerEvent::PointerLeave`] event with dummy values.
///
/// This is used internally to create synthetic `PointerLeave` events when pointer
Expand Down
21 changes: 15 additions & 6 deletions masonry/src/passes/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,8 @@ fn build_accessibility_tree(
tree_update,
rebuild_all,
};
let mut node = build_access_node(widget.item, &mut ctx);
let mut node = build_access_node(widget.item, &mut ctx, scale_factor);
widget.item.accessibility(&mut ctx, &mut node);
if let Some(scale_factor) = scale_factor {
node.set_transform(accesskit::Affine::scale(scale_factor));
}

let id: NodeId = ctx.widget_state.id.into();
if ctx.global_state.trace.access {
Expand Down Expand Up @@ -89,9 +86,21 @@ fn build_accessibility_tree(
}

// --- MARK: BUILD NODE ---
fn build_access_node(widget: &mut dyn Widget, ctx: &mut AccessCtx) -> Node {
fn build_access_node(
widget: &mut dyn Widget,
ctx: &mut AccessCtx,
scale_factor: Option<f64>,
) -> Node {
let mut node = Node::new(widget.accessibility_role());
node.set_bounds(to_accesskit_rect(ctx.widget_state.window_layout_rect()));
node.set_bounds(to_accesskit_rect(ctx.widget_state.size.to_rect()));

let local_translation = ctx.widget_state.scroll_translation + ctx.widget_state.origin.to_vec2();
let mut local_transform = ctx.widget_state.transform.then_translate(local_translation);

if let Some(scale_factor) = scale_factor {
local_transform = local_transform.pre_scale(scale_factor);
}
node.set_transform(accesskit::Affine::new(local_transform.as_coeffs()));

node.set_children(
widget
Expand Down
44 changes: 33 additions & 11 deletions masonry/src/passes/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use tracing::info_span;
use tree_arena::ArenaMut;
use vello::kurbo::Vec2;
use vello::kurbo::Affine;

use crate::passes::{enter_span_if, recurse_on_children};
use crate::render_root::{RenderRoot, RenderRootState};
Expand All @@ -14,8 +14,8 @@ fn compose_widget(
global_state: &mut RenderRootState,
mut widget: ArenaMut<'_, Box<dyn Widget>>,
mut state: ArenaMut<'_, WidgetState>,
parent_moved: bool,
parent_translation: Vec2,
parent_transformed: bool,
parent_window_transform: Affine,
) {
let _span = enter_span_if(
global_state.trace.compose,
Expand All @@ -24,14 +24,21 @@ fn compose_widget(
state.reborrow(),
);

let moved = parent_moved || state.item.translation_changed;
let translation = parent_translation + state.item.translation + state.item.origin.to_vec2();
state.item.window_origin = translation.to_point();
let transformed = parent_transformed || state.item.transform_changed;

if !parent_moved && !state.item.translation_changed && !state.item.needs_compose {
if !transformed && !state.item.needs_compose {
return;
}

// the translation needs to be applied *after* applying the transform, as translation by scrolling should be within the transformed coordinate space. Same is true for the (layout) origin, to behave similar as in CSS.
let local_translation = state.item.scroll_translation + state.item.origin.to_vec2();

state.item.window_transform =
parent_window_transform * state.item.transform.then_translate(local_translation);

let local_rect = state.item.size.to_rect() + state.item.paint_insets;
state.item.bounding_rect = state.item.window_transform.transform_rect_bbox(local_rect);

let mut ctx = ComposeCtx {
global_state,
widget_state: state.item,
Expand All @@ -49,9 +56,10 @@ fn compose_widget(

state.item.needs_compose = false;
state.item.request_compose = false;
state.item.translation_changed = false;
state.item.transform_changed = false;

let id = state.item.id;
let parent_transform = state.item.window_transform;
let parent_state = state.item;
recurse_on_children(
id,
Expand All @@ -62,9 +70,23 @@ fn compose_widget(
global_state,
widget,
state.reborrow_mut(),
moved,
translation,
transformed,
parent_transform,
);
let parent_bounding_rect = parent_state.bounding_rect;

// This could be further optimized by more tightly clipping the child bounding rect according to the clip path.
let clipped_child_bounding_rect = if let Some(clip_path) = parent_state.clip_path {
let clip_path_bounding_rect =
parent_state.window_transform.transform_rect_bbox(clip_path);
state.item.bounding_rect.intersect(clip_path_bounding_rect)
} else {
state.item.bounding_rect
};
if !clipped_child_bounding_rect.is_zero_area() {
parent_state.bounding_rect =
parent_bounding_rect.union(clipped_child_bounding_rect);
}
parent_state.merge_up(state.item);
},
);
Expand All @@ -87,6 +109,6 @@ pub(crate) fn run_compose_pass(root: &mut RenderRoot) {
root_widget,
root_state,
false,
Vec2::ZERO,
Affine::IDENTITY,
);
}
11 changes: 6 additions & 5 deletions masonry/src/passes/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ use std::collections::HashMap;

use tracing::{info_span, trace};
use tree_arena::ArenaMut;
use vello::kurbo::{Affine, Stroke};
use vello::peniko::Mix;
use vello::Scene;

use crate::paint_scene_helpers::stroke;
use crate::passes::{enter_span_if, recurse_on_children};
use crate::render_root::{RenderRoot, RenderRootState};
use crate::theme::get_debug_color;
Expand Down Expand Up @@ -54,7 +54,7 @@ fn paint_widget(

let clip = state.item.clip_path;
let has_clip = clip.is_some();
let transform = Affine::translate(state.item.window_origin.to_vec2());
let transform = state.item.window_transform;
let scene = scenes.get(&id).unwrap();

if let Some(clip) = clip {
Expand All @@ -64,7 +64,7 @@ fn paint_widget(
complete_scene.append(scene, Some(transform));

let id = state.item.id;
let size = state.item.size;
let bounding_rect = state.item.bounding_rect;
let parent_state = state.item;
recurse_on_children(
id,
Expand Down Expand Up @@ -92,11 +92,12 @@ fn paint_widget(
},
);

// draw the global axis aligned bounding rect of the widget
if debug_paint {
const BORDER_WIDTH: f64 = 1.0;
let rect = size.to_rect().inset(BORDER_WIDTH / -2.0);
let color = get_debug_color(id.to_raw());
complete_scene.stroke(&Stroke::new(BORDER_WIDTH), transform, color, None, &rect);
let rect = bounding_rect.inset(BORDER_WIDTH / -2.0);
stroke(complete_scene, &rect, color, BORDER_WIDTH);
}

if has_clip {
Expand Down
2 changes: 1 addition & 1 deletion masonry/src/passes/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ pub(crate) fn run_update_scroll_pass(root: &mut RenderRoot) {
// is more accurate.

let state = &ctx.widget_state;
target_rect = target_rect + state.translation + state.origin.to_vec2();
target_rect = target_rect + state.scroll_translation + state.origin.to_vec2();
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions masonry/src/testing/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,8 @@ impl TestHarness {
#[track_caller]
pub fn mouse_move_to(&mut self, id: WidgetId) {
let widget = self.get_widget(id);
let widget_rect = widget.ctx().window_layout_rect();
let widget_center = widget_rect.center();
let local_widget_center = (widget.ctx().size() / 2.0).to_vec2().to_point();
let widget_center = widget.ctx().widget_state.window_transform * local_widget_center;

if !widget.ctx().accepts_pointer_interaction() {
panic!("Widget {id} doesn't accept pointer events");
Expand Down
Loading

0 comments on commit 9bb33dd

Please sign in to comment.