Cooking with Mogwai
This book contains explanations and examples of solving common user interface problems using the mogwai rust library. It is a work in progress.
Any requests, questions, or improvements may be logged in the issue tracker.
See the library documentation for an introduction to the library in general.
Visit us at the support channel.
Happy hacking!
π¦
Starting a new project
First you'll need a new(ish) version of the rust toolchain. For that you can visit https://rustup.rs/ and follow the installation instructions.
Browser apps
In most cases this cookbook assumes you'll be using mogwai-dom
to build browser
applications.
For that you'll need wasm-pack or trunk.
If you haven't set up a WASM project before it is recommended you read up on Rust and WebAssembly.
If you just want to quickly get hacking you may use the wonderful cargo-generate
, which
can be installed using cargo install cargo-generate
.
Then run
cargo generate --git https://github.com/schell/mogwai-template.git
and give the command line a project name. Then cd
into your sparkling new
project and
wasm-pack build --target web
Then, if you don't already have it, cargo install basic-http-server
or use your
favorite alternative to serve your app:
basic-http-server -a 127.0.0.1:8888
Rust Syntax Extension
Consider this variable declaration:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let element = rsx!{ h1(){"Hello, world!"} }; }
This funny tag syntax is neither a string nor Rust code - it is a ViewBuilder
.
The macro rsx!
is using RSX, which is a "Rust Syntax Extension".
Similarly there is a html!
macro that creates ViewBuilder
from an HTML-ish
syntax:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let element = html!{ <h1>"Hello, world!"</h1> }; }
The two definitions are synonymous.
We recommend using these macros in mogwai to describe the DOM nodes used by your components. RSX cuts down on the amount of boilerplate you have to type when writing components. RSX may remind you of a template language, but it comes with the full power of Rust.
You can always write your components without RSX - here is the same example above written out manually:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let my_builder: ViewBuilder = ViewBuilder::element("h1") .append(ViewBuilder::text("Hello, world!")); }
Tags
You may use any html tags you wish when writing RSX with mogwai_dom
.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let _: ViewBuilder = html! { <p>"Once upon a time in a galaxy far, far away..."</p> }; }
Attributes
Adding attributes happens the way you expect it to.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let _: ViewBuilder = html! { <p id="starwars">"Once upon a time in a galaxy far, far away..."</p> }; }
All html attributes are supported.
Attributes that have hyphens should be written with underscores.
Special Mogwai Attributes
Additionally there are some mogwai
specific attributes that do special things.
These are all denoted by two words separated by
a colon, with an expression on the right hand side. In most cases the right hand
side is allowed to be a String
, an &str
, an impl Stream<Item = String>
or a
tuple of a stringy type and a string stream. See MogwaiValue
for more details about types that can be turned into streams.
-
style:{name} =
impl Into<MogwaiValue<String or &str, Stream<Item = String>>
Declares a single style.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let _ = html! { <a href="#burritos" style:border="1px dashed #333">"link"</a> }; }
-
on:{event} =
impl Sink<DomEvent>
Declares that the events of a certain type (
event
) occurring on the element should be sent on the given sender. You will often see the use of SinkExt::contra_map in this position to "prefix map" the type of theSender
.#![allow(unused)] fn main() { use mogwai_dom::prelude::*; use mogwai_dom::core::channel::{broadcast, ONE}; let (tx, _rx) = broadcast::bounded::<()>(ONE); let _ = html! { <div on:click=tx.contra_map(|_:DomEvent| ())>"Click me!"</div> }; }
-
window:{event} =
impl Sink<DomEvent>
Declares that the windows's matching events should be sent on the given sender.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; use mogwai_dom::core::channel::{broadcast, ONE}; use mogwai_dom::prelude::*; let (tx, rx) = broadcast::bounded::<()>(ONE); let _ = html! { <div window:load=tx.contra_map(|_:DomEvent| ())>{("", rx.map(|()| "Loaded!".to_string()))}</div> }; }
-
document:{event} =
impl Sink<DomEvent>
Declares that the document's matching events should be sent on the given transmitter.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; use mogwai_dom::core::channel::{broadcast, ONE}; let (tx, rx) = broadcast::bounded::<String>(ONE); let _ = html! { <div document:keyup=tx.contra_map(|ev: DomEvent| format!("{:#?}", ev))>{("waiting for first event", rx)}</div> }; }
-
boolean:{name} =
impl Into<MogwaiValue<bool, Stream<Item = bool>>
Declares a boolean attribute with the given name.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let _ = html! { <input boolean:checked=true /> }; }
-
patch:children =
impl Stream<ListPatch<ViewBuilder>>
Declares that this element's children will be updated with a stream of ListPatch.
Note
ViewBuilder is not
Clone
. For this reason we cannot usemogwai::channel::broadcast::{Sender, Receiver}
channels to send patches, because a broadcast channel requires its messages to beClone
. Instead usemogwai::channel::mpsc::{Sender, Receiver}
channels, which have no such requirement. Just remember that even though theReceiver
can be cloned, if ampsc::Sender
has more than onempsc::Receiver
listening, only one will receive the message and the winningReceiver
alternates in round-robin style.let (tx, rx) = mpsc::bounded(1); let my_view = SsrDom::try_from(html! { <div id="main" patch:children=rx>"Waiting for a patch message..."</div> }) .unwrap(); my_view .executor .run(async { tx.send(ListPatch::drain()).await.unwrap(); // just as a sanity check we wait until the view has removed all child // nodes repeat_times(0.1, 10, || async { my_view.html_string().await == r#"<div id="main"></div>"# }) .await .unwrap(); let other_viewbuilder = html! { <h1>"Hello!"</h1> }; tx.send(ListPatch::push(other_viewbuilder)).await.unwrap(); // now wait until the view has been patched with the new child repeat_times(0.1, 10, || async { let html_string = my_view.html_string().await; html_string == r#"<div id="main"><h1>Hello!</h1></div>"# }) .await .unwrap(); }) .await;
-
post:build =
FnOnce(&mut T)
Used to apply one-off changes to the domain specific view
T
after it has been built. -
capture:view =
impl Sink<T>
Used to capture a clone of the view after it has been built. The view type
T
must beClone
. For more info see Capturing Views -
cast:type = Any domain specific inner view type, eg
Dom
Declares the inner type of the resulting ViewBuilder. By default this is Dom.
use mogwai_dom::prelude::*; let my_input: ViewBuilder<MyCustomInnerView> = html! { <input cast:type=MyCustomInnerView /> };
Expressions
Rust expressions can be used as the values of attributes and as child nodes.
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let is_cool = true; let _ = html! { <div> { if !is_cool { "This is hot." } else { "This is cool." } } </div> }; }
Conditionally include DOM
Within a tag or at the top level of an RSX macro, anything inside literal brackets is interpreted and used
as Into<ViewBuilder<T>>
. Any type that can be converted into a ViewBuilder
can be used to construct a node including Option<impl Into<ViewBuilder<T>>
. When the value is None
,
an empty node is created.
Below we display a user's image if they have one:
let o_image: Option<ViewBuilder> = user
.o_image
.as_ref()
.map(|image| {
if image.is_empty() {
None
} else {
Some(html! { <img class="user-pic" src=image /> })
}
})
.flatten();
rsx! {
ul(class="nav navbar-nav pull-xs-right") {
li(class="nav-item") {
a(class=home_class, href="#/"){ " Home" }
}
li(class="nav-item") {
a(class=editor_class, href="#/editor") {
i(class="ion-compose") {
" New Post"
}
}
}
li(class="nav-item") {
a(class=settings_class, href="#/settings") {
i(class="ion-gear-a"){
" Settings"
}
}
}
li(class="nav-item") {
a(class=profile_class, href=format!("#/profile/{}", user.username)) {
{o_image}
{format!(" {}", user.username)}
}
}
}
}
Including fragments
You can use RSX to build more than one view at a time:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; // Create a vector with three builders in it. let builders: Vec<ViewBuilder> = html! { <div>"hello"</div> <div>"hola"</div> <div>"kia ora"</div> }; // Then add them all into a parent tag just like any component let parent: ViewBuilder = html! { <section>{builders}</section> }; }
Without RSX
It is possible and easy to create mogwai views without RSX by using the API provided by ViewBuilder.
Components
A "component", "widget", "gizmo" in mogwai
is a view and zero or more async tasks that modify
that view. To create a view we use a ViewBuilder:
Components may have asynchronous tasks appended to them. When the view is built its tasks will be spawned until they are complete, or when the view implementation decides to drop and cancel them.
A view talks to its task loops using sinks and streams.
This is the mode of mogwai
.
Here is an example of a click counter:
fn counter(recv_parent_msg: impl Stream<Item = CounterMsg> + Send + 'static) -> ViewBuilder {
let clicked = Output::<CounterMsg>::default();
let mut num_clicks = Input::<u32>::default();
let click_stream = clicked.get_stream();
rsx! (
button(on:click = clicked.sink().contra_map(|_: JsDomEvent| CounterMsg::Click)) {
{(
"clicks = 0",
num_clicks.stream().unwrap().map(|n| format!("clicks = {}", n))
)}
}
)
.with_task(async move {
let mut msg = click_stream.boxed().or(recv_parent_msg.boxed());
let mut clicks: u32 = 0;
loop {
match msg.next().await {
Some(CounterMsg::Click) => {
clicks += 1;
}
Some(CounterMsg::Reset) => {
clicks = 0;
}
None => break,
}
num_clicks.set(clicks).await.unwrap();
}
})
}
We can nest the counter component in another component:
fn app() -> ViewBuilder {
let reset_clicked = Output::<CounterMsg>::default();
rsx! {
div() {
"Application"
br(){}
{counter(reset_clicked.get_stream())}
button(on:click = reset_clicked.sink().contra_map(|_:JsDomEvent| CounterMsg::Reset)){
"Click to reset"
}
}
}
}
And then build it all into one view:
let view = JsDom::try_from(app()).unwrap();
πΈοΈ Capturing parts of the View
Views often contain nodes that are required in the logic loop. When a view node is needed in a
logic loop we can capture it using a sink, or better yet, Captured
.
Using the capture:view
attribute
To capture a view after it is built you can use the capture:view
attribute
with an impl Sink<T>
, where T
is your domain view type, and then await the first message on the
receiver:
use mogwai_dom::core::channel::broadcast;
use mogwai_dom::prelude::*;
futures::executor::block_on(async {
println!("using channels");
let (tx, mut rx) = broadcast::bounded::<Dom>(1.try_into().unwrap());
let builder = rsx! {
div(){
button(capture:view = tx) { "Click" }
}
};
let div = Dom::try_from(builder).unwrap();
div.run_while(async move {
let _button: Dom = rx.next().await.unwrap();
})
.await
.unwrap();
});
Using the Captured
type
To make capturing a view easier you can use the Captured
type, which encapsulates
the ends of a channel with a nice API:
π’ Facades and Relays π
Components talk within themselves from the view to the logic and vice versa, but there are often stakeholders outside the component that would like to make queries, inject state, or otherwise communicate with the internals of the component.
Because of this it is often convenient to provide a wrapper around view inputs and outputs that have a nice API that breaks down requests from outside the component into its internal messages. These wrappers can be called facades, or relays and they might be a bit familiar if you've worked with MVC frameworks - but don't worry if you haven't! Mogwai is not a framework or a philosophy and "whatever works is good".
Inputs and Outputs instead of channels
To be a successful programmer of message passing systems, you have to know details about the channels you're using to pass messages. You should know how much capacity they have and what happens when you send messages above its capacity, if they clone, how they clone as well as any other idiosyncracies of the channel. It can be quite a lot to wrangle!
To make this easier mogwai provides some channel wrappers in the relay module. These wrappers provide a simplified API that makes working with a view's inputs and outputs easier.
- Input - an input to a view that has at most one consumer.
- FanInput - an input to a view that may have many consumers.
- Output - an output from a view.
Access to the raw view
You can access built, raw views by using Captured. Read more about this in Capturing parts of the view.
Model, ListPatchModel and HashPatchModel
You can share data between multiple components and react to streams of updates using the types in the model module.
- Model - wraps a shared
T
and provides a stream of the latest value to observers. - ListPatchModel - wraps a vector of
T
and provides a stream of ListPatch to observers. - HashPatchModel - wraps a hashmap of
K
keys andV
values, providing a stream of HashPatch to observers.
Helper struct
With these tools we can create a helper struct that can create our ViewBuilder
and
then be used to control it through the API of our choosing, hiding the input/output implementation details.
A detailed example of this the
TodoItem
from the todomvc
example project:
//! Provides a todo line-item that can be edited by double clicking,
//! marked as complete or removed.
use mogwai_dom::{
core::{model::Model, stream},
prelude::*,
};
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, KeyboardEvent};
/// Used to set the todo item's `<li>` class
pub enum ItemClass {
None,
Editing,
Completed,
}
impl ItemClass {
fn is_done(is_done: bool) -> Self {
if is_done {
ItemClass::Completed
} else {
ItemClass::None
}
}
// see as_list_class
fn to_string(self) -> String {
match self {
ItemClass::None => "",
ItemClass::Editing => "editing",
ItemClass::Completed => "completed",
}
.to_string()
}
}
/// Determines the source of a "stop editing" event.
#[derive(Clone, Debug)]
enum StopEditingEvent {
Enter,
Escape,
Blur,
}
/// Messages that come out of the todo item and out to the list.
#[derive(Clone)]
pub enum TodoItemMsg {
Completion,
Remove(usize),
}
/// Messages that come from the list into the todo item via
/// pub async functions exposed on [`TodoItem`].
#[derive(Clone, PartialEq)]
enum ListItemMsg {
SetComplete(bool),
SetVisible(bool),
}
#[derive(Clone)]
pub struct TodoItem {
pub id: usize,
pub complete: Model<bool>,
pub name: Model<String>,
output_to_list: Output<TodoItemMsg>,
input_to_item: FanInput<ListItemMsg>,
}
impl TodoItem {
pub fn new(
id: usize,
name: impl Into<String>,
complete: bool,
output_to_list: Output<TodoItemMsg>,
) -> Self {
let input_to_item = FanInput::default();
TodoItem {
name: Model::new(name.into()),
complete: Model::new(complete),
id,
output_to_list,
input_to_item,
}
}
pub async fn set_complete(&self, complete: bool) {
self.input_to_item
.set(ListItemMsg::SetComplete(complete))
.await
.expect("could not set complete");
}
pub async fn set_visible(&self, visible: bool) {
self.input_to_item
.set(ListItemMsg::SetVisible(visible))
.await
.expect("could not set visible")
}
fn stream_of_is_visible_display(&self) -> impl Stream<Item = String> {
stream::iter(std::iter::once("block".to_string())).chain(
self.input_to_item.stream().filter_map(|msg| match msg {
ListItemMsg::SetVisible(is_visible) => {
Some(if is_visible { "block" } else { "none" }.to_string())
}
_ => None,
}),
)
}
async fn task_start_editing(
self,
captured_edit_input: Captured<JsDom>,
input_item_class: Input<ItemClass>,
output_label_double_clicked: Output<()>,
) {
let edit_input: JsDom = captured_edit_input.get().await;
let starting_name: String = self.name.read().await.clone();
edit_input.visit_as(|el: &HtmlInputElement| el.set_value(&starting_name));
while let Some(()) = output_label_double_clicked.get().await {
// set the input to "editing"
input_item_class
.set(ItemClass::Editing)
.await
.expect("can't set editing class");
// give a moment for the class to update and make the input editable
mogwai_dom::core::time::wait_millis(10).await;
// focus the input
edit_input.visit_as(|el: &HtmlInputElement| el.focus().expect("can't focus"));
}
}
async fn task_stop_editing(
self,
captured_edit_input: Captured<JsDom>,
input_item_class: Input<ItemClass>,
output_edit_onkeyup: Output<JsDomEvent>,
output_edit_onblur: Output<()>,
) {
let edit_input = captured_edit_input.get().await;
let on_keyup = output_edit_onkeyup.get_stream().map(Either::Left);
let on_blur = output_edit_onblur.get_stream().map(Either::Right);
let mut events = on_keyup.boxed().or(on_blur.boxed());
while let Some(e) = events.next().await {
log::info!("stop editing event");
let may_edit_event = match e {
// keyup
Either::Left(ev) => {
log::info!(" keyup");
// Get the browser event or filter on non-wasm targets.
let ev = ev.browser_event().expect("can't get keyup event");
// This came from a key event
let kev = ev.unchecked_ref::<KeyboardEvent>();
let key = kev.key();
if key == "Enter" {
Some(StopEditingEvent::Enter)
} else if key == "Escape" {
Some(StopEditingEvent::Escape)
} else {
None
}
}
// blur
Either::Right(()) => {
log::info!(" blur");
Some(StopEditingEvent::Blur)
}
};
if let Some(ev) = may_edit_event {
match ev {
StopEditingEvent::Enter | StopEditingEvent::Blur => {
let input_name = edit_input
.visit_as(|i: &HtmlInputElement| crate::utils::input_value(i).unwrap());
if let Some(s) = input_name {
self.name
.visit_mut(|name| {
*name = s;
})
.await;
}
}
StopEditingEvent::Escape => {
let name = self.name.read().await.clone();
edit_input
.visit_as(|i: &HtmlInputElement| i.set_value(&name))
.unwrap();
}
}
input_item_class
.set(ItemClass::None)
.await
.expect("can't set editing class");
}
}
}
async fn task_toggle_complete(
self,
captured_complete_toggle: Captured<JsDom>,
output_complete_toggle_clicked: Output<()>,
) {
let toggle_input_element = captured_complete_toggle.get().await;
let done = *self.complete.read().await;
toggle_input_element.visit_as(|el: &HtmlInputElement| el.set_checked(done));
let mut events = self.input_to_item.stream().map(Either::Left).boxed().or(
output_complete_toggle_clicked
.get_stream()
.map(Either::Right)
.boxed(),
);
while let Some(ev) = events.next().await {
match ev {
Either::Left(ListItemMsg::SetComplete(done)) => {
toggle_input_element
.visit_as(|el: &HtmlInputElement| el.set_checked(done))
.expect("could not set checked");
self.complete.visit_mut(|c| *c = done).await;
}
Either::Left(_) => {}
Either::Right(()) => {
let done = toggle_input_element
.visit_as(|el: &HtmlInputElement| el.checked())
.unwrap_or_default();
let _ = self.complete.visit_mut(|d| *d = done).await;
let _ = self.output_to_list.send(TodoItemMsg::Completion).await;
}
}
}
}
async fn task_remove_item(self, output_remove_button_clicked: Output<()>) {
while let Some(()) = output_remove_button_clicked.get().await {
self.output_to_list
.send(TodoItemMsg::Remove(self.id))
.await
.expect("could not send removal");
}
}
pub fn viewbuilder(self) -> ViewBuilder {
let captured_complete_toggle_dom = Captured::<JsDom>::default();
let captured_edit_input = Captured::<JsDom>::default();
let mut input_item_class = Input::<ItemClass>::default();
let output_complete_toggle_clicked = Output::<()>::default();
let output_remove_button_clicked = Output::<()>::default();
let output_label_double_clicked = Output::<()>::default();
let output_edit_onblur = Output::<()>::default();
let output_edit_onkeyup = Output::<JsDomEvent>::default();
let builder = rsx! {
li(
class = (
ItemClass::is_done(self.complete.current().expect("could not read complete")).to_string(),
input_item_class.stream().unwrap()
.map(ItemClass::to_string).boxed()
.or(
self.complete.stream().map(|done| ItemClass::is_done(done).to_string()).boxed()
)
),
style:display = self.stream_of_is_visible_display()
) {
div(class="view") {
input(
class = "toggle",
type_ = "checkbox",
style:cursor = "pointer",
capture:view = captured_complete_toggle_dom.sink(),
on:click = output_complete_toggle_clicked.sink().contra_map(|_:JsDomEvent| ())
){}
label(on:dblclick = output_label_double_clicked.sink().contra_map(|_:JsDomEvent| ())) {
{(
self.name.current().expect("current name"),
self.name.stream()
)}
}
button(
class = "destroy",
style = "cursor: pointer;",
on:click = output_remove_button_clicked.sink().contra_map(|_:JsDomEvent| ()),
){}
}
input(
class = "edit",
capture:view = captured_edit_input.sink(),
on:blur = output_edit_onblur.sink().contra_map(|_:JsDomEvent| ()),
on:keyup = output_edit_onkeyup.sink()
){}
}
};
builder
.with_task(self.clone().task_start_editing(
captured_edit_input.clone(),
input_item_class.clone(),
output_label_double_clicked,
))
.with_task(self.clone().task_stop_editing(
captured_edit_input,
input_item_class,
output_edit_onkeyup,
output_edit_onblur,
))
.with_task(
self.clone().task_toggle_complete(
captured_complete_toggle_dom,
output_complete_toggle_clicked,
),
)
.with_task(self.task_remove_item(output_remove_button_clicked))
}
}
Notice above in TodoItem::viewbuilder
that we have many more captured views, inputs and outputs than we
keep in TodoItem
. This is because they are only needed for specific async tasks and are never accessed
from outside the TodoItem
. These could have been included in TodoItem
without harm and the choice of how to
structure your application is up to you.
Child components
Any impl Into<ViewBuilder>
can be nested as a child node of another ViewBuilder.
This includes:
- ViewBuilder
String
&str
(String, impl Stream<Item = String>)
(&str, impl Stream<Item = String>)
Additionally some container/iterator types can be nested, with slightly different behavior:
Option<impl Into<ViewBuilder>>
- ifNone
, no child is added, otherwise ifSome(viewbuilder)
,viewbuilder
is added. See conditionally adding DOM.Vec<impl Into<ViewBuilder>>
- all children are appended one after another. See including fragments
To nest a child component within a parent, simply include it as a node using RSX brackets:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let child = html! {<p>"My paragraph"</p>}; let parent = html! {<div>{child}</div>}; }
Or use the ViewBuilder::append
function if you're not using RSX:
#![allow(unused)] fn main() { use mogwai_dom::prelude::*; let child: ViewBuilder = ViewBuilder::element("p") .append(ViewBuilder::text("My paragraph")); let parent = ViewBuilder::element("div") .append(child); }
If there is a std
type that you feel should have an impl Into<ViewBuilder>
or a container/iterator
that you'd like mogwai
to support with regards to appending children, please open
an issue at the mogwai github repo.
A List of Components
A list of components is a common interface pattern. In mogwai
we can express this using two component
definitions. In this example we'll use a list
component and an item
component.
Contents
Explanation
Views
The list
defines its view to include a button
to create new items and an ol
to hold them.
Messages will flow from the view into two async tasks, which will patch the items using the patch:children
RSX attribute (which we can be set to anything that implements Stream<Item = ListPatch<ViewBuilder<JsDom>>
).
rsx! {
fieldset() {
legend(){ "A List of Gizmos" }
button(
style:cursor = "pointer",
on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Create a new item"
}
fieldset() {
legend(){ "Items" }
ol(
patch:children = items
.stream()
.map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
){}
}
}
}
Each item has a unique id
that helps us determine which items to remove.
Each item contains a button to remove the item from the list as well as a button to increment a counter. Click events on these buttons will be as an output into an async task that determines what to do next.
The view receives a count of the current number of clicks from a stream and uses that to display a nice message to the user.
rsx! {
li() {
button(
style:cursor = "pointer",
on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Increment"
}
button(
style:cursor = "pointer",
// every time the user clicks, we'll send the id as output
on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
) {
"Remove"
}
{" "}
span() {
{
("", num_clicks.stream().map(|clicks| match clicks {
1 => "1 click".to_string(),
n => format!("{} clicks", n),
}))
}
}
}
}
Logic and Communication
Just like most other components, both list
and item
have their own async tasks that loop over incoming messages.
These messages encode all the events that occur in our views.
The difference between this list of items and other less complex widgets is the way items communicate up to the parent list and how view updates are made to the parent list.
As you can see below, item
takes the item ID _and a remove_item_clicked: Output<ItemId>
, which it plugs into
the "remove" button's on:click
attribute.
This sends any click events straight to the the caller of item
.
It is this output that list
uses to receiver remove events from its items.
Also notice that we're using Model
to keep the state of the number of clicks.
Model
is convenient here because it tracks the state and automatically streams any
updates to downstream observers.
/// An id to keep track of item nodes
#[derive(Clone, Copy, Debug, PartialEq)]
struct ItemId(usize);
/// Creates an individual item.
///
/// Takes the id of the item (which will be unique) and an `Output` to send
/// "remove item" click events (so the item itself can inform the parent when
/// it should be removed).
fn item(id: ItemId, remove_item_clicked: Output<ItemId>) -> ViewBuilder {
let increment_item_clicked = Output::<()>::default();
let num_clicks = Model::new(0u32);
rsx! {
li() {
button(
style:cursor = "pointer",
on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Increment"
}
button(
style:cursor = "pointer",
// every time the user clicks, we'll send the id as output
on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
) {
"Remove"
}
{" "}
span() {
{
("", num_clicks.stream().map(|clicks| match clicks {
1 => "1 click".to_string(),
n => format!("{} clicks", n),
}))
}
}
}
}
.with_task(async move {
while let Some(_) = increment_item_clicked.get().await {
num_clicks.visit_mut(|n| *n += 1).await;
}
log::info!("item {} loop is done", id.0);
})
}
The list
uses ListPatchModel
to maintain our list if items.
ListPatchModel
is much like Model
but is special in that every time itself is patched
it sends a clone of that patch to downstream listeners.
So while Model
sends a clone of the full updated inner value, ListPatchModel
sends the diff.
This makes it easy to map the patch from ItemId
to ViewBuilder
and use the resulting stream to patch the list's view:
// Maps a patch of ItemId into a patch of ViewBuilder
fn map_item_patch(
patch: ListPatch<ItemId>,
remove_item_clicked: Output<ItemId>,
) -> ListPatch<ViewBuilder> {
patch.map(|id| item(id, remove_item_clicked.clone()))
}
/// Our list of items.
///
/// Set up our communication from items to this logic loop by
/// * giving each created item a clone of a shared `Output<ItemId>` to send events from
/// * creating a list patch model to update from two separate async tasks (one to create, one to remove)
/// * receive output "removal" messages and patch the list patch model
/// * receive output "create" messages and patch the list patch model
fn list() -> ViewBuilder {
let remove_item_clicked = Output::<ItemId>::default();
let remove_item_clicked_patch = remove_item_clicked.clone();
let new_item_clicked = Output::<()>::default();
let items: ListPatchModel<ItemId> = ListPatchModel::new();
let items_remove_loop = items.clone();
rsx! {
fieldset() {
legend(){ "A List of Gizmos" }
button(
style:cursor = "pointer",
on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Create a new item"
}
fieldset() {
legend(){ "Items" }
ol(
patch:children = items
.stream()
.map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
){}
}
}
}
.with_task(async move {
// add new items
let mut next_id = 0;
while let Some(_) = new_item_clicked.get().await {
log::info!("creating item {}", next_id);
let id = ItemId(next_id);
next_id += 1;
let patch = ListPatch::push(id);
items.patch(patch).await.expect("could not patch");
}
log::info!("list 'add' loop is done - should never happen");
}).with_task(async move {
// remove items
while let Some(remove_id) = remove_item_clicked.get().await {
let items_read = items_remove_loop.read().await;
let index = items_read.iter().enumerate().find_map(|(i, id)| if id == &remove_id {
Some(i)
} else {
None
}).unwrap();
drop(items_read);
log::info!("removing item {} at index {}", remove_id.0, index);
let patch = ListPatch::remove(index);
items_remove_loop.patch(patch).await.expect("could not patch");
}
log::info!("list 'remove' loop is done - should never happen");
})
}
This is a good example of how mogwai
separates component state from component views. The list logic doesn't own the
view and doesn't maintain the list of DOM nodes. Instead, the view has a patching mechanism that is set with a stream and then the logic maintains a collection of ItemId
s that it patches locally - triggering downstream patches to the view automatically.
Notes
In this example you can see that unlike other vdom based libraries, mogwai's state is completely separate from its views. Indeed they even require separate handling to "keep them in sync". This is by design. In general a mogwai app tends to be more explicit and less magical than its vdom counterparts.
Code
You can see the code in its entirety at mogwai's list of components example.
Play with it
Single Page App Routing
SPA routing is often needed to represent different resources within a single page application.
Here we define a method of routing that relies on conversions of types and mogwai's patch:children
RSX attribute.
Contents
Explanation
We first define a Route
type which will hold all our available routes.
Then we create conversion implementations to convert from a window hash string into a Route
and back again.
For this routing example (and in order to keep it simple) we also implement From<Route> for ViewBuilder
,
which we'll use to create views from our route.
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
/// Here we enumerate all our app's routes.
#[derive(Clone, Debug, PartialEq)]
pub enum Route {
Home,
Settings,
Profile {
username: String,
is_favorites: bool,
},
}
/// We'll use TryFrom::try_from to convert the window's url hash into a Route.
impl TryFrom<&str> for Route {
type Error = String;
fn try_from(s: &str) -> Result<Route, String> {
trace!("route try_from: {}", s);
// remove the scheme, if it has one
let hash_split = s.split("#").collect::<Vec<_>>();
let after_hash = match hash_split.as_slice() {
[_, after] => Ok(after),
_ => Err(format!("route must have a hash: {}", s)),
}?;
let paths: Vec<&str> = after_hash.split("/").collect::<Vec<_>>();
trace!("route paths: {:?}", paths);
match paths.as_slice() {
[""] => Ok(Route::Home),
["", ""] => Ok(Route::Home),
["", "settings"] => Ok(Route::Settings),
// Here you can see that profile may match two different routes -
// '#/profile/{username}' and '#/profile/{username}/favorites'
["", "profile", username] => Ok(Route::Profile {
username: username.to_string(),
is_favorites: false,
}),
["", "profile", username, "favorites"] => Ok(Route::Profile {
username: username.to_string(),
is_favorites: true,
}),
r => Err(format!("unsupported route: {:?}", r)),
}
}
}
#[cfg(test)]
mod test_route_try_from {
use super::*;
#[test]
fn can_convert_string_to_route() {
let s = "https://localhost:8080/#/";
assert_eq!(Route::try_from(s), Ok(Route::Home));
}
}
/// Convert the route into its hashed string.
/// This should match the inverse conversion in TryFrom above.
impl From<Route> for String {
fn from(route: Route) -> String {
match route {
Route::Home => "#/".into(),
Route::Settings => "#/settings".into(),
Route::Profile {
username,
is_favorites,
} => {
if is_favorites {
format!("#/profile/{}/favorites", username)
} else {
format!("#/profile/{}", username)
}
}
}
}
}
/// We can convert a route into a ViewBuilder in order to embed it in a component.
/// This is just a suggestion for this specific example. The general idea is
/// to use the route to inform your app that it needs to change the page. This
/// is just one of many ways to accomplish that.
impl From<&Route> for ViewBuilder {
fn from(route: &Route) -> Self {
match route {
Route::Home => html! {
<main>
<h1>"Welcome to the homepage"</h1>
</main>
},
Route::Settings => html! {
<main>
<h1>"Update your settings"</h1>
</main>
},
Route::Profile {
username,
is_favorites,
} => html! {
<main>
<h1>{username}"'s Profile"</h1>
{if *is_favorites {
Some(html!{
<h2>"Favorites"</h2>
})
} else {
None
}}
</main>
},
}
}
The view will wait for the window's hashchange
event.
When it receives a hashchange
event it will extract the new URL and send a
message to the async logic
loop.
The view will contain a pre
element to hold any potential error messages.
We will use some convenience functions on Route
to help display data in the view.
let mut route = starting_route;
while let Some(ev) = output_window_hashchange.get().await {
let hash = {
let ev = ev.browser_event().expect("not a browser event");
let hev = ev.dyn_ref::<HashChangeEvent>().unwrap().clone();
hev.new_url()
};
// When we get a hash change, attempt to convert it into one of our routes
let msg = match Route::try_from(hash.as_str()) {
// If we can't, let's send an error message to the view
Err(msg) => msg,
// If we _can_, create a new view from the route and send a patch message to
// the view
Ok(new_route) => {
trace!("got new route: {:?}", new_route);
if new_route != route {
route = new_route;
input_route.set(route.clone()).await.expect("could not set route");
}
// ...and clear any existing error message
String::new()
}
};
input_error_msg
.set(msg)
.await
.expect("could not set error msg");
}
})
}
#[wasm_bindgen]
pub fn main(parent_id: Option<String>) -> Result<(), JsValue> {
panic::set_hook(Box::new(console_error_panic_hook::hook));
console_log::init_with_level(Level::Trace).unwrap();
// TODO: instantiate the route from the window location
let route = Route::Home;
logic
receives the hashchange
, attempting to convert it into a Route
and either patches the DOM with a new page or sends an error message to our error element.
let builder = rsx! {
slot(
//window:hashchange = tx_logic.contra_filter_map(|ev: JsDomEvent| {
//}),
window:hashchange = output_window_hashchange.sink(),
patch:children = input_route
.stream()
.unwrap()
.map(|r| ListPatch::replace(2, ViewBuilder::from(&r)))
) {
nav() {
ul() {
li(class = starting_route.nav_home_class()) {
a(href = String::from(Route::Home)) { "Home" }
}
li(class = starting_route.nav_settings_class()) {
a(href = String::from(Route::Settings)) { "Settings" }
}
li(class = starting_route.nav_settings_class()) {
a(href = String::from(Route::Profile {
username: username.clone(),
is_favorites: true
})) {
{format!("{}'s Profile", username)}
}
}
}
}
pre() { {("", input_error_msg.stream().unwrap())} }
{&starting_route}
}
};
That's the bulk of the work.
Code
Here's the whole mogwai single page routing example on github.
Notice that the main
of this example takes an optional string. This allows us to pass the id
of an element that we'd like to append our routing component to. This allows us to load the example on
the page right here. Click on the links below to see the route and our page change. Click on the links in
the table of contents to see our errors propagate into the view.
Play with it
Creating SVGs with mogwai
Scalable Vector Graphics are images that are specified with XML. An SVG's markup looks a lot like HTML markup and allows developers and artists to quickly create images that can be resized without degrading the quality of the image.
Contents
Explanation
In mogwai
we create SVG images using the same RSX we use to create any other
View
. There's just one extra attribute we need to specify
that lets the browser know that we're drawing an SVG image instead of HTML -
the xmlns
attribute.
Notes
Unfortunately we must supply this namespace for each SVG node. It ends up not being too much of a burden.
Code
#![allow(unused_braces)]
use log::Level;
use mogwai_dom::prelude::*;
use std::panic;
use wasm_bindgen::prelude::*;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
/// Create an SVG circle using the xmlns attribute and the SVG namespace.
fn my_circle() -> ViewBuilder {
let ns = "http://www.w3.org/2000/svg";
html! {
<svg xmlns=ns width="100" height="100">
<circle xmlns=ns
cx="50"
cy="50"
r="40"
stroke="green"
stroke-width="4"
fill="yellow" />
</svg>
}
}
#[wasm_bindgen]
pub fn main(parent_id: Option<String>) {
panic::set_hook(Box::new(console_error_panic_hook::hook));
console_log::init_with_level(Level::Trace).unwrap();
let dom = JsDom::try_from(my_circle()).unwrap();
if let Some(id) = parent_id {
let parent = mogwai_dom::utils::document()
.visit_as::<web_sys::Document, JsDom>(|doc| {
JsDom::from_jscast(&doc.get_element_by_id(&id).unwrap())
})
.unwrap();
dom.run_in_container(parent)
} else {
dom.run()
}.unwrap();
}
Notice that the main
of this example takes an optional string. This allows us
to pass the id of an element that we'd like to append our list/parent component
to. This allows us to load the example on the page right here.