xilem_web: Add `Rotate` and `Scale` (CSS) transform views (#621)

This sketches/implements (parts of) the transform modifier API described
in [Add `Affine` transform to `Widget`
trait?](https://xi.zulipchat.com/#narrow/stream/317477-masonry/topic/Add.20.60Affine.60.20transform.20to.20.60Widget.60.20trait.3F/near/472076600)
for xilem_web.

This currently includes `rotate` and `scale`, because there are less
cases to handle for these [CSS transform
functions](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function).
In `rotate` I think we can reduce this to just radians (there are more
units for `<angle>`: `turn`, `grad`, `deg` and `rad` (used here) but
they can all be derived from radians), and percent in scale is kinda
redundant (as `200%` == `2.0`)), for example `translate` has more cases
as it includes all kinds of units (like `px`, `em`, `%` etc.), so this
may require more thought (and is thus probably better for a future PR).

This can be combined with untyped `transform: ...` as style, these
modifier views extend the `transform` style (while the untyped
`style(..)` overwrites previous set values).

The `svgtoy` example is updated to include these views.
This commit is contained in:
Philipp Mildenberger 2024-10-10 19:44:47 +02:00 committed by GitHub
parent da92ba6cf0
commit a4f88b7d5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 373 additions and 23 deletions

View File

@ -18,7 +18,7 @@ use crate::{
attribute::{Attr, WithAttributes},
class::{AsClassIter, Class, WithClasses},
events,
style::{IntoStyles, Style, WithStyle},
style::{IntoStyles, Rotate, Scale, ScaleValue, Style, WithStyle},
DomNode, DomView, IntoAttributeValue, OptionalAction, Pointer, PointerMsg,
};
use wasm_bindgen::JsCast;
@ -146,6 +146,59 @@ pub trait Element<State, Action = ()>:
Attr::new(self, Cow::from("id"), value.into_attr_value())
}
/// Set a style attribute
fn style(self, style: impl IntoStyles) -> Style<Self, State, Action>
where
<Self::DomNode as DomNode>::Props: WithStyle,
{
let mut styles = vec![];
style.into_styles(&mut styles);
Style::new(self, styles)
}
/// Add a `rotate(<radians>rad)` [transform-function](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function) to the current CSS `transform`
/// # Examples
///
/// ```
/// use xilem_web::{style as s, interfaces::Element, svg::kurbo::Rect};
///
/// # fn component() -> impl Element<()> {
/// Rect::from_origin_size((0.0, 10.0), (20.0, 30.0))
/// .style(s("transform", "translate(10px, 0)")) // can be combined with untyped `transform`
/// .rotate(std::f64::consts::PI / 4.0)
/// // results in the following html:
/// // <rect width="20" height="30" x="0.0" y="10.0" style="transform: translate(10px, 0) rotate(0.78539rad);"></rect>
/// # }
/// ```
fn rotate(self, radians: f64) -> Rotate<Self, State, Action>
where
<Self::DomNode as DomNode>::Props: WithStyle,
{
Rotate::new(self, radians)
}
/// Add a `scale(<scale>)` [transform-function](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function) to the current CSS `transform`
/// # Examples
///
/// ```
/// use xilem_web::{style as s, interfaces::Element, svg::kurbo::Circle};
///
/// # fn component() -> impl Element<()> {
/// Circle::new((10.0, 20.0), 30.0)
/// .style(s("transform", "translate(10px, 0)")) // can be combined with untyped `transform`
/// .scale(1.5)
/// .scale((1.5, 2.0))
/// // results in the following html:
/// // <circle r="30" cy="20" cx="10" style="transform: translate(10px, 0) scale(1.5) scale(1.5, 2);"></circle>
/// # }
/// ```
fn scale(self, scale: impl Into<ScaleValue>) -> Scale<Self, State, Action>
where
<Self::DomNode as DomNode>::Props: WithStyle,
{
Scale::new(self, scale)
}
// event list from
// https://html.spec.whatwg.org/multipage/webappapis.html#idl-definitions
//
@ -498,16 +551,19 @@ where
{
}
// pub trait StyleExt {
// }
// /// Keep this shared code in sync between `HtmlElement` and `SvgElement`
// macro_rules! style_impls {
// () => {};
// }
// #[cfg(feature = "HtmlElement")]
pub trait HtmlElement<State, Action = ()>:
Element<State, Action, DomNode: DomNode<Props: WithStyle> + AsRef<web_sys::HtmlElement>>
{
/// Set a style attribute
fn style(self, style: impl IntoStyles) -> Style<Self, State, Action> {
let mut styles = vec![];
style.into_styles(&mut styles);
Style::new(self, styles)
}
// style_impls!();
}
// #[cfg(feature = "HtmlElement")]
@ -1479,12 +1535,7 @@ where
pub trait SvgElement<State, Action = ()>:
Element<State, Action, DomNode: DomNode<Props: WithStyle> + AsRef<web_sys::SvgElement>>
{
/// Set a style attribute
fn style(self, style: impl IntoStyles) -> Style<Self, State, Action> {
let mut styles = vec![];
style.into_styles(&mut styles);
Style::new(self, styles)
}
// style_impls!();
}
// #[cfg(feature = "SvgElement")]

