diff --git a/masonry/src/contexts.rs b/masonry/src/contexts.rs index ae8a36098..a9df1e8a9 100644 --- a/masonry/src/contexts.rs +++ b/masonry/src/contexts.rs @@ -10,7 +10,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; @@ -18,9 +17,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. @@ -282,8 +282,9 @@ impl_context_method!( self.widget_state.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() } pub fn paint_rect(&self) -> Rect { @@ -302,7 +303,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 } } ); @@ -561,6 +562,12 @@ impl_context_method!(MutateCtx<'_>, EventCtx<'_>, UpdateCtx<'_>, { self.widget_state.request_compose = true; } + pub fn transform_changed(&mut self) { + trace!("transform_changed"); + self.widget_state.transform_changed = true; + self.request_compose(); + } + /// Request an animation frame. pub fn request_anim_frame(&mut self) { trace!("request_anim_frame"); @@ -608,6 +615,11 @@ impl_context_method!(MutateCtx<'_>, EventCtx<'_>, UpdateCtx<'_>, { self.widget_state.needs_update_disabled = true; self.widget_state.is_explicitly_disabled = disabled; } + + pub fn set_transform(&mut self, transform: Affine) { + self.widget_state.transform = transform; + self.transform_changed(); + } }); // --- MARK: OTHER METHODS --- @@ -875,7 +887,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; }; @@ -885,7 +897,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); @@ -968,7 +980,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() @@ -1116,7 +1128,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; @@ -1136,7 +1148,7 @@ impl ComposeCtx<'_> { /// Set a 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, @@ -1147,7 +1159,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(), @@ -1155,9 +1167,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; } } } diff --git a/masonry/src/event.rs b/masonry/src/event.rs index d53b62200..db7d98ecb 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; @@ -379,6 +380,12 @@ impl PointerEvent { Self::Pinch(_, _) => true, } } + + // TODO Logical/PhysicalPosition as return type instead? + 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) + } } impl TextEvent { diff --git a/masonry/src/passes/accessibility.rs b/masonry/src/passes/accessibility.rs index 03b61ff25..70569b994 100644 --- a/masonry/src/passes/accessibility.rs +++ b/masonry/src/passes/accessibility.rs @@ -91,7 +91,7 @@ fn build_accessibility_tree( // --- MARK: BUILD NODE --- fn build_access_node(widget: &mut dyn Widget, ctx: &mut AccessCtx) -> 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.bounding_rect())); node.set_children( widget diff --git a/masonry/src/passes/compose.rs b/masonry/src/passes/compose.rs index 71eb76242..780fb1ddb 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.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); }, ); @@ -86,6 +108,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 fb059b44c..2e8904e68 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; @@ -53,7 +53,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 { @@ -63,7 +63,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, @@ -93,9 +93,9 @@ fn paint_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 e22f7ae42..6286fef80 100644 --- a/masonry/src/passes/update.rs +++ b/masonry/src/passes/update.rs @@ -556,7 +556,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 da96bec17..ea8479c47 100644 --- a/masonry/src/testing/harness.rs +++ b/masonry/src/testing/harness.rs @@ -456,9 +456,11 @@ impl TestHarness { /// - If the widget is scrolled out of view. #[track_caller] pub fn mouse_move_to(&mut self, id: WidgetId) { + // FIXME - handle case where the widget isn't visible + // FIXME - assert that the widget correctly receives the event otherwise? 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 d6afe054b..cf752e73d 100644 --- a/masonry/src/testing/helper_widgets.rs +++ b/masonry/src/testing/helper_widgets.rs @@ -16,7 +16,7 @@ use accesskit::{Node, Role}; use smallvec::SmallVec; use tracing::trace_span; use vello::Scene; -use widget::widget::get_child_at_pos; +use widget::widget::{find_widget_at_pos, AsDynWidget as _}; use widget::WidgetRef; use crate::event::{PointerEvent, TextEvent}; @@ -390,12 +390,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 { @@ -590,12 +596,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 77a7dbaa4..a0c1a276a 100644 --- a/masonry/src/widget/flex.rs +++ b/masonry/src/widget/flex.rs @@ -1148,7 +1148,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 9d4a4ba90..da8a849d9 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -56,6 +56,7 @@ pub use widget_pod::WidgetPod; pub use widget_ref::WidgetRef; 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 1f741c991..6da044388 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 6475051e4..6a97f8e71 100644 --- a/masonry/src/widget/scroll_bar.rs +++ b/masonry/src/widget/scroll_bar.rs @@ -124,14 +124,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); @@ -144,16 +142,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/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/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 a21b93052..9da224621 100644 --- a/masonry/src/widget/widget.rs +++ b/masonry/src/widget/widget.rs @@ -52,6 +52,22 @@ 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. /// @@ -74,7 +90,7 @@ impl WidgetId { /// widget is mutated either during a method call (eg `on_event` or `update`) or /// through a [`WidgetMut`](crate::widget::WidgetMut). #[allow(unused_variables)] -pub trait Widget: AsAny { +pub trait Widget: AsAny + AsDynWidget { /// Handle an event - usually user interaction. /// /// A number of different events (in the [`Event`] enum) are handled in this @@ -245,23 +261,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. @@ -303,35 +324,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() - .map_or(true, |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. @@ -492,12 +516,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..7a80d945f 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,10 @@ impl WidgetMut<'_, W> { widget, } } + + 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 9228a9a09..0ddd6f48b 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}; // TODO - rewrite links in doc @@ -24,8 +24,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, } @@ -41,19 +46,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, } } @@ -70,11 +87,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 aa5689e29..e02a15f46 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 a834b622f..e58d2bb8d 100644 --- a/masonry/src/widget/widget_state.rs +++ b/masonry/src/widget/widget_state.rs @@ -3,7 +3,7 @@ #![cfg(not(tarpaulin_include))] -use vello::kurbo::{Insets, Point, Rect, Size, Vec2}; +use vello::kurbo::{Affine, Insets, Point, Rect, Size, Vec2}; use crate::WidgetId; @@ -44,8 +44,6 @@ pub(crate) struct WidgetState { /// The origin of the widget in the parent's 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. @@ -53,6 +51,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. + 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 @@ -79,9 +79,11 @@ 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, + pub(crate) transform: Affine, + pub(crate) scroll_translation: Vec2, + pub(crate) transform_changed: bool, // --- PASSES --- /// `WidgetAdded` hasn't been sent to this widget yet. @@ -151,11 +153,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, @@ -165,8 +166,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, @@ -192,6 +193,9 @@ impl WidgetState { update_focus_chain: true, #[cfg(debug_assertions)] widget_name, + window_transform: Affine::IDENTITY, + bounding_rect: Rect::ZERO, + transform, } } @@ -215,7 +219,7 @@ impl WidgetState { needs_update_stashed: false, children_changed: false, update_focus_chain: false, - ..Self::new(id, "") + ..Self::new(id, "", Affine::IDENTITY) } } @@ -253,23 +257,21 @@ impl WidgetState { Rect::from_origin_size(self.origin, self.size) } - /// The [`layout_rect`](crate::WidgetPod::layout_rect) in window coordinates. - /// - /// 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) + /// The axis aligned bounding rect of this widget in window coordinates. + 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/http_cats.rs b/xilem/examples/http_cats.rs index fd351f586..8d863a39c 100644 --- a/xilem/examples/http_cats.rs +++ b/xilem/examples/http_cats.rs @@ -7,6 +7,7 @@ #![expect(clippy::match_same_arms, reason = "Deferred: Noisy")] #![expect(clippy::missing_assert_message, reason = "Deferred: Noisy")] +use std::f64::consts::PI; use std::sync::Arc; use vello::peniko::{Blob, Image}; @@ -17,7 +18,7 @@ use xilem::core::fork; use xilem::core::one_of::OneOf3; use xilem::view::{ button, flex, image, inline_prose, portal, prose, sized_box, spinner, worker, Axis, FlexExt, - FlexSpacer, Padding, + FlexSpacer, Padding, Transformable, }; use xilem::{palette, EventLoop, EventLoopBuilder, TextAlignment, WidgetView, Xilem}; @@ -46,13 +47,17 @@ enum ImageState { impl HttpCats { fn view(&mut self) -> impl WidgetView { - let left_column = sized_box(portal(flex(( - prose("Status"), - self.statuses - .iter_mut() - .map(Status::list_view) - .collect::>(), - )))) + let left_column = sized_box( + portal(flex(( + prose("Status"), + self.statuses + .iter_mut() + .map(Status::list_view) + .collect::>(), + ))) + .rotate(PI * 0.125) + .translate((200.0, 0.0)), + ) .padding(Padding::leading(5.)); let (info_area, worker_value) = if let Some(selected_code) = self.selected_code { diff --git a/xilem/examples/mason.rs b/xilem/examples/mason.rs index b48f7478c..12281e28f 100644 --- a/xilem/examples/mason.rs +++ b/xilem/examples/mason.rs @@ -7,14 +7,13 @@ #![windows_subsystem = "windows"] #![expect(clippy::shadow_unrelated, reason = "Idiomatic for Xilem users")] -use std::time::Duration; - +use std::time::{Duration, Instant}; use winit::error::EventLoopError; use xilem::core::{fork, run_once}; use xilem::tokio::time; use xilem::view::{ button, button_any_pointer, checkbox, flex, label, prose, task, textbox, Axis, FlexExt as _, - FlexSpacer, + FlexSpacer, Transformable, }; use xilem::{ palette, Color, EventLoop, EventLoopBuilder, FontWeight, TextAlignment, WidgetView, Xilem, @@ -98,10 +97,26 @@ fn app_logic(data: &mut AppData) -> impl WidgetView { )), // The following `task` view only exists whilst the example is in the "active" state, so // the updates it performs will only be running whilst we are in that state. - data.active.then(|| { + ( + data.active.then(|| { + task( + |proxy| async move { + let mut interval = time::interval(Duration::from_secs(1)); + loop { + interval.tick().await; + let Ok(()) = proxy.message(()) else { + break; + }; + } + }, + |data: &mut AppData, ()| { + data.count += 1; + }, + ) + }), task( |proxy| async move { - let mut interval = time::interval(Duration::from_secs(1)); + let mut interval = time::interval(Duration::from_secs_f64(1.0 / 60.0)); loop { interval.tick().await; let Ok(()) = proxy.message(()) else { @@ -110,15 +125,16 @@ fn app_logic(data: &mut AppData) -> impl WidgetView { } }, |data: &mut AppData, ()| { - data.count += 1; + data.current_instant = Instant::now(); }, - ) - }), + ), + ), ) } fn toggleable(data: &mut AppData) -> impl WidgetView { if data.active { + let secs_since_start = (data.current_instant - data.start_instant).as_secs_f64(); fork( flex(( button("Deactivate", |data: &mut AppData| { @@ -126,8 +142,12 @@ fn toggleable(data: &mut AppData) -> impl WidgetView { }), button("Unlimited Power", |data: &mut AppData| { data.count = -1_000_000; - }), + }) + .translate((-100.0, -20.0)) + .rotate(secs_since_start) + .scale(secs_since_start.sin() + 1.5), )) + .scale(((secs_since_start + 0.5).sin() + 1.5) * 0.5) .direction(Axis::Horizontal), run_once(|| tracing::warn!("The pathway to unlimited power has been revealed")), ) @@ -139,6 +159,8 @@ fn toggleable(data: &mut AppData) -> impl WidgetView { struct AppData { textbox_contents: String, + current_instant: Instant, + start_instant: Instant, count: i32, active: bool, } @@ -146,8 +168,10 @@ struct AppData { fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> { let data = AppData { count: 0, + current_instant: Instant::now(), + start_instant: Instant::now(), textbox_contents: "Not quite a placeholder".into(), - active: false, + active: true, }; Xilem::new(data, app_logic) diff --git a/xilem/examples/transforms.rs b/xilem/examples/transforms.rs new file mode 100644 index 000000000..d061ba2e7 --- /dev/null +++ b/xilem/examples/transforms.rs @@ -0,0 +1,93 @@ +// 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, GridExt as _, Transformable as _}; +use xilem::{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 status = if rotation_correct && scale_correct && translation_correct { + label("Great success!") + } 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}" + )) + }; + + // Every view can be transformed similar as with CSS transforms in the web. + // Currently only 2D transforms are supported. + let transformed_status = status + .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); + } +