Implement a Simple WASM Calculator in Rust Using Leptos, and with DumbCalculator
Development Environment
Here I will assume program development tools like
- Of course, the Rust programming language itself.
- The popular VSCode program development editor / IDE, with the extension rust-analyzer
- Preferably the popular source control tool GIT.
Rust Crates Used
- Leptos -- a Rust framework to develop WASM app in Rust.
- DumbCalculator of rusty_dumb_tools -- a simple Rust component that acts like a simple physical calculator.
Preparation for WASM Development
WASM development in Rust can be enabled with the Trunk tool.
Indeed, Trunk is used here, and we install Trunk like
cargo install trunk
After installing Trunk, we will also need to add the Rust target wasm32-unknown-unknown
, like
rustup target add wasm32-unknown-unknown
Kick-starting wasm_calculator
To get kick-started, create a new Rust project wasm_calculator
like
cargo new wasm_calculator
Open the just created folder wasm_calculator
with VSCode like
cd wasm_calculator
code .
In VSCode, open and edit Cargo.toml
adding the necessary dependencies, like
...
[dependencies]
leptos = { version = "0.6.5", features = ["csr"] }
rusty_dumb_tools = "0.1.8"
Add the Trunk config file Trunk.toml
with content like
[build]
target = "trunk.html"
Add trunk.html
, which is sort of the template for our final output index.html
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body></body>
</html>
Note that without the above mentioned Trunk.toml
config file, Trunk will in fact look for index.html
as the template instead.
(However, we would like to reserve index.html
for other purposes, and hence would use trunk.html
instead.)
Our WASM code will be "mounted" to <body>
of this trunk.html
, let's see it working
trunk serve --open
This will run the Trunk server serving the trunk.html
merged with whatever WASM code in main.rs
The server will keep running, and hot update the page whenever trunk.html
or main.rs
get changed
Say, change the <body>
of trunk.html
to
<body><h3>— WASM Calculator —</h3></body>
See that the browser page is updated accordingly.
The Basis of wasm_calculator
The initially generated main.rs
is actually not WASM code to be "mounted" to <body>
.
To "mount" some simple WASM code (written in Rust), can change main.rs
like
use leptos::*;
fn main() {
mount_to_body(move || {
view! {
<div style="color:red">Hello, World!</div>
}
});
}
Again, our modification will be hot-deployed, and we should see that
<div style="color:red">Hello, World!</div>
is "mounted" to <body>
, after <h3>— WASM Calculator —</h3>
Here is some little insights from the above code:
-
mount_to_body
is the function provided by Leptos to "mount" WASM code (written in Rust) to<body>
-
mount_to_body
can accept a closure which accepts no argument and returns the result of calling theview!
macro, which is of cause also provided by Leptos. - Inside
view!
, we write "HTML" -- like<div style="color:red">Hello, World!</div>
-- which even looks like plain HTML, is in fact "legal" Rust code to be pre-processed by the macroview!
.
In fact, normally, we will be coding our WASM code, in an App()
function, and "mount" it like
use leptos::*;
fn main() {
mount_to_body(move || view! { <App/> });
}
fn App() -> impl IntoView {
view! {
<div style="color:red">Hello, World!</div>
}
}
As mentioned above, things inside view!
will be pre-processed to be transcribed to Rust code in compile time, hence, we should be able to include regular Rust code inside view!
like
fn App() -> impl IntoView {
let color = "red";
let who = "World";
view! {
<div style={format!("color:{}", color)}>Hello, {who}!</div>
}
}
As shown, we can enclose regular Rust code inside {}
as above {format!("color:{}", color)}
and Hello, {who}!
We can even "nest" view!
like
fn App() -> impl IntoView {
let color = "red";
let who = "World";
view! {
{
view! {
<div style={format!("color:{}", color)}>Hello, {who}!</div>
}
}
}
}
Why "nest" view!
? Hopefully, it will become apparent in later sections.
Let's extract the styling of the <div>
to live else-where. Indeed, we can put our CSS in trunk.html
and use it in App()
like
trunk.html
<!DOCTYPE html>
<style>
.test-class {
color: green;
}
</style>
...
main.rs
...
fn App() -> impl IntoView {
let who = "World";
view! {
{
view! {
<div class="test-class">Hello, {who}!</div>
}
}
}
}
Now, let's add two buttons to it to make it interactive.
Note that Leptos will generate HTML once only -- like the above App()
will only be called once to generate initial HTML code -- any updates are triggered with "signals", and rendered with closures (more about this later).
Therefore, to make it interactive, not only we will need to add some interactive HTML elements, like <button>
, we will also need to make use of "signals" as well.
First, let's add two <button>
s, and be able to log to the browser's console when any of the button is clicked:
use leptos::*;
use leptos::logging::log;
use web_sys::MouseEvent;
...
fn App() -> impl IntoView {
let who = "World";
let on_clicked = |ev: MouseEvent| {
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
};
view! {
{
view! {
<div class="test-class">Hello, {who}!</div>
}
}
<button on:click=on_clicked value="1">I am 1</button>
<button on:click=on_clicked value="2">I am 2</button>
}
}
Notes:
- Two more dependencies
use leptos::logging::log;
use web_sys::MouseEvent;
- The closure defined by
on_clicked
will be called when any one of the button is clicked
<button on:click=on_clicked value="1">I am 1</button>
<button on:click=on_clicked value="2">I am 2</button>
Notice that the two <button>
s are placed in the same level as the "nested" view!
- Each button is associated with a value (e.g.
value="1"
), and when a button is clicked, the associated value is retrieved and printed out to the browser's console
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
Now, let's make use of "signal" to trigger update of the <div>
content
fn App() -> impl IntoView {
let (clicked_value, set_clicked_value) = create_signal(String::from(""));
let on_clicked = move |ev: MouseEvent| {
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
set_clicked_value.set(value);
};
view! {
{
move || view! {
<div class="test-class"> {
let value = clicked_value.get();
format!("Hello, [{}]!", value)
} </div>
}
}
<button on:click=on_clicked value="1">I am 1</button>
<button on:click=on_clicked value="2">I am 2</button>
}
}
Notes:
- The "signal" is created like
let (clicked_value, set_clicked_value) = create_signal(String::from(""));
- such a "signal" is composed of a "getter"
clicked_value
and a "setter"set_clicked_value
- the type of the "signal" is
String
, and the "signal" is initialized to the emptyString
- The "setter"
set_clicked_value
is called to set new value in the closure defined byon_click
, which is called when any of the button is clicked
- The "setter"
let on_clicked = move |ev: MouseEvent| {
...
set_clicked_value.set(value);
}
Notice that the closure captures variables, like set_clicked_value
, moved; and this is the requirement of using "signal"
- The "user" of the "signal" is the
<div>
created by the nestedview!
move || view! {
<div class="test-class"> {
let value = clicked_value.get();
format!("Hello, [{}]!", value)
} </div>
}
Notice:
- the "nested"
view
is now a "moved" closure, since it is using the "signal" to get the new value set - the value set is returned by calling
clicked_value.get()
- by using the "signal", Leptos knows that the closure need be called again to update the content of the nested
view!
when the "signal" is updated- Here is what will happen when a button is clicked
- the closure
on_click
gets called - the "signal" gets updated when
set_clicked_value
is called - the closure of the "nested"
view!
gets called when the "signal" is updated, which will update the<div>
generated by the "nested"view!
Let's change the buttons to ones that simulate the key presses for 1 + 2 =
<button on:click=on_clicked value="1">1</button>
<button on:click=on_clicked value="+">+</button>
<button on:click=on_clicked value="2">2</button>
<button on:click=on_clicked value="=">=</button>
And also add DumbCalculator
into the picture
...
use std::cell::RefCell;
use rusty_dumb_tools::calculator::*;
...
fn App() -> impl IntoView {
let calculator_ref = RefCell::new(DumbCalculator::new());
let (clicked_value, set_clicked_value) = create_signal(String::from(""));
let on_clicked = move |ev: MouseEvent| {
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
set_clicked_value.set(value);
};
view! {
{
move || view! {
<div class="test-class"> {
let mut calculator = calculator_ref.borrow_mut();
let value = clicked_value.get();
if !value.is_empty() {
calculator.push(value.as_str()).unwrap();
}
let result_value = calculator.get_display_sized(10);
format!("[{}]", result_value)
} </div>
}
}
<button on:click=on_clicked value="1">1</button>
<button on:click=on_clicked value="+">+</button>
<button on:click=on_clicked value="2">2</button>
<button on:click=on_clicked value="=">=</button>
}
}
Notes:
- An instance of
DumbCalculator
is created and stored in aRefCell
, and assigned tocalculator_ref
; note that evencalculator_ref
is immutable, the instance ofDumbCalculator
can be retrieved mutable
let calculator_ref = RefCell::new(DumbCalculator::new());
Note that since App()
will only be called one, only a single instance of DumbCalculator
will ever be created, unless the browser page is refreshed.
- The "nested"
view!
code for the<div>
is changed to something like
<div class="test-class"> {
let mut calculator = calculator_ref.borrow_mut();
let value = clicked_value.get();
if !value.is_empty() {
calculator.push(value.as_str()).unwrap();
}
let result_value = calculator.get_display_sized(10);
format!("[{}]", result_value)
} </div>
-
The instance of
DumbCalculator
is "borrowed" mutable
let mut calculator = calculator_ref.borrow_mut();
-
The button value, which is supposed to simulate calculator key press, is pushed to the
DumbCalculator
like
calculator.push(value.as_str()).unwrap();
-
What the calculator display should look like is rendered in the content of the
<div>
let result_value = calculator.get_display_sized(10); format!("[{}]!", result_value)
Now, let's add two buttons for "AC" (all cancel) as well as "⬅" (undo)
fn App() -> impl IntoView {
let calculator_ref = RefCell::new(DumbCalculator::new());
let (clicked_value, set_clicked_value) = create_signal(String::from(""));
let on_clicked = move |ev: MouseEvent| {
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
set_clicked_value.set(value);
};
view! {
{
move || view! {
<div class="test-class"> {
let mut calculator = calculator_ref.borrow_mut();
let value = clicked_value.get();
if value == "ac" {
calculator.reset();
} else if value == "<" {
calculator.undo();
} else if !value.is_empty() {
calculator.push(value.as_str()).unwrap();
}
let result_value = calculator.get_display_sized(10);
format!("[{}]", result_value)
} </div>
}
}
<button on:click=on_clicked value="1">1</button>
<button on:click=on_clicked value="+">+</button>
<button on:click=on_clicked value="2">2</button>
<button on:click=on_clicked value="=">=</button>
<div>
<button on:click=on_clicked value="ac">AC</button>
<button on:click=on_clicked value="<">{"⬅"}</button>
</div>
}
}
Notes:
- The two additional buttons are added below the
1 + 2 =
buttons like
<div>
<button on:click=on_clicked value="ac">AC</button>
<button on:click=on_clicked value="<">{"⬅"}</button>
</div>
- Clicking of the two buttons are handled like
let value = clicked_value.get();
if value == "ac" {
calculator.reset();
} else if value == "<" {
calculator.undo();
} else if !value.is_empty() {
calculator.push(value.as_str()).unwrap();
}
We are getting closer and closer. Let's add to the code the capability of showing the history of the calculator.
For this, we will be using another history
"signal".
fn App() -> impl IntoView {
let calculator_ref = RefCell::new(DumbCalculator::new());
let (clicked_value, set_clicked_value) = create_signal(String::from(""));
let (history, set_history) = create_signal(String::from(""));
let on_clicked = move |ev: MouseEvent| {
let value = event_target_value(&ev);
log!("* clicked value [{}]", value);
set_clicked_value.set(value);
};
view! {
{
move || view! {
<div class="test-class"> {
let mut calculator = calculator_ref.borrow_mut();
let value = clicked_value.get();
if value == "ac" {
calculator.reset();
} else if value == "<" {
calculator.undo();
} else if !value.is_empty() {
calculator.push(value.as_str()).unwrap();
}
let history = calculator.get_history_string(true);
match &history {
Some(hist) => set_history.set(hist.to_string()),
None => set_history.set("".to_string()),
}
let result_value = calculator.get_display_sized(10);
format!("[{}]", result_value)
} </div>
}
}
<button on:click=on_clicked value="1">1</button>
<button on:click=on_clicked value="+">+</button>
<button on:click=on_clicked value="2">2</button>
<button on:click=on_clicked value="=">=</button>
<div>
<button on:click=on_clicked value="ac">AC</button>
<button on:click=on_clicked value="<">{"⬅"}</button>
{move || view! {
<span> { history.get() } </span>
}}
</div>
}
}
Notes:
- The
history
"signal" is added like
let (history, set_history) = create_signal(String::from(""));
- The
history
if set after pushing value to the calculator
let history = calculator.get_history_string(true);
match &history {
Some(hist) => set_history.set(hist.to_string()),
None => set_history.set("".to_string()),
}
- The updated
history
is displayed next to the "⬅" button
{move || view! {
<span> { history.get() } </span>
}}
Finally
Finally, let's add all the whistles and bells and finish off our calculator ...
Simply ... please replace the corresponding file as listed here:
-
trunk.html
-- https://github.com/trevorwslee/wasm_calculator/blob/master/trunk.html -
main.rs
-- https://github.com/trevorwslee/wasm_calculator/blob/master/src/main.rs
Or ... simply ... clone the GitHub repo https://github.com/trevorwslee/wasm_calculator
Manually Deploy to GitHub Pages
Now that we have the final result WASM calculator, we may want to deploy it to GitHub Pages.
Assuming a GitHub account, say like mine -- trevorwslee
, I can easily post some static pages to my GitHub Pages -- https://trevorwslee.github.io/
What I needed is a GitHub repository with the "same" name trevorwslee.github.io
-- https://github.com/trevorwslee/trevorwslee.github.io
In order for GitHub Pages to host our WASM Calculator app
- With Trunk, build release version specifying the context to use in GitHub Pages, like mine
- The context is
WASMCalculator
- Then the URL to the WASM app is https://trevorwslee.github.io/WASMCalculator/
- The context is
- To build such release, run Trunk like
trunk build --release --public-url "WASMCalculator"
- After building the release with Trunk, the
dist
folder will contain the filesindex.html
wasm_calculator-XXX.wasm
-
wasm_calculator-XXX.js
to be checked in toWASMCalculator
folder of the GitHub Pages repository, like mine -- https://github.com/trevorwslee/trevorwslee.github.io/tree/main/WASMCalculator
- After a few moments, the WASM Calculator should be ready to try out -- https://trevorwslee.github.io/WASMCalculator/
Enjoy!
Peace be with you!
May God bless you!
Jesus loves you!
Amazing Grace!
Top comments (9)
The calculator isn't obeying the order of operations rules for bracketed terms order of operations thread index
very interesting ... I guess the error boils down to concepts that tell
8/2(1+3)
vs
8/2x, where x is (1+3)
apart ... for me, I do not have the expertise to say which is which ... but just "followed" what most calculators [that I come across] will do
treat
8/2(1+3)
as
8/2*(1+3)
thanks for the insight
I'm a Maths teacher/tutor. :-)
Yes, hence why I wrote this part specifically - I'm tired of hearing "But Google/ChatGPT/Wolfram Alpha says...". The only e-calculator I've seen which handles it correctly is MathGPT (all physical calculators except for Texas Instruments handle it correctly).
No problem. :-)
P.S. if you fix it then I'd be quite happy to promote it as the only online calculator I know of other than MathGPT which handles brackets correctly. :-)
FYI: I have updated it. Hopefully, it is behaving correctly now.
Yes, excellent! Good on you. I'll give you a plug tomorrow for my semi-regular MathsMonday post. :-)
Here you go - as promised gave you a plug :-) MathsMonday post
P.S. Are you on Mastodon? If you are let me know your username and I'll tag you in the post.
Live seeing examples with Leptos and WASM. Thanks for sharing!