View File

@ -232,6 +232,14 @@ impl WithStyle for Noop {
fn mark_end_of_style_modifier(&mut self) {
unreachable!()
}
fn get_style(&self, _name: &str) -> Option<&CowStr> {
unreachable!()
}
fn was_updated(&self, _name: &str) -> bool {
unreachable!()
}
}
impl<T> AsRef<T> for Noop {
@ -425,6 +433,34 @@ impl<
OneOf::I(e) => e.mark_end_of_style_modifier(),
}
}
fn get_style(&self, name: &str) -> Option<&CowStr> {
match self {
OneOf::A(e) => e.get_style(name),
OneOf::B(e) => e.get_style(name),
OneOf::C(e) => e.get_style(name),
OneOf::D(e) => e.get_style(name),
OneOf::E(e) => e.get_style(name),
OneOf::F(e) => e.get_style(name),
OneOf::G(e) => e.get_style(name),
OneOf::H(e) => e.get_style(name),
OneOf::I(e) => e.get_style(name),
}
}
fn was_updated(&self, name: &str) -> bool {
match self {
OneOf::A(e) => e.was_updated(name),
OneOf::B(e) => e.was_updated(name),
OneOf::C(e) => e.was_updated(name),
OneOf::D(e) => e.was_updated(name),
OneOf::E(e) => e.was_updated(name),
OneOf::F(e) => e.was_updated(name),
OneOf::G(e) => e.was_updated(name),
OneOf::H(e) => e.was_updated(name),
OneOf::I(e) => e.was_updated(name),
}
}
}
impl<N1, N2, N3, N4, N5, N6, N7, N8, N9> DomNode for OneOf<N1, N2, N3, N4, N5, N6, N7, N8, N9>

View File

