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.
This commit is contained in:
Philipp Mildenberger 2025-01-14 10:59:31 +01:00 committed by GitHub
parent 8fd5bde369
commit 9bb33dd1c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 729 additions and 200 deletions

2
.gitattributes vendored
View File

@ -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

View File

@ -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

View File

@ -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<W: Widget>(
pub fn set_child_scroll_translation<W: Widget>(
&mut self,
child: &mut WidgetPod<W>,
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<impl Widget>) {
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);

View File

@ -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

View File

@ -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<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

View File

@ -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<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,
@ -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,
);
}

View File

@ -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 {

View File

@ -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();
});
}
}

View File

@ -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");

View File

@ -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<S: 'static> Widget for ModularWidget<S> {
CursorIcon::Default
}
fn get_child_at_pos<'c>(
&self,
fn find_widget_at_pos<'c>(
&'c self,
ctx: QueryCtx<'c>,
pos: Point,
) -> Option<WidgetRef<'c, dyn Widget>> {
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<W: Widget> Widget for Recorder<W> {
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<WidgetRef<'c, dyn Widget>> {
self.child.get_child_at_pos(ctx, pos)
self.child.find_widget_at_pos(ctx, pos)
}
fn type_name(&self) -> &'static str {

View File

@ -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);

View File

@ -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};

View File

@ -426,7 +426,7 @@ impl<W: Widget> Widget for Portal<W> {
}
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) {}

View File

@ -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;
}

View File

@ -11,4 +11,5 @@ mod lifecycle_disable;
mod lifecycle_focus;
mod safety_rails;
mod status_change;
mod transforms;
mod widget_tree;

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e5aa3920a321bcd64db228eff1ebe5b8e9186893ef00ad1a10f30ab8c6d1f35
size 21630

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:14d51a14e7d9f74fc47f419941f9b36784cf39337e07239ba5c1a80ed6ff6d67
size 15905

View File

@ -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:?}");

View File

@ -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");
}

View File

@ -485,15 +485,11 @@ impl<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
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<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
}
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<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
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)

View File

