From 9bb33dd1c6c8dda4ab62866be502b535d21ac8eb Mon Sep 17 00:00:00 2001 From: Philipp Mildenberger Date: Tue, 14 Jan 2025 10:59:31 +0100 Subject: [PATCH] Support transforms for each widget (#753) 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. --- .gitattributes | 2 +- .github/workflows/ci.yml | 4 +- masonry/src/contexts.rs | 51 +++++---- masonry/src/event.rs | 8 ++ masonry/src/passes/accessibility.rs | 21 +++- masonry/src/passes/compose.rs | 44 ++++++-- masonry/src/passes/paint.rs | 11 +- masonry/src/passes/update.rs | 2 +- masonry/src/testing/harness.rs | 4 +- masonry/src/testing/helper_widgets.rs | 20 ++-- masonry/src/widget/flex.rs | 2 +- masonry/src/widget/mod.rs | 1 + masonry/src/widget/portal.rs | 2 +- masonry/src/widget/scroll_bar.rs | 12 +- masonry/src/widget/tests/mod.rs | 1 + ..._transforms__transforms_pointer_events.png | 3 + ...forms__transforms_translation_rotation.png | 3 + masonry/src/widget/tests/status_change.rs | 8 +- masonry/src/widget/tests/transforms.rs | 56 ++++++++++ masonry/src/widget/text_area.rs | 14 +-- masonry/src/widget/widget.rs | 95 ++++++++++------ masonry/src/widget/widget_mut.rs | 9 +- masonry/src/widget/widget_pod.rs | 39 +++++-- masonry/src/widget/widget_ref.rs | 20 +--- masonry/src/widget/widget_state.rs | 44 +++++--- xilem/examples/transforms.rs | 104 ++++++++++++++++++ xilem/src/lib.rs | 8 +- xilem/src/one_of.rs | 30 ++++- xilem/src/view/button.rs | 38 +++++-- xilem/src/view/checkbox.rs | 20 +++- xilem/src/view/flex.rs | 18 ++- xilem/src/view/grid.rs | 18 ++- xilem/src/view/image.rs | 19 +++- xilem/src/view/label.rs | 18 ++- xilem/src/view/mod.rs | 39 +++++++ xilem/src/view/portal.rs | 18 ++- xilem/src/view/progress_bar.rs | 23 +++- xilem/src/view/prose.rs | 18 ++- xilem/src/view/sized_box.rs | 18 ++- xilem/src/view/spinner.rs | 22 +++- xilem/src/view/textbox.rs | 17 ++- xilem/src/view/variable_label.rs | 8 +- xilem/src/view/zstack.rs | 19 +++- 43 files changed, 730 insertions(+), 201 deletions(-) create mode 100644 masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_pointer_events.png create mode 100644 masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_translation_rotation.png create mode 100644 masonry/src/widget/tests/transforms.rs create mode 100644 xilem/examples/transforms.rs diff --git a/.gitattributes b/.gitattributes index 3ca6856c2..0dd30b929 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceb39d0b4..c05b222a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/masonry/src/contexts.rs b/masonry/src/contexts.rs index a42fc3a1b..383882960 100644 --- a/masonry/src/contexts.rs +++ b/masonry/src/contexts.rs @@ -8,7 +8,6 @@ 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; @@ -16,9 +15,10 @@ 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. @@ -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; @@ -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() @@ -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( + pub fn set_child_scroll_translation( &mut self, child: &mut WidgetPod, translation: Vec2, @@ -666,7 +666,7 @@ 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(), @@ -674,9 +674,9 @@ impl ComposeCtx<'_> { ); } 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; } } } @@ -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. @@ -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 } } ); @@ -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 @@ -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 --- @@ -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) { - let Some(widget) = child.take_inner() else { + let Some(CreateWidget { widget, transform }) = child.take_inner() else { return; }; @@ -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); diff --git a/masonry/src/event.rs b/masonry/src/event.rs index d11e33784..711acc2d0 100644 --- a/masonry/src/event.rs +++ b/masonry/src/event.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; +use vello::kurbo::Point; use winit::event::{Force, Ime, KeyEvent, Modifiers}; use winit::keyboard::ModifiersState; @@ -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 diff --git a/masonry/src/passes/accessibility.rs b/masonry/src/passes/accessibility.rs index f60ed90de..eaf763009 100644 --- a/masonry/src/passes/accessibility.rs +++ b/masonry/src/passes/accessibility.rs @@ -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 { @@ -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, +) -> 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 diff --git a/masonry/src/passes/compose.rs b/masonry/src/passes/compose.rs index 50561cfd5..a97f98fd7 100644 --- a/masonry/src/passes/compose.rs +++ b/masonry/src/passes/compose.rs @@ -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}; @@ -14,8 +14,8 @@ fn compose_widget( global_state: &mut RenderRootState, mut widget: ArenaMut<'_, Box>, 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, @@ -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, @@ -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, @@ -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); }, ); @@ -87,6 +109,6 @@ pub(crate) fn run_compose_pass(root: &mut RenderRoot) { root_widget, root_state, false, - Vec2::ZERO, + Affine::IDENTITY, ); } diff --git a/masonry/src/passes/paint.rs b/masonry/src/passes/paint.rs index 8c6324010..1b8cf53f5 100644 --- a/masonry/src/passes/paint.rs +++ b/masonry/src/passes/paint.rs @@ -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; @@ -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 { @@ -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, @@ -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 { diff --git a/masonry/src/passes/update.rs b/masonry/src/passes/update.rs index a37a27894..b0b9a6f49 100644 --- a/masonry/src/passes/update.rs +++ b/masonry/src/passes/update.rs @@ -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(); }); } } diff --git a/masonry/src/testing/harness.rs b/masonry/src/testing/harness.rs index 3455bc4b6..ae8fe384b 100644 --- a/masonry/src/testing/harness.rs +++ b/masonry/src/testing/harness.rs @@ -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"); diff --git a/masonry/src/testing/helper_widgets.rs b/masonry/src/testing/helper_widgets.rs index 162eeb574..e906a3b70 100644 --- a/masonry/src/testing/helper_widgets.rs +++ b/masonry/src/testing/helper_widgets.rs @@ -18,7 +18,7 @@ use tracing::trace_span; use vello::Scene; use crate::event::{PointerEvent, TextEvent}; -use crate::widget::widget::get_child_at_pos; +use crate::widget::widget::{find_widget_at_pos, AsDynWidget as _}; use crate::widget::{SizedBox, WidgetRef}; use crate::{ AccessCtx, AccessEvent, AsAny, BoxConstraints, ComposeCtx, CursorIcon, EventCtx, LayoutCtx, @@ -401,12 +401,18 @@ impl Widget for ModularWidget { CursorIcon::Default } - fn get_child_at_pos<'c>( - &self, + fn find_widget_at_pos<'c>( + &'c self, ctx: QueryCtx<'c>, pos: Point, ) -> Option> { - get_child_at_pos(self, ctx, pos) + find_widget_at_pos( + &WidgetRef { + widget: self.as_dyn(), + ctx, + }, + pos, + ) } fn type_name(&self) -> &'static str { @@ -601,12 +607,12 @@ impl Widget for Recorder { self.child.get_cursor(ctx, pos) } - fn get_child_at_pos<'c>( - &self, + fn find_widget_at_pos<'c>( + &'c self, ctx: QueryCtx<'c>, pos: Point, ) -> Option> { - self.child.get_child_at_pos(ctx, pos) + self.child.find_widget_at_pos(ctx, pos) } fn type_name(&self) -> &'static str { diff --git a/masonry/src/widget/flex.rs b/masonry/src/widget/flex.rs index ce9a861bd..2e1d6d084 100644 --- a/masonry/src/widget/flex.rs +++ b/masonry/src/widget/flex.rs @@ -1146,7 +1146,7 @@ impl Widget for Flex { // or be clipped (e.g. if its parent is a Portal). let my_size: Size = self.direction.pack(major, minor_dim).into(); - let my_bounds = Rect::ZERO.with_size(my_size); + let my_bounds = my_size.to_rect(); let insets = child_paint_rect - my_bounds; ctx.set_paint_insets(insets); diff --git a/masonry/src/widget/mod.rs b/masonry/src/widget/mod.rs index 9d19e8e55..378749fcc 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -61,6 +61,7 @@ pub use widget_ref::WidgetRef; pub use zstack::{Alignment, ChildAlignment, HorizontalAlignment, VerticalAlignment, ZStack}; pub(crate) use widget_arena::WidgetArena; +pub(crate) use widget_pod::CreateWidget; pub(crate) use widget_state::WidgetState; use crate::{Affine, Size}; diff --git a/masonry/src/widget/portal.rs b/masonry/src/widget/portal.rs index 36fa616a7..2bbe45c30 100644 --- a/masonry/src/widget/portal.rs +++ b/masonry/src/widget/portal.rs @@ -426,7 +426,7 @@ impl Widget for Portal { } fn compose(&mut self, ctx: &mut ComposeCtx) { - ctx.set_child_translation(&mut self.child, Vec2::new(0.0, -self.viewport_pos.y)); + ctx.set_child_scroll_translation(&mut self.child, Vec2::new(0.0, -self.viewport_pos.y)); } fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut Scene) {} diff --git a/masonry/src/widget/scroll_bar.rs b/masonry/src/widget/scroll_bar.rs index 0e7f96b4e..cfd1c64b9 100644 --- a/masonry/src/widget/scroll_bar.rs +++ b/masonry/src/widget/scroll_bar.rs @@ -119,14 +119,12 @@ impl ScrollBar { impl Widget for ScrollBar { fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { match event { - PointerEvent::PointerDown(_, state) => { + PointerEvent::PointerDown(_, _) => { ctx.capture_pointer(); let cursor_min_length = theme::SCROLLBAR_MIN_SIZE; let cursor_rect = self.get_cursor_rect(ctx.size(), cursor_min_length); - - let mouse_pos = - Point::new(state.position.x, state.position.y) - ctx.window_origin().to_vec2(); + let mouse_pos = event.local_position(ctx); if cursor_rect.contains(mouse_pos) { let (z0, z1) = self.axis.major_span(cursor_rect); let mouse_major = self.axis.major_pos(mouse_pos); @@ -139,16 +137,14 @@ impl Widget for ScrollBar { }; ctx.request_render(); } - PointerEvent::PointerMove(state) => { - let mouse_pos = - Point::new(state.position.x, state.position.y) - ctx.window_origin().to_vec2(); + PointerEvent::PointerMove(_) => { if let Some(grab_anchor) = self.grab_anchor { let cursor_min_length = theme::SCROLLBAR_MIN_SIZE; self.cursor_progress = self.progress_from_mouse_pos( ctx.size(), cursor_min_length, grab_anchor, - mouse_pos, + event.local_position(ctx), ); self.moved = true; } diff --git a/masonry/src/widget/tests/mod.rs b/masonry/src/widget/tests/mod.rs index b1bfde3aa..5a60f9053 100644 --- a/masonry/src/widget/tests/mod.rs +++ b/masonry/src/widget/tests/mod.rs @@ -11,4 +11,5 @@ mod lifecycle_disable; mod lifecycle_focus; mod safety_rails; mod status_change; +mod transforms; mod widget_tree; diff --git a/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_pointer_events.png b/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_pointer_events.png new file mode 100644 index 000000000..4c9379f9a --- /dev/null +++ b/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_pointer_events.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e5aa3920a321bcd64db228eff1ebe5b8e9186893ef00ad1a10f30ab8c6d1f35 +size 21630 diff --git a/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_translation_rotation.png b/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_translation_rotation.png new file mode 100644 index 000000000..a8168de95 --- /dev/null +++ b/masonry/src/widget/tests/screenshots/masonry__widget__tests__transforms__transforms_translation_rotation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14d51a14e7d9f74fc47f419941f9b36784cf39337e07239ba5c1a80ed6ff6d67 +size 15905 diff --git a/masonry/src/widget/tests/status_change.rs b/masonry/src/widget/tests/status_change.rs index 7b355a9bb..689f94519 100644 --- a/masonry/src/widget/tests/status_change.rs +++ b/masonry/src/widget/tests/status_change.rs @@ -72,10 +72,10 @@ fn propagate_hovered() { harness.mouse_move_to(empty); - dbg!(harness.get_widget(button).ctx().window_layout_rect()); - dbg!(harness.get_widget(pad).ctx().window_layout_rect()); - dbg!(harness.get_widget(root).ctx().window_layout_rect()); - dbg!(harness.get_widget(empty).ctx().window_layout_rect()); + dbg!(harness.get_widget(button).ctx().bounding_rect()); + dbg!(harness.get_widget(pad).ctx().bounding_rect()); + dbg!(harness.get_widget(root).ctx().bounding_rect()); + dbg!(harness.get_widget(empty).ctx().bounding_rect()); eprintln!("root: {root:?}"); eprintln!("empty: {empty:?}"); diff --git a/masonry/src/widget/tests/transforms.rs b/masonry/src/widget/tests/transforms.rs new file mode 100644 index 000000000..f0ec1da72 --- /dev/null +++ b/masonry/src/widget/tests/transforms.rs @@ -0,0 +1,56 @@ +// Copyright 2025 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests related to transforms. + +use std::f64::consts::PI; + +use vello::kurbo::{Affine, Vec2}; +use vello::peniko::color::palette; + +use crate::testing::TestHarness; +use crate::widget::{Alignment, Button, ChildAlignment, Label, SizedBox, ZStack}; +use crate::{assert_render_snapshot, PointerButton, Widget, WidgetPod}; + +fn blue_box(inner: impl Widget) -> SizedBox { + SizedBox::new(inner) + .width(200.) + .height(100.) + .background(palette::css::BLUE) + .border(palette::css::TEAL, 2.) +} + +#[test] +fn transforms_translation_rotation() { + let translation = Vec2::new(100.0, 50.0); + let transformed_widget = WidgetPod::new_with_transform( + blue_box(Label::new("Background")), + // Currently there's no support for changing the transform-origin, which is currently at the top left. + // This rotates around the center of the widget + Affine::translate(-translation) + .then_rotate(PI * 0.25) + .then_translate(translation), + ) + .boxed(); + let widget = ZStack::new().with_child_pod(transformed_widget, ChildAlignment::ParentAligned); + + let mut harness = TestHarness::create(widget); + assert_render_snapshot!(harness, "transforms_translation_rotation"); +} + +#[test] +fn transforms_pointer_events() { + let transformed_widget = WidgetPod::new_with_transform( + blue_box( + ZStack::new().with_child(Button::new("Should be pressed"), Alignment::BottomTrailing), + ), + Affine::rotate(PI * 0.125).then_translate(Vec2::new(100.0, 50.0)), + ) + .boxed(); + let widget = ZStack::new().with_child_pod(transformed_widget, ChildAlignment::ParentAligned); + + let mut harness = TestHarness::create(widget); + harness.mouse_move((335.0, 350.0)); // Should hit the last "d" of the button text + harness.mouse_button_press(PointerButton::Primary); + assert_render_snapshot!(harness, "transforms_pointer_events"); +} diff --git a/masonry/src/widget/text_area.rs b/masonry/src/widget/text_area.rs index 8780afed1..7e5b7e797 100644 --- a/masonry/src/widget/text_area.rs +++ b/masonry/src/widget/text_area.rs @@ -485,15 +485,11 @@ impl Widget for TextArea { return; } - let window_origin = ctx.widget_state.window_origin(); let (fctx, lctx) = ctx.text_contexts(); let is_rtl = self.editor.layout(fctx, lctx).is_rtl(); - let inner_origin = Point::new( - window_origin.x + self.padding.get_left(is_rtl), - window_origin.y + self.padding.top, - ); + let padding = Vec2::new(self.padding.get_left(is_rtl), self.padding.top); match event { - PointerEvent::PointerDown(button, state) => { + PointerEvent::PointerDown(button, _) => { if !ctx.is_disabled() && *button == PointerButton::Primary { let now = Instant::now(); if let Some(last) = self.last_click_time.take() { @@ -507,7 +503,7 @@ impl Widget for TextArea { } self.last_click_time = Some(now); let click_count = self.click_count; - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; + let cursor_pos = event.local_position(ctx) - padding; let (fctx, lctx) = ctx.text_contexts(); let mut drv = self.editor.driver(fctx, lctx); match click_count { @@ -525,9 +521,9 @@ impl Widget for TextArea { ctx.capture_pointer(); } } - PointerEvent::PointerMove(state) => { + PointerEvent::PointerMove(_) => { if !ctx.is_disabled() && ctx.has_pointer_capture() { - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; + let cursor_pos = event.local_position(ctx) - padding; let (fctx, lctx) = ctx.text_contexts(); self.editor .driver(fctx, lctx) diff --git a/masonry/src/widget/widget.rs b/masonry/src/widget/widget.rs index 64e945824..59fb1cb31 100644 --- a/masonry/src/widget/widget.rs +++ b/masonry/src/widget/widget.rs @@ -51,6 +51,23 @@ impl WidgetId { } } +/// A trait to access a `Widget` as trait object. It is implemented for all types that implement `Widget`. +pub trait AsDynWidget { + fn as_dyn(&self) -> &dyn Widget; + fn as_mut_dyn(&mut self) -> &mut dyn Widget; +} + +impl AsDynWidget for T { + fn as_dyn(&self) -> &dyn Widget { + self as &dyn Widget + } + + fn as_mut_dyn(&mut self) -> &mut dyn Widget { + self as &mut dyn Widget + } +} + +// TODO - Add tutorial: implementing a widget - See https://github.com/linebender/xilem/issues/376 /// The trait implemented by all widgets. /// /// For details on how to implement this trait, see the [tutorials](crate::doc). @@ -76,7 +93,7 @@ impl WidgetId { /// through [`WidgetPod`](crate::WidgetPod)s. Widget methods are called by Masonry, and a /// widget should only be mutated either during a method call or through a [`WidgetMut`](crate::widget::WidgetMut). #[allow(unused_variables)] -pub trait Widget: AsAny { +pub trait Widget: AsAny + AsDynWidget { /// Handle a pointer event. /// /// Pointer events will target the widget under the pointer, and then the @@ -255,23 +272,28 @@ pub trait Widget: AsAny { // --- Auto-generated implementations --- - /// Return which child, if any, has the given `pos` in its layout rect. In case of overlapping - /// children, the last child as determined by [`Widget::children_ids`] is chosen. No child is - /// returned if `pos` is outside the widget's clip path. + /// Return the first innermost widget composed by this (including `self`), that contains/intersects with `pos` and accepts pointer interaction, if any. /// - /// The child returned is a direct child, not e.g. a grand-child. + /// In case of overlapping children, the last child as determined by [`Widget::children_ids`] is chosen. No widget is + /// returned if `pos` is outside the widget's clip path. /// /// Has a default implementation that can be overridden to search children more efficiently. /// Custom implementations must uphold the conditions outlined above. /// /// **pos** - the position in global coordinates (e.g. `(0,0)` is the top-left corner of the /// window). - fn get_child_at_pos<'c>( - &self, + fn find_widget_at_pos<'c>( + &'c self, ctx: QueryCtx<'c>, pos: Point, ) -> Option> { - get_child_at_pos(self, ctx, pos) + find_widget_at_pos( + &WidgetRef { + widget: self.as_dyn(), + ctx, + }, + pos, + ) } /// Get the (verbose) type name of the widget for debugging purposes. @@ -313,35 +335,38 @@ pub trait Widget: AsAny { } } -pub(crate) fn get_child_at_pos<'c>( - widget: &(impl Widget + ?Sized), - ctx: QueryCtx<'c>, +/// See [`Widget::find_widget_at_pos`] for more details. +pub fn find_widget_at_pos<'c>( + widget: &WidgetRef<'c, dyn Widget>, pos: Point, ) -> Option> { - let relative_pos = pos - ctx.window_origin().to_vec2(); - if !ctx - .clip_path() - .is_none_or(|clip| clip.contains(relative_pos)) - { - return None; - } - - // Assumes `Self::children_ids` is in increasing "z-order", picking the last child in case - // of overlapping children. - for child_id in widget.children_ids().iter().rev() { - let child = ctx.get(*child_id); - - // The position must be inside the child's layout and inside the child's clip path (if - // any). - if !child.ctx().is_stashed() - && child.ctx().accepts_pointer_interaction() - && child.ctx().window_layout_rect().contains(pos) + if widget.ctx.widget_state.bounding_rect.contains(pos) { + let local_pos = widget.ctx().widget_state.window_transform.inverse() * pos; + + if widget.ctx.is_stashed() + || Some(false) == widget.ctx.clip_path().map(|clip| clip.contains(local_pos)) { - return Some(child); + return None; } - } - None + // Assumes `Self::children_ids` is in increasing "z-order", picking the last child in case + // of overlapping children. + for child_id in widget.children_ids().iter().rev() { + let child_ref = widget.ctx.get(*child_id); + if let Some(child) = child_ref.widget.find_widget_at_pos(child_ref.ctx, pos) { + return Some(child); + } + } + if widget.ctx.accepts_pointer_interaction() + && widget.ctx.size().to_rect().contains(local_pos) + { + Some(*widget) + } else { + None + } + } else { + None + } } /// Marker trait for Widgets whose parents can get a raw mutable reference to them. @@ -502,12 +527,12 @@ impl Widget for Box { self.deref().get_cursor(ctx, pos) } - fn get_child_at_pos<'c>( - &self, + fn find_widget_at_pos<'c>( + &'c self, ctx: QueryCtx<'c>, pos: Point, ) -> Option> { - self.deref().get_child_at_pos(ctx, pos) + self.deref().find_widget_at_pos(ctx, pos) } fn as_any(&self) -> &dyn Any { diff --git a/masonry/src/widget/widget_mut.rs b/masonry/src/widget/widget_mut.rs index aaeb48230..df577ea93 100644 --- a/masonry/src/widget/widget_mut.rs +++ b/masonry/src/widget/widget_mut.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::contexts::MutateCtx; -use crate::Widget; +use crate::{Affine, Widget}; // TODO - Document extension trait workaround. // See https://xi.zulipchat.com/#narrow/stream/317477-masonry/topic/Thoughts.20on.20simplifying.20WidgetMut/near/436478885 @@ -47,6 +47,13 @@ impl WidgetMut<'_, W> { widget, } } + + /// Set the local transform of this widget. + /// + /// It behaves similarly as CSS transforms. + pub fn set_transform(&mut self, transform: Affine) { + self.ctx.set_transform(transform); + } } impl WidgetMut<'_, Box> { diff --git a/masonry/src/widget/widget_pod.rs b/masonry/src/widget/widget_pod.rs index f7b31ca63..61476d2b2 100644 --- a/masonry/src/widget/widget_pod.rs +++ b/masonry/src/widget/widget_pod.rs @@ -1,7 +1,7 @@ // Copyright 2018 the Xilem Authors and the Druid Authors // SPDX-License-Identifier: Apache-2.0 -use crate::{Widget, WidgetId}; +use crate::{Affine, Widget, WidgetId}; /// A container for one widget in the hierarchy. /// @@ -20,8 +20,13 @@ pub struct WidgetPod { // through context methods where they already have access to the arena. // Implementing that requires solving non-trivial design questions. +pub(crate) struct CreateWidget { + pub(crate) widget: W, + pub(crate) transform: Affine, +} + enum WidgetPodInner { - Created(W), + Create(CreateWidget), Inserted, } @@ -37,19 +42,31 @@ impl WidgetPod { /// Create a new widget pod with fixed id. pub fn new_with_id(inner: W, id: WidgetId) -> Self { + Self::new_with_id_and_transform(inner, id, Affine::IDENTITY) + } + + /// Create a new widget pod with a custom transform. + pub fn new_with_transform(inner: W, transform: Affine) -> Self { + Self::new_with_id_and_transform(inner, WidgetId::next(), transform) + } + + pub fn new_with_id_and_transform(inner: W, id: WidgetId, transform: Affine) -> Self { Self { id, - inner: WidgetPodInner::Created(inner), + inner: WidgetPodInner::Create(CreateWidget { + widget: inner, + transform, + }), } } pub(crate) fn incomplete(&self) -> bool { - matches!(self.inner, WidgetPodInner::Created(_)) + matches!(self.inner, WidgetPodInner::Create(_)) } - pub(crate) fn take_inner(&mut self) -> Option { + pub(crate) fn take_inner(&mut self) -> Option> { match std::mem::replace(&mut self.inner, WidgetPodInner::Inserted) { - WidgetPodInner::Created(widget) => Some(widget), + WidgetPodInner::Create(widget) => Some(widget), WidgetPodInner::Inserted => None, } } @@ -66,11 +83,9 @@ impl WidgetPod { /// Convert a `WidgetPod` containing a widget of a specific concrete type /// into a dynamically boxed widget. pub fn boxed(self) -> WidgetPod> { - match self.inner { - WidgetPodInner::Created(inner) => WidgetPod::new_with_id(Box::new(inner), self.id), - WidgetPodInner::Inserted => { - panic!("Cannot box a widget after it has been inserted into the widget graph") - } - } + let WidgetPodInner::Create(inner) = self.inner else { + panic!("Cannot box a widget after it has been inserted into the widget graph") + }; + WidgetPod::new_with_id_and_transform(Box::new(inner.widget), self.id, inner.transform) } } diff --git a/masonry/src/widget/widget_ref.rs b/masonry/src/widget/widget_ref.rs index e7615795f..1c32474ce 100644 --- a/masonry/src/widget/widget_ref.rs +++ b/masonry/src/widget/widget_ref.rs @@ -159,29 +159,13 @@ impl WidgetRef<'_, dyn Widget> { } /// Recursively find the innermost widget at the given position, using - /// [`Widget::get_child_at_pos`] to descend the widget tree. If `self` does not contain the + /// [`Widget::find_widget_at_pos`] to descend the widget tree. If `self` does not contain the /// given position in its layout rect or clip path, this returns `None`. /// /// **pos** - the position in global coordinates (e.g. `(0,0)` is the top-left corner of the /// window). pub fn find_widget_at_pos(&self, pos: Point) -> Option { - let mut innermost_widget = *self; - - if !self.ctx.window_layout_rect().contains(pos) { - return None; - } - - // TODO: add debug assertion to check whether the child returned by - // `Widget::get_child_at_pos` upholds the conditions of that method. See - // https://github.com/linebender/xilem/pull/565#discussion_r1756536870 - while let Some(child) = innermost_widget - .widget - .get_child_at_pos(innermost_widget.ctx, pos) - { - innermost_widget = child; - } - - Some(innermost_widget) + self.widget.find_widget_at_pos(self.ctx, pos) } } diff --git a/masonry/src/widget/widget_state.rs b/masonry/src/widget/widget_state.rs index 45d091ebb..5e6e1f95c 100644 --- a/masonry/src/widget/widget_state.rs +++ b/masonry/src/widget/widget_state.rs @@ -1,7 +1,7 @@ // Copyright 2018 the Xilem Authors and the Druid Authors // SPDX-License-Identifier: Apache-2.0 -use vello::kurbo::{Insets, Point, Rect, Size, Vec2}; +use vello::kurbo::{Affine, Insets, Point, Rect, Size, Vec2}; use crate::WidgetId; @@ -58,11 +58,9 @@ pub(crate) struct WidgetState { /// The size of the widget; this is the value returned by the widget's layout /// method. pub(crate) size: Size, - /// The origin of the widget in the parent's coordinate space; together with + /// The origin of the widget in the `window_transform` coordinate space; together with /// `size` these constitute the widget's layout rect. pub(crate) origin: Point, - /// The origin of the widget in the window coordinate space; - pub(crate) window_origin: Point, /// The insets applied to the layout rect to generate the paint rect. /// In general, these will be zero; the exception is for things like /// drop shadows or overflowing text. @@ -70,6 +68,8 @@ pub(crate) struct WidgetState { // TODO - Document // The computed paint rect, in local coordinates. pub(crate) local_paint_rect: Rect, + /// An axis aligned bounding rect (AABB in 2D), containing itself and all its descendents in window coordinates. Includes `paint_insets`. + pub(crate) bounding_rect: Rect, /// The offset of the baseline relative to the bottom of the widget. /// /// In general, this will be zero; the bottom of the widget will be considered @@ -96,9 +96,14 @@ pub(crate) struct WidgetState { // efficiently hold an arbitrary shape. pub(crate) clip_path: Option, - // TODO - Handle matrix transforms - pub(crate) translation: Vec2, - pub(crate) translation_changed: bool, + /// This is being computed out of all ancestor transforms and `translation` + pub(crate) window_transform: Affine, + /// Local transform of this widget in the parent coordinate space. + pub(crate) transform: Affine, + /// translation applied by scrolling, this is applied after applying `transform` to this widget. + pub(crate) scroll_translation: Vec2, + /// The `transform` or `scroll_translation` has changed. + pub(crate) transform_changed: bool, // --- PASSES --- /// `WidgetAdded` hasn't been sent to this widget yet. @@ -168,11 +173,10 @@ pub(crate) struct WidgetState { } impl WidgetState { - pub(crate) fn new(id: WidgetId, widget_name: &'static str) -> Self { + pub(crate) fn new(id: WidgetId, widget_name: &'static str, transform: Affine) -> Self { Self { id, origin: Point::ORIGIN, - window_origin: Point::ORIGIN, size: Size::ZERO, is_expecting_place_child_call: false, paint_insets: Insets::ZERO, @@ -182,8 +186,8 @@ impl WidgetState { accepts_text_input: false, ime_area: None, clip_path: Default::default(), - translation: Vec2::ZERO, - translation_changed: false, + scroll_translation: Vec2::ZERO, + transform_changed: false, is_explicitly_disabled: false, is_explicitly_stashed: false, is_disabled: false, @@ -209,6 +213,9 @@ impl WidgetState { needs_update_focus_chain: true, #[cfg(debug_assertions)] widget_name, + window_transform: Affine::IDENTITY, + bounding_rect: Rect::ZERO, + transform, } } @@ -232,7 +239,7 @@ impl WidgetState { needs_update_stashed: false, children_changed: false, needs_update_focus_chain: false, - ..Self::new(id, "") + ..Self::new(id, "", Affine::IDENTITY) } } @@ -267,23 +274,24 @@ impl WidgetState { Rect::from_origin_size(self.origin, self.size) } - /// The [`layout_rect`](Self::layout_rect) in window coordinates. + /// The axis aligned bounding rect of this widget in window coordinates. Includes `paint_insets`. /// /// This might not map to a visible area of the screen, eg if the widget is scrolled /// away. - pub fn window_layout_rect(&self) -> Rect { - Rect::from_origin_size(self.window_origin(), self.size) + pub fn bounding_rect(&self) -> Rect { + self.bounding_rect } /// Returns the area being edited by an IME, in global coordinates. /// - /// By default, returns the same as [`Self::window_layout_rect`]. + /// By default, returns the same as [`Self::bounding_rect`]. pub(crate) fn get_ime_area(&self) -> Rect { - self.ime_area.unwrap_or_else(|| self.size.to_rect()) + self.window_origin.to_vec2() + self.window_transform + .transform_rect_bbox(self.ime_area.unwrap_or_else(|| self.size.to_rect())) } pub(crate) fn window_origin(&self) -> Point { - self.window_origin + self.window_transform.translation().to_point() } pub(crate) fn needs_rewrite_passes(&self) -> bool { diff --git a/xilem/examples/transforms.rs b/xilem/examples/transforms.rs new file mode 100644 index 000000000..83c7b4665 --- /dev/null +++ b/xilem/examples/transforms.rs @@ -0,0 +1,104 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The transform for all views can be modified similar to CSS transforms. + +use std::f64::consts::{PI, TAU}; + +use winit::error::EventLoopError; +use xilem::view::{button, grid, label, sized_box, GridExt as _, Transformable as _}; +use xilem::{Color, EventLoop, Vec2, WidgetView, Xilem}; + +struct TransformsGame { + rotation: f64, + translation: Vec2, + scale: f64, +} + +impl TransformsGame { + fn view(&mut self) -> impl WidgetView { + let rotation_correct = (self.rotation % TAU).abs() < 0.001; + let scale_correct = self.scale >= 0.99 && self.scale <= 1.01; + let translation_correct = self.translation.x == 0.0 && self.translation.y == 0.0; + let everything_correct = rotation_correct && scale_correct && translation_correct; + + let status = if everything_correct { + label("Great success!") + .brush(Color::new([0.0, 0.0, 1.0, 1.0])) + .text_size(30.0) + } else { + let rotation_mark = if rotation_correct { "✓" } else { "⨯" }; + let scale_mark = if scale_correct { "✓" } else { "⨯" }; + let translation_mark = if translation_correct { "✓" } else { "⨯" }; + label(format!( + "rotation: {rotation_mark}\nscale: {scale_mark}\ntranslation: {translation_mark}" + )) + }; + + let bg_color = if everything_correct { + [0.0, 1.0, 0.0, 1.0] + } else { + [1.0, 0.0, 0.0, 0.2] + }; + + // Every view can be transformed similar as with CSS transforms in the web. + // Currently only 2D transforms are supported. + // Note that the order of the transformations is relevant. + let transformed_status = sized_box(status) + .background(Color::new(bg_color)) + .translate(self.translation) + .rotate(self.rotation) + .scale(self.scale); + + let controls = ( + button("↶", |this: &mut Self| { + this.rotation -= PI * 0.125; + }) + .grid_pos(0, 0), + button("↑", |this: &mut Self| { + this.translation.y -= 10.0; + }) + .grid_pos(1, 0), + button("↷", |this: &mut Self| { + this.rotation += PI * 0.125; + }) + .grid_pos(2, 0), + button("←", |this: &mut Self| { + this.translation.x -= 10.0; + }) + .grid_pos(0, 1), + button("→", |this: &mut Self| { + this.translation.x += 10.0; + }) + .grid_pos(2, 1), + button("-", |this: &mut Self| { + // 2 ^ (1/3) for 3 clicks to reach the target. + this.scale /= 1.2599210498948732; + }) + .grid_pos(0, 2), + button("↓", |this: &mut Self| { + this.translation.y += 10.0; + }) + .grid_pos(1, 2), + button("+", |this: &mut Self| { + this.scale *= 1.2599210498948732; + }) + .grid_pos(2, 2), + ); + + grid((controls, transformed_status.grid_pos(1, 1)), 3, 3) + } +} + +fn main() -> Result<(), EventLoopError> { + let app = Xilem::new( + TransformsGame { + rotation: PI * 0.25, + translation: Vec2::new(20.0, 30.0), + scale: 2.0, + }, + TransformsGame::view, + ); + app.run_windowed(EventLoop::with_user_event(), "Transforms".into())?; + Ok(()) +} diff --git a/xilem/src/lib.rs b/xilem/src/lib.rs index d94f810fa..6d899c0df 100644 --- a/xilem/src/lib.rs +++ b/xilem/src/lib.rs @@ -53,7 +53,7 @@ use crate::core::{ ViewPathTracker, ViewSequence, }; pub use masonry::event_loop_runner::{EventLoop, EventLoopBuilder}; -pub use masonry::{dpi, palette, Color, FontWeight, TextAlignment}; +pub use masonry::{dpi, palette, Affine, Color, FontWeight, TextAlignment, Vec2}; pub use xilem_core as core; /// Tokio is the async runner used with Xilem. @@ -292,6 +292,12 @@ impl ViewCtx { } } + pub fn new_pod_with_transform(&mut self, widget: W, transform: Affine) -> Pod { + Pod { + inner: WidgetPod::new_with_transform(widget, transform), + } + } + pub fn boxed_pod(&mut self, pod: Pod) -> Pod> { Pod { inner: pod.inner.boxed(), diff --git a/xilem/src/one_of.rs b/xilem/src/one_of.rs index ff0d4d28f..61c474d81 100644 --- a/xilem/src/one_of.rs +++ b/xilem/src/one_of.rs @@ -13,7 +13,8 @@ use vello::Scene; use crate::core::one_of::OneOf; use crate::core::Mut; -use crate::{Pod, ViewCtx}; +use crate::view::Transformable; +use crate::{Affine, Pod, ViewCtx}; impl< A: Widget, @@ -142,6 +143,33 @@ impl< } } +impl Transformable for OneOf +where + A: Transformable, + B: Transformable, + C: Transformable, + D: Transformable, + E: Transformable, + F: Transformable, + G: Transformable, + H: Transformable, + I: Transformable, +{ + fn transform_mut(&mut self) -> &mut Affine { + match self { + Self::A(w) => w.transform_mut(), + Self::B(w) => w.transform_mut(), + Self::C(w) => w.transform_mut(), + Self::D(w) => w.transform_mut(), + Self::E(w) => w.transform_mut(), + Self::F(w) => w.transform_mut(), + Self::G(w) => w.transform_mut(), + Self::H(w) => w.transform_mut(), + Self::I(w) => w.transform_mut(), + } + } +} + impl crate::core::one_of::PhantomElementCtx for ViewCtx { type PhantomElement = Pod>; } diff --git a/xilem/src/view/button.rs b/xilem/src/view/button.rs index efc96bd33..171d1949e 100644 --- a/xilem/src/view/button.rs +++ b/xilem/src/view/button.rs @@ -7,7 +7,9 @@ pub use masonry::PointerButton; use crate::core::{DynMessage, Mut, View, ViewMarker}; use crate::view::Label; -use crate::{MessageResult, Pod, ViewCtx, ViewId}; +use crate::{Affine, MessageResult, Pod, ViewCtx, ViewId}; + +use super::Transformable; /// A button which calls `callback` when the primary mouse button (normally left) is pressed. pub fn button( @@ -17,6 +19,7 @@ pub fn button( { Button { label: label.into(), + transform: Affine::IDENTITY, callback: move |state: &mut State, button| match button { PointerButton::Primary => MessageResult::Action(callback(state)), _ => MessageResult::Nop, @@ -32,6 +35,7 @@ pub fn button_any_pointer( { Button { label: label.into(), + transform: Affine::IDENTITY, callback: move |state: &mut State, button| MessageResult::Action(callback(state, button)), } } @@ -39,9 +43,16 @@ pub fn button_any_pointer( #[must_use = "View values do nothing unless provided to Xilem."] pub struct Button { label: Label, + transform: Affine, callback: F, } +impl Transformable for Button { + fn transform_mut(&mut self) -> &mut Affine { + &mut self.transform + } +} + impl ViewMarker for Button {} impl View for Button where @@ -52,15 +63,18 @@ where fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { ctx.with_leaf_action_widget(|ctx| { - ctx.new_pod(widget::Button::from_label( - // TODO: Use `Label::build` here - currently impossible because `Pod` uses `WidgetPod` internally - widget::Label::new(self.label.label.clone()) - .with_brush(self.label.text_brush.clone()) - .with_alignment(self.label.alignment) - .with_style(StyleProperty::FontSize(self.label.text_size)) - .with_style(StyleProperty::FontWeight(self.label.weight)) - .with_style(StyleProperty::FontStack(self.label.font.clone())), - )) + ctx.new_pod_with_transform( + widget::Button::from_label( + // TODO: Use `Label::build` here - currently impossible because `Pod` uses `WidgetPod` internally + widget::Label::new(self.label.label.clone()) + .with_brush(self.label.text_brush.clone()) + .with_alignment(self.label.alignment) + .with_style(StyleProperty::FontSize(self.label.text_size)) + .with_style(StyleProperty::FontWeight(self.label.weight)) + .with_style(StyleProperty::FontStack(self.label.font.clone())), + ), + self.transform, + ) }) } @@ -71,6 +85,10 @@ where ctx: &mut ViewCtx, mut element: Mut, ) { + if prev.transform != self.transform { + element.set_transform(self.transform); + } +