@ -1,8 +1,10 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use peniko::kurbo::Vec2;
use std::{
collections::{BTreeMap, HashMap},
fmt::Display,
marker::PhantomData,
};
use wasm_bindgen::{JsCast, UnwrapThrowExt};
@ -120,8 +122,16 @@ pub trait WithStyle {
/// When in [`View::rebuild`] this has to be invoked *after* traversing the inner `View` with [`View::rebuild`]
fn set_style(&mut self, name: &CowStr, value: &Option<CowStr>);
// TODO first find a use-case for this...
// fn get_style(&self, name: &str) -> Option<&CowStr>;
/// Gets a previously set style from this modifier.
///
/// When in [`View::rebuild`] this has to be invoked *after* traversing the inner `View` with [`View::rebuild`]
fn get_style(&self, name: &str) -> Option<&CowStr>;
/// Returns `true` if a style property `name` was updated.
///
/// This can be useful, for modifying a previously set value.
/// When in [`View::rebuild`] this has to be invoked *after* traversing the inner `View` with [`View::rebuild`]
fn was_updated(&self, name: &str) -> bool;
}
#[derive(Debug, PartialEq)]
@ -307,6 +317,21 @@ impl WithStyle for Styles {
self.idx += 1;
self.start_idx = self.idx | (self.start_idx & RESERVED_BIT_MASK);
}
fn get_style(&self, name: &str) -> Option<&CowStr> {
for modifier in self.style_modifiers[..self.idx as usize].iter().rev() {
match modifier {
StyleModifier::Remove(removed) if removed == name => return None,
StyleModifier::Set(key, value) if key == name => return Some(value),
_ => (),
}
}
None
}
fn was_updated(&self, name: &str) -> bool {
self.updated_styles.contains_key(name)
}
}
impl WithStyle for ElementProps {
@ -321,6 +346,19 @@ impl WithStyle for ElementProps {
fn set_style(&mut self, name: &CowStr, value: &Option<CowStr>) {
self.styles().set_style(name, value);
}
fn get_style(&self, name: &str) -> Option<&CowStr> {
self.styles
.as_deref()
.and_then(|styles| styles.get_style(name))
}
fn was_updated(&self, name: &str) -> bool {
self.styles
.as_deref()
.map(|styles| styles.was_updated(name))
.unwrap_or(false)
}
}
impl<N: DomNode> WithStyle for Pod<N>
@ -338,6 +376,14 @@ where
fn set_style(&mut self, name: &CowStr, value: &Option<CowStr>) {
self.props.set_style(name, value);
}
fn get_style(&self, name: &str) -> Option<&CowStr> {
self.props.get_style(name)
}
fn was_updated(&self, name: &str) -> bool {
self.props.was_updated(name)
}
}
impl<N: DomNode> WithStyle for PodMut<'_, N>
@ -355,6 +401,14 @@ where
fn set_style(&mut self, name: &CowStr, value: &Option<CowStr>) {
self.props.set_style(name, value);
}
fn get_style(&self, name: &str) -> Option<&CowStr> {
self.props.get_style(name)
}
fn was_updated(&self, name: &str) -> bool {
self.props.was_updated(name)
}
}
/// Syntax sugar for adding a type bound on the `ViewElement` of a view, such that both, [`ViewElement`] and [`ViewElement::Mut`] are bound to [`WithStyle`]
@ -441,3 +495,200 @@ where
self.el.message(view_state, id_path, message, app_state)
}
}
/// Add a `rotate(<radians>rad)` [transform-function](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function) to the current CSS `transform`
pub struct Rotate<E, State, Action> {
el: E,
phantom: PhantomData<fn() -> (State, Action)>,
radians: f64,
}
impl<E, State, Action> Rotate<E, State, Action> {
pub(crate) fn new(element: E, radians: f64) -> Self {
Rotate {
el: element,
phantom: PhantomData,
radians,
}
}
}
fn modify_rotate_transform(transform: Option<&CowStr>, radians: f64) -> Option<CowStr> {
if let Some(transform) = transform {
Some(CowStr::from(format!("{transform} rotate({radians}rad)")))
} else {
Some(CowStr::from(format!("rotate({radians}rad)")))
}
}
impl<E, T, A> ViewMarker for Rotate<E, T, A> {}
impl<T, A, E> View<T, A, ViewCtx, DynMessage> for Rotate<E, T, A>
where
T: 'static,
A: 'static,
E: View<T, A, ViewCtx, DynMessage, Element: ElementWithStyle>,
{
type Element = E::Element;
type ViewState = (E::ViewState, Option<CowStr>);
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
ctx.add_modifier_size_hint::<Styles>(1);
let (mut element, state) = self.el.build(ctx);
let css_repr = modify_rotate_transform(element.get_style("transform"), self.radians);
element.set_style(&"transform".into(), &css_repr);
element.mark_end_of_style_modifier();
(element, (state, css_repr))
}
fn rebuild<'el>(
&self,
prev: &Self,
(view_state, css_repr): &mut Self::ViewState,
ctx: &mut ViewCtx,
mut element: Mut<'el, Self::Element>,
) -> Mut<'el, Self::Element> {
element.rebuild_style_modifier();
let mut element = self.el.rebuild(&prev.el, view_state, ctx, element);
if prev.radians != self.radians || element.was_updated("transform") {
*css_repr = modify_rotate_transform(element.get_style("transform"), self.radians);
}
element.set_style(&"transform".into(), css_repr);
element.mark_end_of_style_modifier();
element
}
fn teardown(
&self,
(view_state, _): &mut Self::ViewState,
ctx: &mut ViewCtx,
element: Mut<'_, Self::Element>,
) {
self.el.teardown(view_state, ctx, element);
}
fn message(
&self,
(view_state, _): &mut Self::ViewState,
id_path: &[ViewId],
message: DynMessage,
app_state: &mut T,
) -> MessageResult<A, DynMessage> {
self.el.message(view_state, id_path, message, app_state)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ScaleValue {
Uniform(f64),
NonUniform(f64, f64),
}
impl Display for ScaleValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScaleValue::Uniform(uniform) => write!(f, "{uniform}"),
ScaleValue::NonUniform(x, y) => write!(f, "{x}, {y}"),
}
}
}
impl From<f64> for ScaleValue {
fn from(value: f64) -> Self {
ScaleValue::Uniform(value)
}
}
impl From<(f64, f64)> for ScaleValue {
fn from(value: (f64, f64)) -> Self {
ScaleValue::NonUniform(value.0, value.1)
}
}
impl From<Vec2> for ScaleValue {
fn from(value: Vec2) -> Self {
ScaleValue::NonUniform(value.x, value.y)
}
}
/// Add a `rotate(<radians>rad)` [transform-function](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function) to the current CSS `transform`
pub struct Scale<E, State, Action> {
el: E,
phantom: PhantomData<fn() -> (State, Action)>,
scale: ScaleValue,
}
impl<E, State, Action> Scale<E, State, Action> {
pub(crate) fn new(element: E, scale: impl Into<ScaleValue>) -> Self {
Scale {
el: element,
phantom: PhantomData,
scale: scale.into(),
}
}
}
fn modify_scale_transform(transform: Option<&CowStr>, scale: ScaleValue) -> Option<CowStr> {
if let Some(transform) = transform {
Some(CowStr::from(format!("{transform} scale({scale})")))
} else {
Some(CowStr::from(format!("scale({scale})")))
}
}
impl<E, T, A> ViewMarker for Scale<E, T, A> {}
impl<T, A, E> View<T, A, ViewCtx, DynMessage> for Scale<E, T, A>
where
T: 'static,
A: 'static,
E: View<T, A, ViewCtx, DynMessage, Element: ElementWithStyle>,
{
type Element = E::Element;
type ViewState = (E::ViewState, Option<CowStr>);
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
ctx.add_modifier_size_hint::<Styles>(1);
let (mut element, state) = self.el.build(ctx);
let css_repr = modify_scale_transform(element.get_style("transform"), self.scale);
element.set_style(&"transform".into(), &css_repr);
element.mark_end_of_style_modifier();
(element, (state, css_repr))
}
fn rebuild<'el>(
&self,
prev: &Self,
(view_state, css_repr): &mut Self::ViewState,
ctx: &mut ViewCtx,
mut element: Mut<'el, Self::Element>,
) -> Mut<'el, Self::Element> {
element.rebuild_style_modifier();
let mut element = self.el.rebuild(&prev.el, view_state, ctx, element);
if prev.scale != self.scale || element.was_updated("transform") {
*css_repr = modify_scale_transform(element.get_style("transform"), self.scale);
}
element.set_style(&"transform".into(), css_repr);
element.mark_end_of_style_modifier();
element
}
fn teardown(
&self,
(view_state, _): &mut Self::ViewState,
ctx: &mut ViewCtx,
element: Mut<'_, Self::Element>,
) {
self.el.teardown(view_state, ctx, element);
}
fn message(
&self,
(view_state, _): &mut Self::ViewState,
id_path: &[ViewId],
message: DynMessage,
app_state: &mut T,
) -> MessageResult<A, DynMessage> {
self.el.message(view_state, id_path, message, app_state)
}
}

