diff --git a/Cargo.lock b/Cargo.lock index 4c4ce148..4f874968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,6 +1062,20 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fetch" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "console_log", + "gloo-net", + "log", + "serde", + "wasm-bindgen", + "web-sys", + "xilem_web", +] + [[package]] name = "flate2" version = "1.0.30" @@ -1291,6 +1305,36 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "gloo-utils", + "http", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.13.1" @@ -1423,6 +1467,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -4069,6 +4124,7 @@ version = "0.1.0" dependencies = [ "peniko", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "xilem_core", ] diff --git a/Cargo.toml b/Cargo.toml index 81322d9a..35b33f43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "xilem_web/web_examples/counter", "xilem_web/web_examples/counter_custom_element", "xilem_web/web_examples/elm", + "xilem_web/web_examples/fetch", "xilem_web/web_examples/todomvc", "xilem_web/web_examples/mathml_svg", "xilem_web/web_examples/svgtoy", diff --git a/xilem_web/Cargo.toml b/xilem_web/Cargo.toml index 075a2933..00db6482 100644 --- a/xilem_web/Cargo.toml +++ b/xilem_web/Cargo.toml @@ -23,6 +23,7 @@ workspace = true xilem_core = { workspace = true, features = ["kurbo"] } peniko.workspace = true wasm-bindgen = "0.2.92" +wasm-bindgen-futures = "0.4.42" [dependencies.web-sys] version = "0.3.69" diff --git a/xilem_web/src/attribute_value.rs b/xilem_web/src/attribute_value.rs index bb649531..04124cb8 100644 --- a/xilem_web/src/attribute_value.rs +++ b/xilem_web/src/attribute_value.rs @@ -9,8 +9,11 @@ type CowStr = std::borrow::Cow<'static, str>; #[derive(PartialEq, Clone, Debug, PartialOrd)] pub enum AttributeValue { True, // for the boolean true, this serializes to an empty string (e.g. for ) + I16(i16), + U16(u16), I32(i32), U32(u32), + Usize(usize), F32(f32), F64(f64), String(CowStr), @@ -20,8 +23,11 @@ impl AttributeValue { pub fn serialize(&self) -> CowStr { match self { AttributeValue::True => "".into(), // empty string is equivalent to a true set attribute + AttributeValue::I16(n) => n.to_string().into(), + AttributeValue::U16(n) => n.to_string().into(), AttributeValue::I32(n) => n.to_string().into(), AttributeValue::U32(n) => n.to_string().into(), + AttributeValue::Usize(n) => n.to_string().into(), AttributeValue::F32(n) => n.to_string().into(), AttributeValue::F64(n) => n.to_string().into(), AttributeValue::String(s) => s.clone(), @@ -56,9 +62,15 @@ impl IntoAttributeValue for AttributeValue { } } -impl IntoAttributeValue for u32 { +impl IntoAttributeValue for i16 { fn into_attr_value(self) -> Option { - Some(AttributeValue::U32(self)) + Some(AttributeValue::I16(self)) + } +} + +impl IntoAttributeValue for u16 { + fn into_attr_value(self) -> Option { + Some(AttributeValue::U16(self)) } } @@ -68,6 +80,18 @@ impl IntoAttributeValue for i32 { } } +impl IntoAttributeValue for u32 { + fn into_attr_value(self) -> Option { + Some(AttributeValue::U32(self)) + } +} + +impl IntoAttributeValue for usize { + fn into_attr_value(self) -> Option { + Some(AttributeValue::Usize(self)) + } +} + impl IntoAttributeValue for f32 { fn into_attr_value(self) -> Option { Some(AttributeValue::F32(self)) diff --git a/xilem_web/src/concurrent/memoized_await.rs b/xilem_web/src/concurrent/memoized_await.rs new file mode 100644 index 00000000..55153919 --- /dev/null +++ b/xilem_web/src/concurrent/memoized_await.rs @@ -0,0 +1,255 @@ +// Copyright 2024 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::{future::Future, marker::PhantomData}; + +use wasm_bindgen::{closure::Closure, JsCast, UnwrapThrowExt}; +use wasm_bindgen_futures::spawn_local; +use xilem_core::{MessageResult, Mut, NoElement, View, ViewId, ViewPathTracker}; + +use crate::{DynMessage, OptionalAction, ViewCtx}; + +/// Await a future returned by `init_future` invoked with the argument `data`, `callback` is called with the output of the future. `init_future` will be invoked again, when `data` changes. Use [`memoized_await`] for construction of this [`View`] +pub struct MemoizedAwait { + init_future: InitFuture, + data: Data, + callback: Callback, + debounce_ms: usize, + reset_debounce_on_update: bool, + #[allow(clippy::type_complexity)] + phantom: PhantomData (State, Action, OA, F, FOut)>, +} + +impl + MemoizedAwait +where + FOut: std::fmt::Debug + 'static, + F: Future + 'static, + InitFuture: Fn(&Data) -> F, +{ + /// Debounce the `init_future` function, when `data` updates, + /// when `reset_debounce_on_update == false` then this throttles updates each `milliseconds` + /// + /// The default for this is `0` + pub fn debounce_ms(mut self, milliseconds: usize) -> Self { + self.debounce_ms = milliseconds; + self + } + + /// When `reset` is `true`, everytime `data` updates, the debounce timeout is cleared until `init_future` is invoked. + /// This is only effective when `debounce > 0` + /// + /// The default for this is `true` + pub fn reset_debounce_on_update(mut self, reset: bool) -> Self { + self.reset_debounce_on_update = reset; + self + } + + fn init_future(&self, ctx: &mut ViewCtx, generation: u64) { + ctx.with_id(ViewId::new(generation), |ctx| { + let thunk = ctx.message_thunk(); + let future = (self.init_future)(&self.data); + spawn_local(async move { + thunk.push_message(MemoizedAwaitMessage::::Output(future.await)); + }); + }); + } +} + +/// Await a future returned by `init_future` invoked with the argument `data`, `callback` is called with the output of the resolved future. `init_future` will be invoked again, when `data` changes. +/// +/// The update behavior can be controlled, by [`debounce`](`MemoizedAwait::debounce`) and [`reset_debounce_on_update`](`MemoizedAwait::reset_debounce_on_update`) +/// +/// # Examples +/// +/// ``` +/// use xilem_web::{core::fork, concurrent::memoized_await, elements::html::div, interfaces::Element}; +/// +/// fn app_logic(state: &mut i32) -> impl Element { +/// fork( +/// div(*state), +/// memoized_await( +/// 10, +/// |count| std::future::ready(*count), +/// |state, output| *state = output, +/// ) +/// ) +/// } +/// ``` +pub fn memoized_await( + data: Data, + init_future: InitFuture, + callback: Callback, +) -> MemoizedAwait +where + State: 'static, + Action: 'static, + Data: PartialEq + 'static, + FOut: std::fmt::Debug + 'static, + F: Future + 'static, + InitFuture: Fn(&Data) -> F + 'static, + OA: OptionalAction + 'static, + Callback: Fn(&mut State, FOut) -> OA + 'static, +{ + MemoizedAwait { + init_future, + data, + callback, + debounce_ms: 0, + reset_debounce_on_update: true, + phantom: PhantomData, + } +} + +#[derive(Default)] +pub struct MemoizedAwaitState { + generation: u64, + schedule_update: bool, + // Closures are retained so they can be called by environment + schedule_update_fn: Option>, + schedule_update_timeout_handle: Option, + update: bool, +} + +impl MemoizedAwaitState { + fn clear_update_timeout(&mut self) { + if let Some(handle) = self.schedule_update_timeout_handle { + web_sys::window() + .unwrap_throw() + .clear_timeout_with_handle(handle); + } + self.schedule_update_timeout_handle = None; + self.schedule_update_fn = None; + } + + fn reset_debounce_timeout_and_schedule_update( + &mut self, + ctx: &mut ViewCtx, + debounce_duration: usize, + ) { + ctx.with_id(ViewId::new(self.generation), |ctx| { + self.clear_update_timeout(); + let thunk = ctx.message_thunk(); + let schedule_update_fn = Closure::new(move || { + thunk.push_message(MemoizedAwaitMessage::::ScheduleUpdate); + }); + let handle = web_sys::window() + .unwrap_throw() + .set_timeout_with_callback_and_timeout_and_arguments_0( + schedule_update_fn.as_ref().unchecked_ref(), + debounce_duration.try_into().unwrap_throw(), + ) + .unwrap_throw(); + self.schedule_update_fn = Some(schedule_update_fn); + self.schedule_update_timeout_handle = Some(handle); + self.schedule_update = true; + }); + } +} + +#[derive(Debug)] +enum MemoizedAwaitMessage { + Output(Output), + ScheduleUpdate, +} + +impl View + for MemoizedAwait +where + State: 'static, + Action: 'static, + OA: OptionalAction + 'static, + InitFuture: Fn(&Data) -> F + 'static, + FOut: std::fmt::Debug + 'static, + Data: PartialEq + 'static, + F: Future + 'static, + CB: Fn(&mut State, FOut) -> OA + 'static, +{ + type Element = NoElement; + + type ViewState = MemoizedAwaitState; + + fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + let mut state = MemoizedAwaitState::default(); + + if self.debounce_ms > 0 { + state.reset_debounce_timeout_and_schedule_update::(ctx, self.debounce_ms); + } else { + self.init_future(ctx, state.generation); + } + + (NoElement, state) + } + + fn rebuild<'el>( + &self, + prev: &Self, + view_state: &mut Self::ViewState, + ctx: &mut ViewCtx, + (): Mut<'el, Self::Element>, + ) -> Mut<'el, Self::Element> { + let debounce_has_changed_and_update_is_scheduled = view_state.schedule_update + && (prev.reset_debounce_on_update != self.reset_debounce_on_update + || prev.debounce_ms != self.debounce_ms); + + if debounce_has_changed_and_update_is_scheduled { + if self.debounce_ms == 0 { + if view_state.schedule_update_timeout_handle.is_some() { + view_state.clear_update_timeout(); + view_state.schedule_update = false; + view_state.update = true; + } + } else { + view_state + .reset_debounce_timeout_and_schedule_update::(ctx, self.debounce_ms); + return; // avoid update below, as it's already scheduled + } + } + + if view_state.update + || (prev.data != self.data + && (!view_state.schedule_update || self.reset_debounce_on_update)) + { + if !view_state.update && self.debounce_ms > 0 { + view_state + .reset_debounce_timeout_and_schedule_update::(ctx, self.debounce_ms); + } else { + // no debounce + view_state.generation += 1; + view_state.update = false; + self.init_future(ctx, view_state.generation); + } + } + } + + fn teardown(&self, state: &mut Self::ViewState, _: &mut ViewCtx, (): Mut<'_, Self::Element>) { + state.clear_update_timeout(); + } + + fn message( + &self, + view_state: &mut Self::ViewState, + id_path: &[ViewId], + message: DynMessage, + app_state: &mut State, + ) -> MessageResult { + assert_eq!(id_path.len(), 1); + if id_path[0].routing_id() == view_state.generation { + match *message.downcast().unwrap_throw() { + MemoizedAwaitMessage::Output(future_output) => { + match (self.callback)(app_state, future_output).action() { + Some(action) => MessageResult::Action(action), + None => MessageResult::Nop, + } + } + MemoizedAwaitMessage::ScheduleUpdate => { + view_state.update = true; + view_state.schedule_update = false; + MessageResult::RequestRebuild + } + } + } else { + MessageResult::Stale(message) + } + } +} diff --git a/xilem_web/src/concurrent/mod.rs b/xilem_web/src/concurrent/mod.rs new file mode 100644 index 00000000..7bfeb0b0 --- /dev/null +++ b/xilem_web/src/concurrent/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Async views, allowing concurrent operations, like fetching data from a server + +mod memoized_await; + +pub use memoized_await::{memoized_await, MemoizedAwait}; diff --git a/xilem_web/src/lib.rs b/xilem_web/src/lib.rs index 9a4f0240..70c8d5c4 100644 --- a/xilem_web/src/lib.rs +++ b/xilem_web/src/lib.rs @@ -43,6 +43,7 @@ mod text; mod vec_splice; mod vecmap; +pub mod concurrent; pub mod elements; pub mod interfaces; pub mod svg; diff --git a/xilem_web/web_examples/fetch/Cargo.toml b/xilem_web/web_examples/fetch/Cargo.toml new file mode 100644 index 00000000..893a73e9 --- /dev/null +++ b/xilem_web/web_examples/fetch/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fetch" +version = "0.1.0" +publish = false +license.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +console_error_panic_hook = "0.1" +console_log = "1" +gloo-net = { version = "0.5.0", default-features = false, features = ["http", "json", "serde"] } +log = "0.4" +serde = { version = "1", features = ["derive"] } +web-sys = { version = "0.3.69", features = ["Event", "HtmlInputElement"] } +wasm-bindgen = "0.2.92" +xilem_web = { path = "../.." } diff --git a/xilem_web/web_examples/fetch/index.html b/xilem_web/web_examples/fetch/index.html new file mode 100644 index 00000000..e76821ab --- /dev/null +++ b/xilem_web/web_examples/fetch/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/xilem_web/web_examples/fetch/src/main.rs b/xilem_web/web_examples/fetch/src/main.rs new file mode 100644 index 00000000..aaf17402 --- /dev/null +++ b/xilem_web/web_examples/fetch/src/main.rs @@ -0,0 +1,203 @@ +// Copyright 2024 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use wasm_bindgen::{JsCast, UnwrapThrowExt}; +use xilem_web::{ + concurrent::memoized_await, + core::{fork, one_of::Either}, + document_body, + elements::html::*, + interfaces::{Element, HtmlDivElement, HtmlImageElement, HtmlLabelElement}, + App, +}; + +use gloo_net::http::Request; +use serde::{Deserialize, Serialize}; + +const TOO_MANY_CATS: usize = 8; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cat { + pub url: String, + pub width: u16, + pub height: u16, +} + +struct AppState { + cats_to_fetch: usize, + cats_are_being_fetched: bool, + cats: Vec, + debounce_in_ms: usize, + reset_debounce_on_update: bool, + error: Option, +} + +impl Default for AppState { + fn default() -> Self { + Self { + cats_to_fetch: 0, + cats: Vec::new(), + debounce_in_ms: 1000, + cats_are_being_fetched: false, + reset_debounce_on_update: true, + error: None, + } + } +} + +async fn fetch_cats(count: usize) -> Result, gloo_net::Error> { + log::debug!("Fetch {count} cats"); + if count == 0 { + return Ok(Vec::new()); + } + let url = format!("https://api.thecatapi.com/v1/images/search?limit={count}"); + Ok(Request::get(&url) + .send() + .await? + .json::>() + .await? + .into_iter() + .take(count) + .collect()) +} + +pub fn input_target(event: &T) -> web_sys::HtmlInputElement +where + T: JsCast, +{ + event + .unchecked_ref::() + .target() + .unwrap_throw() + .unchecked_into::() +} + +fn app_logic(state: &mut AppState) -> impl HtmlDivElement { + div(( + cat_fetch_controls(state), + fork( + cat_images_and_fetching_indicator(state), + // Here's the actual fetching logic: + (state.cats_to_fetch < TOO_MANY_CATS).then_some( + memoized_await( + // This is given to the first closure right below which when resolved invokes the second closure with the output of the future, + // and when it changes, that first closure will be reevaluated again (similarly as the `Memoize` view). + // If `debounce_ms` below > `0`, then further updates (i.e. invocation of `fetch_cats`) are either throttled (when `!reset_debounce_on_update`), + // or debounced otherwise: + // As long as updates are happening within `debounce_in_ms` ms the first closure is not invoked, and a debounce timeout which runs `debounce_in_ms` is reset. + state.cats_to_fetch, + |count| fetch_cats(*count), + |state: &mut AppState, cats_result| match cats_result { + Ok(cats) => { + log::info!("Received {} cats", cats.len()); + state.cats = cats; + state.cats_are_being_fetched = false; + state.error = None; + } + Err(err) => { + log::warn!("Unable to fetch cats: {err:#}"); + state.cats_are_being_fetched = false; + state.error = Some(err.to_string()); + } + }, + ) + .debounce_ms(state.debounce_in_ms) + .reset_debounce_on_update(state.reset_debounce_on_update), + ), + ), + )) +} + +fn cat_images_and_fetching_indicator(state: &AppState) -> impl HtmlDivElement { + let cat_images = state + .cats + .iter() + .map(|cat| { + img(()) + .src(cat.url.clone()) + .attr("width", cat.width) + .attr("height", cat.height) + }) + .collect::>(); + let error_message = state + .error + .as_ref() + .map(|err| div((h2("Error"), p(err.to_string()))).class("error")); + div(( + error_message, + if state.cats_to_fetch != 0 && state.cats_to_fetch == cat_images.len() { + Either::A(h1("Here are your cats:").class("blink")) + } else if state.cats_to_fetch >= TOO_MANY_CATS { + Either::B(p("Woah there, that's too many cats")) + } else if state.debounce_in_ms > 0 + && state.cats_to_fetch > 0 + && state.reset_debounce_on_update + { + Either::B(p("Debounced fetch of cats...")) + } else if state.debounce_in_ms > 0 && state.cats_to_fetch > 0 { + Either::B(p("Throttled fetch of cats...")) + } else if state.cats_to_fetch > 0 && state.cats_are_being_fetched { + Either::B(p("Fetching cats...")) + } else { + Either::B(p("You need to fetch cats")) + }, + cat_images, + )) +} + +fn cat_fetch_controls(state: &AppState) -> impl Element { + fieldset(( + legend("Cat fetch controls"), + table(( + tr(( + td(label("How many cats would you like?").for_("cat-count")), + td(input(()) + .id("cat-count") + .attr("type", "number") + .attr("min", 0) + .attr("value", state.cats_to_fetch) + .on_input(|state: &mut AppState, ev: web_sys::Event| { + if !state.cats_are_being_fetched { + state.cats.clear(); + } + state.cats_are_being_fetched = true; + state.cats_to_fetch = input_target(&ev).value().parse().unwrap_or(0); + })), + )), + tr(( + td( + label("Reset fetch debounce timeout when updating the cat count:") + .for_("reset-debounce-update"), + ), + td(input(()) + .id("reset-debounce-update") + .attr("type", "checkbox") + .attr("checked", state.reset_debounce_on_update) + .on_input(|state: &mut AppState, event: web_sys::Event| { + state.reset_debounce_on_update = input_target(&event).checked(); + })), + )), + tr(( + td(label("Debounce timeout in ms:").for_("debounce-timeout-duration")), + td(input(()) + .id("debounce-timeout-duration") + .attr("type", "number") + .attr("min", 0) + .attr("value", state.debounce_in_ms) + .on_input(|state: &mut AppState, ev: web_sys::Event| { + state.debounce_in_ms = input_target(&ev).value().parse().unwrap_or(0); + })), + )), + )), + )) + .class("cat-fetch-controls") +} + +pub fn main() { + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + log::info!("Start application"); + + App::new(document_body(), AppState::default(), app_logic).run(); +}