diff --git a/Cargo.lock b/Cargo.lock index 9cdcabe87..1acbd903c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1033,7 +1033,7 @@ dependencies = [ "instant", "js-sys", "keyboard-types", - "kurbo", + "kurbo 0.9.5", "lazy_static", "memchr", "nix 0.25.1", @@ -1333,6 +1333,16 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1684,12 +1694,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "peniko" +version = "0.1.0" +source = "git+https://github.com/linebender/peniko?rev=629fc3325b016a8c98b1cd6204cb4ddf1c6b3daa#629fc3325b016a8c98b1cd6204cb4ddf1c6b3daa" +dependencies = [ + "kurbo 0.10.4", + "smallvec", +] + [[package]] name = "peniko" version = "0.1.0" source = "git+https://github.com/linebender/peniko?rev=cafdac9a211a0fb2fec5656bd663d1ac770bcc81#cafdac9a211a0fb2fec5656bd663d1ac770bcc81" dependencies = [ - "kurbo", + "kurbo 0.9.5", "smallvec", ] @@ -2094,6 +2113,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fb1df15f412ee2e9dfc1c504260fa695c1c3f10fe9f4a6ee2d2184d7d6450e2" +[[package]] +name = "svgtoy" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "wasm-bindgen", + "web-sys", + "xilem_svg", +] + [[package]] name = "swash" version = "0.1.8" @@ -2401,7 +2430,7 @@ dependencies = [ "bytemuck", "fello", "futures-intrusive", - "peniko", + "peniko 0.1.0 (git+https://github.com/linebender/peniko?rev=cafdac9a211a0fb2fec5656bd663d1ac770bcc81)", "raw-window-handle", "vello_encoding", "wgpu", @@ -2415,7 +2444,7 @@ dependencies = [ "bytemuck", "fello", "guillotiere", - "peniko", + "peniko 0.1.0 (git+https://github.com/linebender/peniko?rev=cafdac9a211a0fb2fec5656bd663d1ac770bcc81)", ] [[package]] @@ -2999,7 +3028,7 @@ version = "0.1.0" dependencies = [ "bitflags 2.3.3", "gloo", - "kurbo", + "kurbo 0.9.5", "log", "wasm-bindgen", "web-sys", @@ -3011,7 +3040,7 @@ name = "xilem_svg" version = "0.1.0" dependencies = [ "bitflags 2.3.3", - "kurbo", + "peniko 0.1.0 (git+https://github.com/linebender/peniko?rev=629fc3325b016a8c98b1cd6204cb4ddf1c6b3daa)", "wasm-bindgen", "web-sys", "xilem_core", diff --git a/Cargo.toml b/Cargo.toml index 7a813d401..e346fce1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/xilem_html/web_examples/counter_untyped", "crates/xilem_html/web_examples/todomvc", "crates/xilem_svg", + "crates/xilem_svg/web_examples/svgtoy", ] [workspace.package] diff --git a/crates/xilem_svg/Cargo.toml b/crates/xilem_svg/Cargo.toml index 7e9e2a61f..86565dfb7 100644 --- a/crates/xilem_svg/Cargo.toml +++ b/crates/xilem_svg/Cargo.toml @@ -17,14 +17,11 @@ default-target = "x86_64-pc-windows-msvc" # rustdoc-scrape-examples tracking issue https://github.com/rust-lang/rust/issues/88791 cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] -[lib] -crate-type = ["cdylib"] - [dependencies] xilem_core.workspace = true -kurbo.workspace = true bitflags = "2" wasm-bindgen = "0.2.84" +peniko = { git = "https://github.com/linebender/peniko", rev = "629fc3325b016a8c98b1cd6204cb4ddf1c6b3daa" } [dependencies.web-sys] version = "0.3.4" diff --git a/crates/xilem_svg/README.md b/crates/xilem_svg/README.md index 2fb1fbf66..a9cf3661a 100644 --- a/crates/xilem_svg/README.md +++ b/crates/xilem_svg/README.md @@ -1,7 +1,7 @@ # Xilemsvg prototype -This is a proof of concept showing how to use `xilem_core` to render interactive vector graphics into SVG DOM nodes, running in a browser. A next step would be to factor it into a library so that applications can depend on it, but at the moment the test scene is baked in. +This is a proof of concept showing how to use `xilem_core` to render interactive vector graphics into SVG DOM nodes, running in a browser. It is provided as a library and some examples. -The easiest way to run it is to use [Trunk]. Run `trunk serve`, then navigate the browser to the link provided (usually `http://localhost:8080`). +The easiest way to run the examples is to use [Trunk]. Go into the appropriate subdirectory of `web_examples`, run `trunk serve`, then navigate the browser to the link provided (usually `http://localhost:8080`). [Trunk]: https://trunkrs.dev/ diff --git a/crates/xilem_svg/index.html b/crates/xilem_svg/index.html deleted file mode 100644 index 9a850a6fe..000000000 --- a/crates/xilem_svg/index.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/crates/xilem_svg/src/class.rs b/crates/xilem_svg/src/class.rs index 4e096ec35..ac7ad4add 100644 --- a/crates/xilem_svg/src/class.rs +++ b/crates/xilem_svg/src/class.rs @@ -1,7 +1,7 @@ // Copyright 2023 the Druid Authors. // SPDX-License-Identifier: Apache-2.0 -use std::any::Any; +use std::{any::Any, marker::PhantomData}; use xilem_core::{Id, MessageResult}; @@ -10,23 +10,25 @@ use crate::{ view::{DomElement, View, ViewMarker}, }; -pub struct Class { +pub struct Class { child: V, // This could reasonably be static Cow also, but keep things simple class: String, + phantom: PhantomData, } -pub fn class(child: V, class: impl Into) -> Class { +pub fn class(child: V, class: impl Into) -> Class { Class { child, class: class.into(), + phantom: Default::default(), } } -impl ViewMarker for Class {} +impl ViewMarker for Class {} // TODO: make generic over A (probably requires Phantom) -impl> View for Class { +impl> View for Class { type State = V::State; type Element = V::Element; diff --git a/crates/xilem_svg/src/clicked.rs b/crates/xilem_svg/src/clicked.rs index 1eed1a408..8fe513eb0 100644 --- a/crates/xilem_svg/src/clicked.rs +++ b/crates/xilem_svg/src/clicked.rs @@ -1,7 +1,7 @@ // Copyright 2023 the Druid Authors. // SPDX-License-Identifier: Apache-2.0 -use std::any::Any; +use std::{any::Any, marker::PhantomData}; use wasm_bindgen::{prelude::Closure, JsCast}; use web_sys::SvgElement; @@ -13,9 +13,10 @@ use crate::{ view::{DomElement, View, ViewMarker}, }; -pub struct Clicked { +pub struct Clicked { child: V, callback: F, + phantom: PhantomData, } pub struct ClickedState { @@ -27,13 +28,17 @@ pub struct ClickedState { struct ClickedMsg; -pub fn clicked>(child: V, callback: F) -> Clicked { - Clicked { child, callback } +pub fn clicked>(child: V, callback: F) -> Clicked { + Clicked { + child, + callback, + phantom: Default::default(), + } } -impl ViewMarker for Clicked {} +impl ViewMarker for Clicked {} -impl> View for Clicked { +impl> View for Clicked { type State = ClickedState; type Element = V::Element; diff --git a/crates/xilem_svg/src/common_attrs.rs b/crates/xilem_svg/src/common_attrs.rs new file mode 100644 index 000000000..a11d82e51 --- /dev/null +++ b/crates/xilem_svg/src/common_attrs.rs @@ -0,0 +1,163 @@ +// Copyright 2023 the Druid Authors. +// SPDX-License-Identifier: Apache-2.0 + +use std::{any::Any, marker::PhantomData}; + +use peniko::Brush; +use xilem_core::{Id, MessageResult}; + +use crate::{ + context::{ChangeFlags, Cx}, + view::{DomElement, View, ViewMarker}, +}; + +pub struct Fill { + child: V, + brush: Brush, + phantom: PhantomData, +} + +pub struct Stroke { + child: V, + brush: Brush, + style: peniko::kurbo::Stroke, + phantom: PhantomData, +} + +pub fn fill(child: V, brush: impl Into) -> Fill { + Fill { + child, + brush: brush.into(), + phantom: Default::default(), + } +} + +pub fn stroke( + child: V, + brush: impl Into, + style: peniko::kurbo::Stroke, +) -> Stroke { + Stroke { + child, + brush: brush.into(), + style, + phantom: Default::default(), + } +} + +fn brush_to_string(brush: &Brush) -> String { + match brush { + Brush::Solid(color) => { + if color.a == 0 { + "none".into() + } else { + format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b) + } + } + _ => todo!("gradients not implemented"), + } +} + +impl ViewMarker for Fill {} + +// TODO: make generic over A (probably requires Phantom) +impl> View for Fill { + type State = V::State; + type Element = V::Element; + + fn build(&self, cx: &mut Cx) -> (Id, Self::State, Self::Element) { + let (id, child_state, element) = self.child.build(cx); + element + .as_element_ref() + .set_attribute("fill", &brush_to_string(&self.brush)) + .unwrap(); + (id, child_state, element) + } + + fn rebuild( + &self, + cx: &mut Cx, + prev: &Self, + id: &mut Id, + state: &mut Self::State, + element: &mut V::Element, + ) -> ChangeFlags { + let prev_id = *id; + let mut changed = self.child.rebuild(cx, &prev.child, id, state, element); + if self.brush != prev.brush || prev_id != *id { + element + .as_element_ref() + .set_attribute("fill", &brush_to_string(&self.brush)) + .unwrap(); + changed.insert(ChangeFlags::OTHER_CHANGE); + } + changed + } + + fn message( + &self, + id_path: &[Id], + state: &mut Self::State, + message: Box, + app_state: &mut T, + ) -> MessageResult<()> { + self.child.message(id_path, state, message, app_state) + } +} + +impl ViewMarker for Stroke {} + +// TODO: make generic over A (probably requires Phantom) +impl> View for Stroke { + type State = V::State; + type Element = V::Element; + + fn build(&self, cx: &mut Cx) -> (Id, Self::State, Self::Element) { + let (id, child_state, element) = self.child.build(cx); + element + .as_element_ref() + .set_attribute("stroke", &brush_to_string(&self.brush)) + .unwrap(); + element + .as_element_ref() + .set_attribute("stroke-width", &format!("{}", self.style.width)) + .unwrap(); + (id, child_state, element) + } + + fn rebuild( + &self, + cx: &mut Cx, + prev: &Self, + id: &mut Id, + state: &mut Self::State, + element: &mut V::Element, + ) -> ChangeFlags { + let prev_id = *id; + let mut changed = self.child.rebuild(cx, &prev.child, id, state, element); + if self.brush != prev.brush || prev_id != *id { + element + .as_element_ref() + .set_attribute("stroke", &brush_to_string(&self.brush)) + .unwrap(); + changed.insert(ChangeFlags::OTHER_CHANGE); + } + if self.style.width != prev.style.width || prev_id != *id { + element + .as_element_ref() + .set_attribute("stroke-width", &format!("{}", self.style.width)) + .unwrap(); + } + changed + } + + fn message( + &self, + id_path: &[Id], + state: &mut Self::State, + message: Box, + app_state: &mut T, + ) -> MessageResult<()> { + self.child.message(id_path, state, message, app_state) + } +} diff --git a/crates/xilem_svg/src/context.rs b/crates/xilem_svg/src/context.rs index 82d8fe3c3..82bc75a47 100644 --- a/crates/xilem_svg/src/context.rs +++ b/crates/xilem_svg/src/context.rs @@ -14,7 +14,7 @@ pub struct Cx { app_ref: Option>, } -pub struct MessageThunk { +pub(crate) struct MessageThunk { id_path: IdPath, app_ref: Box, } @@ -82,7 +82,7 @@ impl Cx { &self.document } - pub fn message_thunk(&self) -> MessageThunk { + pub(crate) fn message_thunk(&self) -> MessageThunk { MessageThunk { id_path: self.id_path.clone(), app_ref: self.app_ref.as_ref().unwrap().clone_box(), @@ -93,6 +93,12 @@ impl Cx { } } +impl Default for Cx { + fn default() -> Self { + Self::new() + } +} + impl MessageThunk { pub fn push_message(&self, message_body: impl Any + Send + 'static) { let message = Message { diff --git a/crates/xilem_svg/src/kurbo_shape.rs b/crates/xilem_svg/src/kurbo_shape.rs index 9ad2486a1..30a266935 100644 --- a/crates/xilem_svg/src/kurbo_shape.rs +++ b/crates/xilem_svg/src/kurbo_shape.rs @@ -3,42 +3,16 @@ //! Implementation of the View trait for various kurbo shapes. -use kurbo::{BezPath, Circle, Line, Rect}; +use peniko::kurbo::{BezPath, Circle, Line, Rect}; use web_sys::Element; use xilem_core::{Id, MessageResult}; use crate::{ context::{ChangeFlags, Cx}, - pointer::PointerMsg, view::{View, ViewMarker}, }; -pub trait KurboShape: Sized { - fn class(self, class: impl Into) -> crate::class::Class { - crate::class::class(self, class) - } - - fn clicked(self, f: F) -> crate::clicked::Clicked - where - Self: View, - { - crate::clicked::clicked(self, f) - } - - fn pointer(self, f: F) -> crate::pointer::Pointer - where - Self: View, - { - crate::pointer::pointer(self, f) - } -} - -impl KurboShape for Line {} -impl KurboShape for Rect {} -impl KurboShape for Circle {} -impl KurboShape for BezPath {} - impl ViewMarker for Line {} impl View for Line { diff --git a/crates/xilem_svg/src/lib.rs b/crates/xilem_svg/src/lib.rs index e7a733ab0..f8cb7d90a 100644 --- a/crates/xilem_svg/src/lib.rs +++ b/crates/xilem_svg/src/lib.rs @@ -1,14 +1,12 @@ // Copyright 2023 the Druid Authors. // SPDX-License-Identifier: Apache-2.0 -//! A test program to exercise using xilem_core to generate SVG nodes that -//! render in a browser. -//! -//! Run using `trunk serve`. +//! An experimental library for making reactive SVG graphics. mod app; mod class; mod clicked; +mod common_attrs; mod context; mod group; mod kurbo_shape; @@ -16,88 +14,16 @@ mod pointer; mod view; mod view_ext; -use app::App; -use group::group; -use kurbo::Rect; -use kurbo_shape::KurboShape; -use pointer::PointerMsg; -use view::View; -use wasm_bindgen::prelude::*; +pub use peniko; +pub use peniko::kurbo; + +pub use app::App; +pub use context::Cx; +pub use group::group; +pub use pointer::{PointerDetails, PointerMsg}; +pub use view::{AnyView, Memoize, View, ViewMarker, ViewSequence}; +pub use view_ext::ViewExt; pub use context::ChangeFlags; xilem_core::message!(Send); - -#[derive(Default)] -struct AppState { - x: f64, - y: f64, - grab: GrabState, -} - -#[derive(Default)] -struct GrabState { - is_down: bool, - id: i32, - dx: f64, - dy: f64, -} - -impl GrabState { - fn handle(&mut self, x: &mut f64, y: &mut f64, p: &PointerMsg) { - match p { - PointerMsg::Down(e) => { - if e.button == 0 { - self.dx = *x - e.x; - self.dy = *y - e.y; - self.id = e.id; - self.is_down = true; - } - } - PointerMsg::Move(e) => { - if self.is_down && self.id == e.id { - *x = self.dx + e.x; - *y = self.dy + e.y; - } - } - PointerMsg::Up(e) => { - if self.id == e.id { - self.is_down = false; - } - } - } - } -} - -fn app_logic(state: &mut AppState) -> impl View { - let v = (0..10) - .map(|i| Rect::from_origin_size((10.0 * i as f64, 150.0), (8.0, 8.0))) - .collect::>(); - group(( - Rect::new(100.0, 100.0, 200.0, 200.0).clicked(|_| { - web_sys::console::log_1(&"app logic clicked".into()); - }), - Rect::new(210.0, 100.0, 310.0, 200.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.) - .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.x, &mut s.y, &msg)), - group(v), - 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)), - kurbo::Circle::new((460.0, 260.0), 45.0).clicked(|_| { - web_sys::console::log_1(&"circle clicked".into()); - }), - )) - //button(format!("Count {}", count), |count| *count += 1) -} - -// Called by our JS entry point to run the example -#[wasm_bindgen(start)] -pub fn run() -> Result<(), JsValue> { - let app = App::new(AppState::default(), app_logic); - app.run(); - - Ok(()) -} diff --git a/crates/xilem_svg/src/pointer.rs b/crates/xilem_svg/src/pointer.rs index 0cebd2d05..7cdd29521 100644 --- a/crates/xilem_svg/src/pointer.rs +++ b/crates/xilem_svg/src/pointer.rs @@ -3,7 +3,7 @@ //! Interactivity with pointer events. -use std::any::Any; +use std::{any::Any, marker::PhantomData}; use wasm_bindgen::{prelude::Closure, JsCast}; use web_sys::PointerEvent; @@ -15,9 +15,10 @@ use crate::{ view::{DomElement, View, ViewMarker}, }; -pub struct Pointer { +pub struct Pointer { child: V, callback: F, + phantom: PhantomData, } pub struct PointerState { @@ -32,6 +33,7 @@ pub struct PointerState { } #[derive(Debug)] +/// A message representing a pointer event. pub enum PointerMsg { Down(PointerDetails), Move(PointerDetails), @@ -39,6 +41,7 @@ pub enum PointerMsg { } #[derive(Debug)] +/// Details of a pointer event. pub struct PointerDetails { pub id: i32, pub button: i16, @@ -57,13 +60,20 @@ impl PointerDetails { } } -pub fn pointer>(child: V, callback: F) -> Pointer { - Pointer { child, callback } +pub fn pointer>( + child: V, + callback: F, +) -> Pointer { + Pointer { + child, + callback, + phantom: Default::default(), + } } -impl ViewMarker for Pointer {} +impl ViewMarker for Pointer {} -impl> View for Pointer { +impl> View for Pointer { type State = PointerState; type Element = V::Element; diff --git a/crates/xilem_svg/src/view_ext.rs b/crates/xilem_svg/src/view_ext.rs index db6ea116e..edfd870c3 100644 --- a/crates/xilem_svg/src/view_ext.rs +++ b/crates/xilem_svg/src/view_ext.rs @@ -1,25 +1,36 @@ // Copyright 2023 the Druid Authors. // SPDX-License-Identifier: Apache-2.0 +use peniko::Brush; + use crate::{ class::Class, clicked::Clicked, + common_attrs::{Fill, Stroke}, pointer::{Pointer, PointerMsg}, view::View, }; pub trait ViewExt: View + Sized { - fn clicked(self, f: F) -> Clicked; - fn pointer(self, f: F) -> Pointer { + fn clicked(self, f: F) -> Clicked { + crate::clicked::clicked(self, f) + } + + fn pointer(self, f: F) -> Pointer { crate::pointer::pointer(self, f) } - fn class(self, class: impl Into) -> Class { + + fn class(self, class: impl Into) -> Class { crate::class::class(self, class) } -} -impl> ViewExt for V { - fn clicked(self, f: F) -> Clicked { - crate::clicked::clicked(self, f) + fn fill(self, brush: impl Into) -> Fill { + crate::common_attrs::fill(self, brush) + } + + fn stroke(self, brush: impl Into, style: peniko::kurbo::Stroke) -> Stroke { + crate::common_attrs::stroke(self, brush, style) } } + +impl> ViewExt for V {} diff --git a/crates/xilem_svg/web_examples/svgtoy/Cargo.toml b/crates/xilem_svg/web_examples/svgtoy/Cargo.toml new file mode 100644 index 000000000..6e0fb2706 --- /dev/null +++ b/crates/xilem_svg/web_examples/svgtoy/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "svgtoy" +version = "0.1.0" +publish = false +license.workspace = true +edition.workspace = true + +[dependencies] +console_error_panic_hook = "0.1" +wasm-bindgen = "0.2.87" +web-sys = "0.3.64" +xilem_svg = { path = "../.." } diff --git a/crates/xilem_svg/web_examples/svgtoy/index.html b/crates/xilem_svg/web_examples/svgtoy/index.html new file mode 100644 index 000000000..0bc7c6ac5 --- /dev/null +++ b/crates/xilem_svg/web_examples/svgtoy/index.html @@ -0,0 +1,19 @@ + + + + + + diff --git a/crates/xilem_svg/web_examples/svgtoy/src/main.rs b/crates/xilem_svg/web_examples/svgtoy/src/main.rs new file mode 100644 index 000000000..06cc20b96 --- /dev/null +++ b/crates/xilem_svg/web_examples/svgtoy/src/main.rs @@ -0,0 +1,82 @@ +// Copyright 2023 the Druid Authors. +// SPDX-License-Identifier: Apache-2.0 + +use xilem_svg::{ + group, + kurbo::{self, Rect}, + peniko::Color, + App, PointerMsg, View, ViewExt, +}; + +#[derive(Default)] +struct AppState { + x: f64, + y: f64, + grab: GrabState, +} + +#[derive(Default)] +struct GrabState { + is_down: bool, + id: i32, + dx: f64, + dy: f64, +} + +impl GrabState { + fn handle(&mut self, x: &mut f64, y: &mut f64, p: &PointerMsg) { + match p { + PointerMsg::Down(e) => { + if e.button == 0 { + self.dx = *x - e.x; + self.dy = *y - e.y; + self.id = e.id; + self.is_down = true; + } + } + PointerMsg::Move(e) => { + if self.is_down && self.id == e.id { + *x = self.dx + e.x; + *y = self.dy + e.y; + } + } + PointerMsg::Up(e) => { + if self.id == e.id { + self.is_down = false; + } + } + } + } +} + +fn app_logic(state: &mut AppState) -> impl View { + let v = (0..10) + .map(|i| Rect::from_origin_size((10.0 * i as f64, 150.0), (8.0, 8.0))) + .collect::>(); + group(( + Rect::new(100.0, 100.0, 200.0, 200.0).clicked(|_| { + web_sys::console::log_1(&"app logic clicked".into()); + }), + Rect::new(210.0, 100.0, 310.0, 200.0) + .fill(Color::LIGHT_GRAY) + .stroke(Color::BLUE, Default::default()), + Rect::new(320.0, 100.0, 420.0, 200.0).class("red"), + Rect::new(state.x, state.y, state.x + 100., state.y + 100.) + .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.x, &mut s.y, &msg)), + group(v), + 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)), + kurbo::Circle::new((460.0, 260.0), 45.0).clicked(|_| { + web_sys::console::log_1(&"circle clicked".into()); + }), + )) + //button(format!("Count {}", count), |count| *count += 1) +} + +pub fn main() { + console_error_panic_hook::set_once(); + let app = App::new(AppState::default(), app_logic); + app.run(); +}