DEV Community

Cover image for Reactive Canvas with Rust/WebAssembly and web-sys
Ben Lovy
Ben Lovy

Posted on

Reactive Canvas with Rust/WebAssembly and web-sys

Or How I Learned To Stop Worrying And Love Macros

It's been a little while since I built a resizable dot with a slider in some esoteric stack. Time for Chapter 3! I guess it's a series now.

The last two demos used languages that transpile the whole app to regular ol' JavaScript to be interpreted. This time around, we're going to be compiling our app to WebAssembly first, and then having JavaScript load that.

As per usual with these dot demos, this is overkill for this app. This one perhaps especially so. Roll up your sleeves, we're gonna scrob us some DOM.

The Pipeline

This section is largely copped straight outta the RustWasm Book. If you plan to do further work with Rust and WebAssembly, head straight there next (or now). You'll need a Rust toolchain and Node/NPM to follow along.

First, create a new library-type crate:

$ cargo new wasm-dot --lib
$ cd wasm-dot
Enter fullscreen mode Exit fullscreen mode

We need to add wasm-bindgen. This crate auto-generates all the JS <-> Rust FFI glue for us, and is much of the reason Rust is such a phenomenal choice for writing WebAssembly. Open up Cargo.toml in the crate root and make it look like this:

[package]
name = "wasm-dot"
description = "Demo canvas wasm app"
license = "MIT"
version = "0.1.0"
authors = ["You <you@yourcoolsite.you>"]
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
Enter fullscreen mode Exit fullscreen mode

The cdylib crate type will produce a dynamic system library for loading into another language. Now open up src/lib.rs and make it look like this:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    // import the alert() js function
    pub fn alert(s: &str);
}

// export a Rust function that uses the imported JS function
#[wasm_bindgen]
pub fn say_hi() {
    alert("Hi from Rust/Wasm!");
}
Enter fullscreen mode Exit fullscreen mode

We're importing the JavaScript alert() function and exporting our own say_hi() Rust function that calls it. That's all we need to do, wasm_bindgen is taking care of the details. This will just ensure both directions work as intended.

The rustwasm team also provides a tool called wasm-pack to automate WebAssembly packaging and integration with npm. You'll need to install it once with cargo install wasm-pack and then you can use it to build your package:

$ wasm-pack build
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
    Finished release [optimized] target(s) in 0.00s
[INFO]: :-) Done in 0.05s
[INFO]: :-) Your wasm pkg is ready to publish at ./pkg.
Enter fullscreen mode Exit fullscreen mode

Inside pkg/ you'll find everything you need to deploy, ready to be imported into any npm project. All we need now is a project in which to use it! Because the rustwasm group thought of everything, there's a template ready to go - use it to create a new project:

$ npm init wasm-app www
Enter fullscreen mode Exit fullscreen mode

This www folder now contains a webpage with all the machinery set up to load your wasm library and call it from index.js:

import * as wasm from "hello-wasm-pack";

wasm.greet();
Enter fullscreen mode Exit fullscreen mode

There's a stub included so that it will run as is, but we don't want to import from hello-wasm-pack, we want to use the app we're developing. To point it in the right direction, open up www/package.json and add a dependencies key, pointing directly at the pkg output dir from wasm-pack:

  // ..
  "dependencies": {
    "wasm-dot": "file:../pkg"
  }
Enter fullscreen mode Exit fullscreen mode

Now we can point www/index.js there:

import * as wasm from "wasm-dot";

wasm.say_hi();
Enter fullscreen mode Exit fullscreen mode

Let's see if it does the thing:

$ npm install // because we added a dependency - us!
$ npm run start
Enter fullscreen mode Exit fullscreen mode

You should see the requested alert atlocalhost:8080:

hello wasm alert box screenshot

Huzzah! Now we can iterate. I recommend opening a second terminal at this point. In one, run npm run start and keep it open, and in the other invoke wasm-pack build whenever you make a change to the Rust.

The Layout

To deal with the JavaScript universe, the wasm-bindgen project provides two important crates: web-sys provides bindings for all the Web APIS (!!) and js-sys provides all the ECMAScript stuff like Array and Date (!!). Yeah, they already did the hard work. It's pretty cool, you don't need to manually define a Document.createElement extern or anything. Instead, just pull in what we need from web-sys in Cargo.toml:

[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = [
    "Attr",
    "CanvasRenderingContext2d",
    "Document",
    "Element",
    "Event",
    "EventTarget",
    "HtmlCanvasElement",
    "HtmlElement",
    "HtmlInputElement",
    "Node",
    "Text",
    "Window"
]
Enter fullscreen mode Exit fullscreen mode

It's a huge crate, so each interface is feature-gated. You only use what you need. If you're trying to call a function and it's telling you it doesn't exist, double check the API docs. It always tells you which features a given method needs:

feature gate screenshot

To make sure it's all groovy, we're going to build a DOM node ourselves, JS-style but, like, also Rust-style. Remove the alert() test in src/lib.rs and add:

#[wasm_bindgen]
pub fn run() {
    // get window/document/body
    let window = web_sys::window().expect("Could not get window");
    let document = window.document().expect("Could not get document");
    let body = document.body().expect("Could not get body");

    mount_app(&document, &body);
}

fn mount_app(document: &Document, body: &HtmlElement) {
    mount_title(&document, &body);
}

// Create a title
fn mount_title(document: &Document, body: &HtmlElement) {
    // create title element
    let title = document
        .create_element("h1")
        .expect("Could not create element");
    let title_text = document.create_text_node("DOT"); // always succeeds
    title
        .append_child(&title_text)
        .expect("Could not append child to title");

    // append to body
    body.append_child(&title)
        .expect("Could not append title to body");
}
Enter fullscreen mode Exit fullscreen mode

Using web_sys calls, this code manually builds a DOM node for the title and mounts it to the body of the page.

Now instead of say_hi() we'll need to call run() in www/index.js:

import * as wasm from "wasm-dot";

wasm.run();
Enter fullscreen mode Exit fullscreen mode

See if it works by running wasm-pack build and reloading localhost:8080:

dom node screenshot

Whoa. Did you see how blazing-fast and WASM-infused that title was?!

No, you didn't, but still. Neat. Before moving on, let's address the error handling situation. All of these web-sys calls will return a Result<T, JsValue>. We're not going to deal with other types of errors in this tiny demo, so just alias that:

type Result<T> = std::result::Result<T, JsValue>;
Enter fullscreen mode Exit fullscreen mode

Now we can have our functions return a Result<()> and get to use the ? operator instead of sprinkling expect() everywhere. Refactor run() to leverage this:

fn get_document() -> Result<Document> {
    let window = web_sys::window().unwrap();
    Ok(window.document().unwrap())
}

#[wasm_bindgen]
pub fn run() -> Result<()> {
    let document = get_document()?;
    let body = document.body().unwrap();

    mount_app(&document, &body)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Pulling get_document() out will help us define the event listener later. First, though, we need to define the DOM tree we want. Here's what we're aiming for in HTML:

  <div id="rxcanvas">
    <span id="size-output"></span>
    <input id="size" type="range" min="1" max="100" step="5">
    <label for="size">- Size</label>
    <p>
      <canvas id="dot-canvas"></canvas>
    </p>
  </div>
Enter fullscreen mode Exit fullscreen mode

If you've ever manipulated the DOM via JavaScript, you're pretty much good to go. In Rust, though, this is so verbose. Look how big the function to create a simple <h1>DOT</h1> element was! I promised up above there would be macros - here we go.

For the uninitiated, a macro is a bit of code that expands into other Rust code before everything is evaluated. In Rust, they look like function calls but with an exclamation point at the end. They aren't function calls at all though - when the compiler comes through your module, it expands all of these anywhere they find them into the full Rust code you (or a library) defined. It's a mechanism for automatic code generation!

This syntax is the only place in Rust you'll see that thing { () => {} } bracket pattern. It's its own special syntax. The parameters are prefixed with a $ and placed in the parens, and are copied in to the Rust code in the curly braces during expansion, right in place in your code.

Rust actually has another type of macro called a procedural macro that's even more powerful and arcane but for now macro_rules! will do us just fine.

Here's a macro to append an arbitrary number of attributes to a DOM element, passed as 2-tuples:

macro_rules! append_attrs {
    ($document:ident, $el:ident, $( $attr:expr ),* ) => {
        $(
            let attr = $document.create_attribute($attr.0)?;
            attr.set_value($attr.1);
            $el.set_attribute_node(&attr)?;
        )*
    }
}
Enter fullscreen mode Exit fullscreen mode

Each parameter to expand is tagged with a token type - an ident will allow us to pass a Rust name though and an expr takes any Rust expression (in this case, a 2-tuple). When called, each one will just paste this block of Rust into our function in place, using what we pass in.

This macro is variadic, meaning it can accept a variable number of arguments. The $( $name:expr ),* syntax means that it will carry out this block for zero or more arguments given, pasting a copy of the code in the curly braces for each arg passed here. Each time through, the arg we're processing gets the name $attr.

You can call it like this, with as many trailing tuple arguments as needed for each attribute:

append_attrs!(document, label, ("for", "size"));
Enter fullscreen mode Exit fullscreen mode

We can do better, though - macros can call other macros! We can boil everything down to the bare minimum by defining a few more helpers:

macro_rules! append_text_child {
    ($document:ident, $el:ident, $text:expr ) => {
        let text = $document.create_text_node($text);
        $el.append_child(&text)?;
    };
}

macro_rules! create_element_attrs {
    ($document:ident, $type:expr, $( $attr:expr ),* ) => {{
        let el = $document.create_element($type)?;
        append_attrs!($document, el, $( $attr ),*);
        el}
    }
}

macro_rules! append_element_attrs {
    ($document:ident, $parent:ident, $type:expr, $( $attr:expr ),* ) => {
        let el = create_element_attrs!($document, $type, $( $attr ),* );
        $parent.append_child(&el)?;
    }
}

macro_rules! append_text_element_attrs {
    ($document:ident, $parent:ident, $type:expr, $text:expr, $( $attr:expr ),*) => {
        let el = create_element_attrs!($document, $type, $( $attr ),* );
        append_text_child!($document, el, $text);
        $parent.append_child(&el)?;
    }
}
Enter fullscreen mode Exit fullscreen mode

There are two "top-level" macros, append_element_attrs and append_text_element_attrs. The former will append a childless element with the given attributes to the parent provided and the latter will include a text node child. Note that to pass the variadic trailing arguments down you just use the same syntax inside the curly brace expansion but omit the expr type:

let el = create_element_attrs!($document, $type, $( $attr ),* );
Enter fullscreen mode Exit fullscreen mode

Now we can replace the entirety of that mount_title() function with a single macro invocation:

fn mount_app(document: &Document, body: &HtmlElement) -> Result<()> {
    append_text_element_attrs!(document, body, "h1", "DOT",);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

I've also added our new return type, and as a result we now return a simple Ok(()) at the end to signify success. This macro expansion contains ? operators, which will now work as expected!

Note the trailing comma after "DOT" is mandatory - that's the "zero or more" attributes this macro accepts. That's so much boilerplate we've avoided though. The initial function is what the compiler sees when building the binary, we just saved ourselves the hassle of typing it all. Thanks, macros! Thacros.

Here's the rest of the f#@%!^g owl:

fn mount_canvas(document: &Document, parent: &Element) -> Result<()> {
    let p = create_element_attrs!(document, "p",);
    append_element_attrs!(document, p, "canvas", ("id", "dot-canvas"));
    parent.append_child(&p)?;
    Ok(())
}

fn mount_controls(document: &Document, parent: &HtmlElement) -> Result<()> {
    // containing div
    let div = create_element_attrs!(document, "div", ("id", "rxcanvas"));
    // span
    append_text_element_attrs!(
        document,
        div,
        "span",
        &format!("{}", STARTING_SIZE),
        ("id", "size-output")
    );
    // input
    append_element_attrs!(
        document,
        div,
        "input",
        ("id", "size"),
        ("type", "range"),
        ("min", "5"),
        ("max", "100"),
        ("step", "5")
    );
    // label
    append_text_element_attrs!(document, div, "label", "- Size", ("for", "size"));
    // canvas
    mount_canvas(&document, &div)?;
    parent.append_child(&div)?;
    Ok(())
}

fn mount_app(document: &Document, body: &HtmlElement) -> Result<()> {
    append_text_element_attrs!(document, body, "h1", "DOT",);
    mount_controls(&document, &body)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The astute will notice a reference to STARTING_SIZE - add that constant to the top of your file, this is where the slider will start when the page renders:

const STARTING_SIZE: u32 = 5;
Enter fullscreen mode Exit fullscreen mode

All the web_sys calls look very familiar if you're coming from JavaScript. If you want a Web API function, just try looking for it in the web-sys API docs. Each listing will conveniently link to the corresponding MDN page, too! Leveraging crates or writing your own abstractions to make this smoother is both quite possible and left as an exercise for the reader.

Rebuild with wasm-pack build, and if you have webpack-dev-server running (via npm run start) you can reload localhost:8080:

DOM tree screenshot

Good stuff.

The Action

This doesn't do anything, though. There's nary a dot in sight, let alone a resizable one. The next order of business is to draw the dot to the canvas:

// draw dot
fn update_canvas(document: &Document, size: u32) -> Result<()> {
    // grab canvas
    let canvas = document
        .get_element_by_id("dot-canvas")
        .unwrap()
        .dyn_into::<web_sys::HtmlCanvasElement>()?;
    // resize canvas to size * 2
    let canvas_dim = size * 2;
    canvas.set_width(canvas_dim);
    canvas.set_height(canvas_dim);
    let context = canvas
        .get_context("2d")?
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()?;

    // draw

    context.clear_rect(0.0, 0.0, canvas.width().into(), canvas.height().into());
    // create shape of radius 'size' around center point (size, size)
    context.begin_path();
    context.arc(
        size.into(),
        size.into(),
        size.into(),
        0.0,
        2.0 * std::f64::consts::PI,
    )?;
    context.fill();
    context.stroke();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This is also not too foreign if you've done this in JavaScript. One unfamiliar element is those dyn_into calls. To get this working, you need another import at the top of the file:

use wasm_bindgen::JsCast;
Enter fullscreen mode Exit fullscreen mode

When you grab an element with Document::get_element_by_id(&str) it returns an Element type. A plain Element doesn't have a width or a height, though - this is specifically a canvas element. The HtmlCanvasElement object does have these fields, so we can attempt to cast with dyn_into(). If we did indeed grab the correct element this cast will succeed. Now we can use things like set_height() and get_context(). Note that all methods use snake_case instead of camelCase, and you can't directly modify a field with canvas.height = 10;, you must use a method: canvas.set_height(10);. Otherwise this is a translation of equivalent JavaScript to resize the canvas to the bounding box of the circle with the given radius and then draw that circle.

Cool. We'll also need to update the <span> we have dedicated to showing the current size:

// update the size-output span
fn update_span(document: &Document, new_size: u32) -> Result<()> {
    let span = document.get_element_by_id("size-output").unwrap();
    span.set_text_content(Some(&format!("{}", new_size)));
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This isn't too surprising, set_text_content is a setter for Node.textContent. Let's bundle up these two updates:

// given a new size, sets all relevant DOM elements
fn update_all() -> Result<()> {
    // get new size
    let document = get_document()?;
    let new_size = document
        .get_element_by_id("size")
        .unwrap()
        .dyn_into::<web_sys::HtmlInputElement>()?
        .value()
        .parse::<u32>()
        .expect("Could not parse slider value");
    update_canvas(&document, new_size)?;
    update_span(&document, new_size)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This is going to be our onChange handler for the slider input, called inside this special FFI-interop-y Closure:

fn attach_listener(document: &Document) -> Result<()> {
    // listen for size change events

    update_all()?; // call once for initial render before any changes

    let callback = Closure::wrap(Box::new(move |_evt: web_sys::Event| {
        update_all().expect("Could not update");
    }) as Box<dyn Fn(_)>);

    document
        .get_element_by_id("size")
        .unwrap()
        .dyn_into::<web_sys::HtmlInputElement>()?
        .set_onchange(Some(callback.as_ref().unchecked_ref()));

    callback.forget();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This uses a web_sys::Closure. This allows you to pass Rust-defined closures through to JS to be used as event listener callbacks. This has some definite weirdness, I'm going to direct you to the book for a better run though of why this looks like it does. That as_ref().unchecked_ref() chain lets you extract the &Function that set_onchange expects from the web_sys::Closure.

Now we just need to call this after we mount the app:

#[wasm_bindgen]
pub fn run() -> Result<()> {
    let document = get_document()?;
    let body = document.body().unwrap();

    mount_app(&document, &body)?;
    attach_listener(&document)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

And that's it! Recompile, reload, and rejoice as you resize. Aww yisss.

finished screenshot

The full code can be found here.

Photo by Leo Rivas on Unsplash

Top comments (11)

Collapse
 
ben profile image
Ben Halpern

This is cool

Collapse
 
deciduously profile image
Ben Lovy

What's cooler is what you can build next.

Collapse
 
ben profile image
Ben Halpern

Definitely!

Thread Thread
 
deciduously profile image
Ben Lovy

You may have given me an excuse to finally execute some Rust code! I’ve done lots of reading but haven’t actually tried using Rust yet.

Ever give it a go?

Collapse
 
rhymes profile image
rhymes

Nice post!!

Collapse
 
jeikabu profile image
jeikabu

My lack of experience with JS/node/NPM was a bit of a roadblock with WASM.
Nice use of macros.

Collapse
 
deciduously profile image
Ben Lovy

Yep, it's a pretty big can of worms if you've never worked with it before, but it's nice that it does integrate so nicely with an already established workflow.

Collapse
 
adam_cyclones profile image
Adam Crockett 🌀 • Edited

I'll be checking this out after I'm done with my WASM project, man they are LONG.

Collapse
 
wiltel492019 profile image
Wiltel492019

Decent amount of materials.Rust!!!

Collapse
 
nmrshll profile image
Nicolas Marshall

Great stuff ! Thanks !

Collapse
 
lexchou profile image
Lex Chou

This is cool, I tried this out but sadly the debugger cannot step into the rust's source code, is there anyone can have a source-level debugging in rust wasm?