@ -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<T: Widget> 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<WidgetRef<'c, dyn Widget>> {
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<WidgetRef<'c, dyn Widget>> {
let relative_pos = pos - ctx.window_origin().to_vec2();
if !ctx
.clip_path()
.is_none_or(|clip| clip.contains(relative_pos))
{
return None;
}
if widget.ctx.widget_state.bounding_rect.contains(pos) {
let local_pos = widget.ctx().widget_state.window_transform.inverse() * pos;
// 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.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<dyn Widget> {
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<WidgetRef<'c, dyn Widget>> {
self.deref().get_child_at_pos(ctx, pos)
self.deref().find_widget_at_pos(ctx, pos)
}
fn as_any(&self) -> &dyn Any {

View File

@ -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<W: Widget> 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<dyn Widget>> {

View File

@ -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<W> {
// through context methods where they already have access to the arena.
// Implementing that requires solving non-trivial design questions.
pub(crate) struct CreateWidget<W> {
pub(crate) widget: W,
pub(crate) transform: Affine,
}
enum WidgetPodInner<W> {
Created(W),
Create(CreateWidget<W>),
Inserted,
}
@ -37,19 +42,31 @@ impl<W: Widget> WidgetPod<W> {
/// 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<W> {
pub(crate) fn take_inner(&mut self) -> Option<CreateWidget<W>> {
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<W: Widget + 'static> WidgetPod<W> {
/// Convert a `WidgetPod` containing a widget of a specific concrete type
/// into a dynamically boxed widget.
pub fn boxed(self) -> WidgetPod<Box<dyn Widget>> {
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)
}
}

View File

@ -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<Self> {
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)
}
}

View File

@ -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<Rect>,
// 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, "<root>")
..Self::new(id, "<root>", 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 {

View File

@ -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<Self> {
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(())
}

View File

@ -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<W: Widget>(&mut self, widget: W, transform: Affine) -> Pod<W> {
Pod {
inner: WidgetPod::new_with_transform(widget, transform),
}
}
pub fn boxed_pod<W: Widget>(&mut self, pod: Pod<W>) -> Pod<Box<dyn Widget>> {
Pod {
inner: pod.inner.boxed(),

View File

@ -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<A, B, C, D, E, F, G, H, I> Transformable for OneOf<A, B, C, D, E, F, G, H, I>
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<Box<dyn Widget>>;
}

View File

@ -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<State, Action>(
@ -17,6 +19,7 @@ pub fn button<State, Action>(
{
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<State, Action>(
{
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<State, Action>(
#[must_use = "View values do nothing unless provided to Xilem."]
pub struct Button<F> {
label: Label,
transform: Affine,
callback: F,
}
impl<F> Transformable for Button<F> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<F> ViewMarker for Button<F> {}
impl<F, State, Action> View<State, Action, ViewCtx> for Button<F>
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<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
<Label as View<State, Action, ViewCtx>>::rebuild(
&self.label,
&prev.label,

View File

@ -5,7 +5,9 @@ use masonry::text::ArcStr;
use masonry::widget;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
use crate::{Affine, MessageResult, Pod, View, ViewCtx, ViewId};
use super::Transformable;
pub fn checkbox<F, State, Action>(
label: impl Into<ArcStr>,
@ -19,6 +21,7 @@ where
label: label.into(),
callback,
checked,
transform: Affine::IDENTITY,
}
}
@ -27,6 +30,13 @@ pub struct Checkbox<F> {
label: ArcStr,
checked: bool,
callback: F,
transform: Affine,
}
impl<F> Transformable for Checkbox<F> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<F> ViewMarker for Checkbox<F> {}
@ -39,7 +49,10 @@ where
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
ctx.with_leaf_action_widget(|ctx| {
ctx.new_pod(widget::Checkbox::new(self.checked, self.label.clone()))
ctx.new_pod_with_transform(
widget::Checkbox::new(self.checked, self.label.clone()),
self.transform,
)
})
}
@ -50,6 +63,9 @@ where
_ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.label != self.label {
widget::Checkbox::set_text(&mut element, self.label.clone());
}

View File

@ -11,7 +11,7 @@ use crate::core::{
AppendVec, DynMessage, ElementSplice, MessageResult, Mut, SuperElement, View, ViewElement,
ViewId, ViewMarker, ViewPathTracker, ViewSequence,
};
use crate::{AnyWidgetView, Pod, ViewCtx, WidgetView};
use crate::{Affine, AnyWidgetView, Pod, ViewCtx, WidgetView};
pub fn flex<State, Action, Seq: FlexSequence<State, Action>>(
sequence: Seq,
@ -19,6 +19,7 @@ pub fn flex<State, Action, Seq: FlexSequence<State, Action>>(
Flex {
sequence,
axis: Axis::Vertical,
transform: Affine::IDENTITY,
cross_axis_alignment: CrossAxisAlignment::Center,
main_axis_alignment: MainAxisAlignment::Start,
fill_major_axis: false,
@ -31,6 +32,7 @@ pub fn flex<State, Action, Seq: FlexSequence<State, Action>>(
pub struct Flex<Seq, State, Action = ()> {
sequence: Seq,
axis: Axis,
transform: Affine,
cross_axis_alignment: CrossAxisAlignment,
main_axis_alignment: MainAxisAlignment,
fill_major_axis: bool,
@ -85,6 +87,12 @@ impl<Seq, State, Action> Flex<Seq, State, Action> {
}
}
impl<Seq, State, Action> Transformable for Flex<Seq, State, Action> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<Seq, State, Action> ViewMarker for Flex<Seq, State, Action> {}
impl<State, Action, Seq> View<State, Action, ViewCtx> for Flex<Seq, State, Action>
where
@ -113,7 +121,8 @@ where
FlexElement::FlexSpacer(flex) => widget.with_flex_spacer(flex),
}
}
(ctx.new_pod(widget), seq_state)
let pod = ctx.new_pod_with_transform(widget, self.transform);
(pod, seq_state)
}
fn rebuild(
@ -123,6 +132,9 @@ where
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.axis != self.axis {
widget::Flex::set_direction(&mut element, self.axis);
}
@ -634,6 +646,8 @@ mod hidden {
}
use hidden::AnyFlexChildState;
use super::Transformable;
impl<State, Action> ViewMarker for AnyFlexChild<State, Action> {}
impl<State, Action> View<State, Action, ViewCtx> for AnyFlexChild<State, Action>
where

View File

@ -10,7 +10,9 @@ use crate::core::{
AppendVec, DynMessage, ElementSplice, MessageResult, Mut, SuperElement, View, ViewElement,
ViewId, ViewMarker, ViewSequence,
};
use crate::{Pod, ViewCtx, WidgetView};
use crate::{Affine, Pod, ViewCtx, WidgetView};
use super::Transformable;
pub fn grid<State, Action, Seq: GridSequence<State, Action>>(
sequence: Seq,
@ -23,6 +25,7 @@ pub fn grid<State, Action, Seq: GridSequence<State, Action>>(
phantom: PhantomData,
height,
width,
transform: Affine::IDENTITY,
}
}
@ -32,6 +35,7 @@ pub struct Grid<Seq, State, Action = ()> {
spacing: f64,
width: i32,
height: i32,
transform: Affine,
/// Used to associate the State and Action in the call to `.grid()` with the State and Action
/// used in the View implementation, to allow inference to flow backwards, allowing State and
/// Action to be inferred properly
@ -50,6 +54,12 @@ impl<Seq, State, Action> Grid<Seq, State, Action> {
}
}
impl<Seq, State, Action> Transformable for Grid<Seq, State, Action> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<Seq, State, Action> ViewMarker for Grid<Seq, State, Action> {}
impl<State, Action, Seq> View<State, Action, ViewCtx> for Grid<Seq, State, Action>
@ -72,7 +82,8 @@ where
GridElement::Child(child, params) => widget.with_child_pod(child.inner, params),
}
}
(ctx.new_pod(widget), seq_state)
let pod = ctx.new_pod_with_transform(widget, self.transform);
(pod, seq_state)
}
fn rebuild(
@ -82,6 +93,9 @@ where
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.height != self.height {
widget::Grid::set_height(&mut element, self.height);
}

View File

@ -6,7 +6,9 @@
use masonry::widget::{self, ObjectFit};
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
use crate::{Affine, MessageResult, Pod, View, ViewCtx, ViewId};
use super::Transformable;
/// Displays the bitmap `image`.
///
@ -25,6 +27,7 @@ pub fn image(image: &vello::peniko::Image) -> Image {
// easier than documenting that cloning is cheap.
image: image.clone(),
object_fit: ObjectFit::default(),
transform: Affine::IDENTITY,
}
}
@ -35,6 +38,7 @@ pub fn image(image: &vello::peniko::Image) -> Image {
pub struct Image {
image: vello::peniko::Image,
object_fit: ObjectFit,
transform: Affine,
}
impl Image {
@ -45,13 +49,21 @@ impl Image {
}
}
impl Transformable for Image {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl ViewMarker for Image {}
impl<State, Action> View<State, Action, ViewCtx> for Image {
type Element = Pod<widget::Image>;
type ViewState = ();
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
(ctx.new_pod(widget::Image::new(self.image.clone())), ())
let pod =
ctx.new_pod_with_transform(widget::Image::new(self.image.clone()), self.transform);
(pod, ())
}
fn rebuild(
@ -61,6 +73,9 @@ impl<State, Action> View<State, Action, ViewCtx> for Image {
_: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.object_fit != self.object_fit {
widget::Image::set_fit_mode(&mut element, self.object_fit);
}

View File

@ -7,7 +7,9 @@ use masonry::widget;
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
use crate::{Affine, Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
use super::Transformable;
pub fn label(label: impl Into<ArcStr>) -> Label {
Label {
@ -17,6 +19,7 @@ pub fn label(label: impl Into<ArcStr>) -> Label {
text_size: masonry::theme::TEXT_SIZE_NORMAL,
weight: FontWeight::NORMAL,
font: FontStack::List(std::borrow::Cow::Borrowed(&[])),
transform: Affine::IDENTITY,
}
}
@ -29,6 +32,7 @@ pub struct Label {
pub(in crate::view) text_size: f32,
pub(in crate::view) weight: FontWeight,
pub(in crate::view) font: FontStack<'static>, // TODO: add more attributes of `masonry::widget::Label`
pub(in crate::view) transform: Affine,
}
impl Label {
@ -64,6 +68,12 @@ impl Label {
}
}
impl Transformable for Label {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<T> From<T> for Label
where
T: Into<ArcStr>,
@ -79,13 +89,14 @@ impl<State, Action> View<State, Action, ViewCtx> for Label {
type ViewState = ();
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let widget_pod = ctx.new_pod(
let widget_pod = ctx.new_pod_with_transform(
widget::Label::new(self.label.clone())
.with_brush(self.text_brush.clone())
.with_alignment(self.alignment)
.with_style(StyleProperty::FontSize(self.text_size))
.with_style(StyleProperty::FontWeight(self.weight))
.with_style(StyleProperty::FontStack(self.font.clone())),
self.transform,
);
(widget_pod, ())
}
@ -97,6 +108,9 @@ impl<State, Action> View<State, Action, ViewCtx> for Label {
_ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.label != self.label {
widget::Label::set_text(&mut element, self.label.clone());
}

View File

@ -50,3 +50,42 @@ pub use portal::*;
mod zstack;
pub use zstack::*;
/// An extension trait, to allow common transformations of the views transform.
pub trait Transformable: Sized {
fn transform_mut(&mut self) -> &mut crate::Affine;
#[must_use]
fn rotate(mut self, radians: f64) -> Self {
let transform = self.transform_mut();
*transform = transform.then_rotate(radians);
self
}
#[must_use]
fn scale(mut self, uniform: f64) -> Self {
let transform = self.transform_mut();
*transform = transform.then_scale(uniform);
self
}
#[must_use]
fn scale_non_uniform(mut self, x: f64, y: f64) -> Self {
let transform = self.transform_mut();
*transform = transform.then_scale_non_uniform(x, y);
self
}
#[must_use]
fn translate(mut self, v: impl Into<crate::Vec2>) -> Self {
let transform = self.transform_mut();
*transform = transform.then_translate(v.into());
self
}
#[must_use]
fn transform(mut self, v: impl Into<crate::Affine>) -> Self {
*self.transform_mut() *= v.into();
self
}
}

View File

@ -6,7 +6,9 @@ use std::marker::PhantomData;
use masonry::widget;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId, WidgetView};
use crate::{Affine, MessageResult, Pod, View, ViewCtx, ViewId, WidgetView};
use super::Transformable;
/// A view which puts `child` into a scrollable region.
///
@ -17,6 +19,7 @@ where
{
Portal {
child,
transform: Affine::IDENTITY,
phantom: PhantomData,
}
}
@ -24,6 +27,7 @@ where
#[must_use = "View values do nothing unless provided to Xilem."]
pub struct Portal<V, State, Action> {
child: V,
transform: Affine,
phantom: PhantomData<(State, Action)>,
}
@ -41,7 +45,8 @@ where
// The Portal `View` doesn't get any messages directly (yet - scroll events?), so doesn't need to
// use ctx.with_id.
let (child, child_state) = self.child.build(ctx);
let widget_pod = ctx.new_pod(widget::Portal::new_pod(child.inner));
let widget_pod =
ctx.new_pod_with_transform(widget::Portal::new_pod(child.inner), self.transform);
(widget_pod, child_state)
}
@ -52,6 +57,9 @@ where
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
let child_element = widget::Portal::child_mut(&mut element);
self.child
.rebuild(&prev.child, view_state, ctx, child_element);
@ -77,3 +85,9 @@ where
self.child.message(view_state, id_path, message, app_state)
}
}
impl<V, State, Action> Transformable for Portal<V, State, Action> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}

View File

@ -4,14 +4,26 @@
use masonry::widget;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
use crate::{Affine, MessageResult, Pod, View, ViewCtx, ViewId};
use super::Transformable;
pub fn progress_bar(progress: Option<f64>) -> ProgressBar {
ProgressBar { progress }
ProgressBar {
progress,
transform: Affine::IDENTITY,
}
}
pub struct ProgressBar {
progress: Option<f64>,
transform: Affine,
}
impl Transformable for ProgressBar {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl ViewMarker for ProgressBar {}
@ -20,7 +32,9 @@ impl<State, Action> View<State, Action, ViewCtx> for ProgressBar {
type ViewState = ();
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
ctx.with_leaf_action_widget(|ctx| ctx.new_pod(widget::ProgressBar::new(self.progress)))
ctx.with_leaf_action_widget(|ctx| {
ctx.new_pod_with_transform(widget::ProgressBar::new(self.progress), self.transform)
})
}
fn rebuild(
@ -30,6 +44,9 @@ impl<State, Action> View<State, Action, ViewCtx> for ProgressBar {
_ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.progress != self.progress {
widget::ProgressBar::set_progress(&mut element, self.progress);
}

View File

@ -6,7 +6,9 @@ use masonry::widget::{self, LineBreaking};
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
use crate::{Affine, Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
use super::Transformable;
pub fn prose(content: impl Into<ArcStr>) -> Prose {
Prose {
@ -15,6 +17,7 @@ pub fn prose(content: impl Into<ArcStr>) -> Prose {
alignment: TextAlignment::default(),
text_size: masonry::theme::TEXT_SIZE_NORMAL,
line_break_mode: LineBreaking::WordWrap,
transform: Affine::IDENTITY,
}
}
@ -36,6 +39,7 @@ pub struct Prose {
alignment: TextAlignment,
text_size: f32,
line_break_mode: LineBreaking,
transform: Affine,
// TODO: disabled: bool,
// TODO: add more attributes of `masonry::widget::Prose`
}
@ -67,6 +71,12 @@ fn line_break_clips(linebreaking: LineBreaking) -> bool {
matches!(linebreaking, LineBreaking::Clip | LineBreaking::WordWrap)
}
impl Transformable for Prose {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl ViewMarker for Prose {}
impl<State, Action> View<State, Action, ViewCtx> for Prose {
type Element = Pod<widget::Prose>;
@ -78,9 +88,10 @@ impl<State, Action> View<State, Action, ViewCtx> for Prose {
.with_alignment(self.alignment)
.with_style(StyleProperty::FontSize(self.text_size))
.with_word_wrap(self.line_break_mode == LineBreaking::WordWrap);
let widget_pod = ctx.new_pod(
let widget_pod = ctx.new_pod_with_transform(
widget::Prose::from_text_area(text_area)
.with_clip(line_break_clips(self.line_break_mode)),
self.transform,
);
(widget_pod, ())
}
@ -92,6 +103,9 @@ impl<State, Action> View<State, Action, ViewCtx> for Prose {
_ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
let mut text_area = widget::Prose::text_mut(&mut element);
if prev.content != self.content {
widget::TextArea::reset_text(&mut text_area, &self.content);

View File

@ -9,7 +9,9 @@ use vello::kurbo::RoundedRectRadii;
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, View, ViewId, ViewMarker};
use crate::{Pod, ViewCtx, WidgetView};
use crate::{Affine, Pod, ViewCtx, WidgetView};
use super::Transformable;
/// A widget with predefined size.
///
@ -29,6 +31,7 @@ where
corner_radius: RoundedRectRadii::from_single_radius(0.0),
padding: Padding::ZERO,
phantom: PhantomData,
transform: Affine::IDENTITY,
}
}
@ -42,6 +45,7 @@ pub struct SizedBox<V, State, Action = ()> {
corner_radius: RoundedRectRadii,
padding: Padding,
phantom: PhantomData<fn() -> (State, Action)>,
transform: Affine,
}
impl<V, State, Action> SizedBox<V, State, Action> {
@ -121,6 +125,12 @@ impl<V, State, Action> SizedBox<V, State, Action> {
}
}
impl<V, State, Action> Transformable for SizedBox<V, State, Action> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<V, State, Action> ViewMarker for SizedBox<V, State, Action> {}
impl<V, State, Action> View<State, Action, ViewCtx> for SizedBox<V, State, Action>
where
@ -144,7 +154,8 @@ where
if let Some(border) = &self.border {
widget = widget.border(border.brush.clone(), border.width);
}
(ctx.new_pod(widget), child_state)
let pod = ctx.new_pod_with_transform(widget, self.transform);
(pod, child_state)
}
fn rebuild(
@ -154,6 +165,9 @@ where
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if self.width != prev.width {
match self.width {
Some(width) => widget::SizedBox::set_width(&mut element, width),

View File

@ -4,7 +4,9 @@
use masonry::{widget, Color};
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
use crate::{Affine, MessageResult, Pod, View, ViewCtx, ViewId};
use super::Transformable;
/// An indefinite spinner.
///
@ -32,7 +34,10 @@ use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
/// }
/// ```
pub fn spinner() -> Spinner {
Spinner { color: None }
Spinner {
color: None,
transform: Affine::IDENTITY,
}
}
/// The [`View`] created by [`spinner`].
@ -41,6 +46,7 @@ pub fn spinner() -> Spinner {
#[must_use = "View values do nothing unless provided to Xilem."]
pub struct Spinner {
color: Option<Color>,
transform: Affine,
}
impl Spinner {
@ -51,13 +57,20 @@ impl Spinner {
}
}
impl Transformable for Spinner {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl ViewMarker for Spinner {}
impl<State, Action> View<State, Action, ViewCtx> for Spinner {
type Element = Pod<widget::Spinner>;
type ViewState = ();
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
(ctx.new_pod(widget::Spinner::new()), ())
let pod = ctx.new_pod_with_transform(widget::Spinner::new(), self.transform);
(pod, ())
}
fn rebuild(
@ -67,6 +80,9 @@ impl<State, Action> View<State, Action, ViewCtx> for Spinner {
_: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
if prev.color != self.color {
match self.color {
Some(color) => widget::Spinner::set_color(&mut element, color),

View File

@ -5,7 +5,9 @@ use masonry::widget;
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, View, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, ViewCtx, ViewId};
use crate::{Affine, Color, MessageResult, Pod, TextAlignment, ViewCtx, ViewId};
use super::Transformable;
// FIXME - A major problem of the current approach (always setting the textbox contents)
// is that if the user forgets to hook up the modify the state's contents in the callback,
@ -24,6 +26,7 @@ where
on_enter: None,
text_brush: Color::WHITE.into(),
alignment: TextAlignment::default(),
transform: Affine::IDENTITY,
// TODO?: disabled: false,
}
}
@ -35,6 +38,7 @@ pub struct Textbox<State, Action> {
on_enter: Option<Callback<State, Action>>,
text_brush: Brush,
alignment: TextAlignment,
transform: Affine,
// TODO: add more attributes of `masonry::widget::TextBox`
}
@ -59,6 +63,12 @@ impl<State, Action> Textbox<State, Action> {
}
}
impl<State, Action> Transformable for Textbox<State, Action> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
impl<State, Action> ViewMarker for Textbox<State, Action> {}
impl<State: 'static, Action: 'static> View<State, Action, ViewCtx> for Textbox<State, Action> {
type Element = Pod<widget::Textbox>;
@ -74,7 +84,7 @@ impl<State: 'static, Action: 'static> View<State, Action, ViewCtx> for Textbox<S
// Ensure that the actions from the *inner* TextArea get routed correctly.
let id = textbox.area_pod().id();
ctx.record_action(id);
let widget_pod = ctx.new_pod(textbox);
let widget_pod = ctx.new_pod_with_transform(textbox, self.transform);
(widget_pod, ())
}
@ -85,6 +95,9 @@ impl<State: 'static, Action: 'static> View<State, Action, ViewCtx> for Textbox<S
_ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.transform != self.transform {
element.set_transform(self.transform);
}
let mut text_area = widget::Textbox::text_mut(&mut element);
// Unlike the other properties, we don't compare to the previous value;

View File

@ -10,7 +10,7 @@ use xilem_core::ViewPathTracker;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{MessageResult, Pod, View, ViewCtx, ViewId};
use super::{label, Label};
use super::{label, Label, Transformable};
/// A view for displaying non-editable text, with a variable [weight](masonry::parley::style::FontWeight).
pub fn variable_label(text: impl Into<ArcStr>) -> VariableLabel {
@ -84,7 +84,11 @@ impl VariableLabel {
}
}
impl VariableLabel {}
impl Transformable for VariableLabel {
fn transform_mut(&mut self) -> &mut crate::Affine {
&mut self.label.transform
}
}
impl ViewMarker for VariableLabel {}
impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {

View File

@ -10,7 +10,7 @@ use crate::{
AppendVec, DynMessage, ElementSplice, Mut, SuperElement, View, ViewElement, ViewMarker,
ViewSequence,
},
Pod, ViewCtx, WidgetView,
Affine, Pod, ViewCtx, WidgetView,
};
use masonry::{
widget::{self, Alignment, ChildAlignment, WidgetMut},
@ -18,6 +18,8 @@ use masonry::{
};
use xilem_core::{MessageResult, ViewId};
use super::Transformable;
/// A widget that lays out its children on top of each other.
/// The children are laid out back to front.
///
@ -40,6 +42,7 @@ pub fn zstack<State, Action, Seq: ZStackSequence<State, Action>>(sequence: Seq)
ZStack {
sequence,
alignment: Alignment::default(),
transform: Affine::IDENTITY,
}
}
@ -50,6 +53,7 @@ pub fn zstack<State, Action, Seq: ZStackSequence<State, Action>>(sequence: Seq)
pub struct ZStack<Seq> {
sequence: Seq,
alignment: Alignment,
transform: Affine,
}
impl<Seq> ZStack<Seq> {
@ -78,7 +82,8 @@ where
for child in elements.into_inner() {
widget = widget.with_child_pod(child.widget.inner, child.alignment);
}
(ctx.new_pod(widget), seq_state)
let pod = ctx.new_pod_with_transform(widget, self.transform);
(pod, seq_state)
}
fn rebuild(
@ -88,6 +93,10 @@ where
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if self.transform != prev.transform {
element.set_transform(self.transform);
}
if self.alignment != prev.alignment {
widget::ZStack::set_alignment(&mut element, self.alignment);
}
@ -121,6 +130,12 @@ where
}
}
impl<Seq> Transformable for ZStack<Seq> {
fn transform_mut(&mut self) -> &mut Affine {
&mut self.transform
}
}
// --- MARK: ZStackExt ---
/// A trait that extends a [`WidgetView`] with methods to provide parameters for a parent [`ZStack`].