View File

@ -3,10 +3,11 @@
use xilem_web::{
document_body,
elements::svg::{g, svg},
elements::svg::{g, svg, text},
interfaces::*,
style as s,
svg::{
kurbo::{self, Rect},
kurbo::{Circle, Line, Rect, Stroke},
peniko::Color,
},
App, DomView, PointerMsg,
@ -55,7 +56,10 @@ impl GrabState {
fn app_logic(state: &mut AppState) -> impl DomView<AppState> {
let v = (0..10)
.map(|i| Rect::from_origin_size((10.0 * i as f64, 150.0), (8.0, 8.0)))
.map(|i| {
Rect::from_origin_size((10.0 * i as f64, 150.0), (8.0, 8.0))
.rotate(0.003 * (i as f64) * state.x)
})
.collect::<Vec<_>>();
svg(g((
Rect::new(100.0, 100.0, 200.0, 200.0).on_click(|_, _| {
@ -63,20 +67,28 @@ fn app_logic(state: &mut AppState) -> impl DomView<AppState> {
}),
Rect::new(210.0, 100.0, 310.0, 200.0)
.fill(Color::LIGHT_GRAY)
.stroke(Color::BLUE, Default::default()),
.stroke(Color::BLUE, Default::default())
.scale((state.x / 100.0 + 1.0, state.y / 100.0 + 1.0)),
Rect::new(320.0, 100.0, 420.0, 200.0).class("red"),
Rect::new(state.x, state.y, state.x + 100., state.y + 100.)
.fill(Color::rgba8(100, 100, 255, 100))
.pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.x, &mut s.y, &msg)),
g(v),
text("drag me around")
.style(s(
"transform",
format!("translate({}px, {}px)", state.x, state.y + 50.0),
))
.style([s("font-size", "10px"), s("pointer-events", "none")]),
g(v).style(s("transform", "translate(430px, 0)")) // untyped transform can be combined with transform modifiers, though this overwrites previously set `transform` values
.scale(state.y / 100.0 + 1.0),
Rect::new(210.0, 210.0, 310.0, 310.0).pointer(|_, e| {
web_sys::console::log_1(&format!("pointer event {e:?}").into());
}),
kurbo::Line::new((310.0, 210.0), (410.0, 310.0)).stroke(
Line::new((310.0, 210.0), (410.0, 310.0)).stroke(
Color::YELLOW_GREEN,
kurbo::Stroke::new(1.0).with_dashes(state.x, [7.0, 1.0]),
Stroke::new(1.0).with_dashes(state.x, [7.0, 1.0]),
),
kurbo::Circle::new((460.0, 260.0), 45.0).on_click(|_, _| {
Circle::new((460.0, 260.0), 45.0).on_click(|_, _| {
web_sys::console::log_1(&"circle clicked".into());
}),
)))