50 Shades of Rust, or emerging Rust GUIs in a WASM world
50 Shades of Rust
Or emerging Rust GUIs in a WASM world
Written by Igor Loskutov. Originally published 2023-04-26 on the Monadical blog.
GUI in Rust progresses with unprecedented speed – 3 months in Rust GUI-land is like 3 years in the mortal world. To put things in perspective, GUI moves so fast for the web that it’s even ahead of browsers. There are many possible reasons for this, from the cross-platform nature of Rust to the WebAssembly support, which provides easier distribution of software.
In this post, I’ll review the current toolkit for GUI in Rust, and share some tips for building WebAssembly bundles. Due to the speed at which GUI in Rust moves, some tools I’ve outlined below may deprecate in the near future. So, at the risk of this post becoming obsolete before you’ve finished reading it, let’s dive into the most current GUI architecture for Rust that I’ve discovered thus far.
The current toolkit
Before we get into the toolkit, please note that I mention Elm Architecture and Immediate Mode many times here, although their definitions are beyond the scope of this post. However, you can think of Elm Architecture as roughly similar to Redux (if you have experience with it), and you can consider Immediate Mode more imperative than Elm. It’s also worth mentioning that many libraries have now started using “signal” reactive semantics, where you mutate your state variables directly; this is then reflected in the UI automatically, just like we had in the times of Angular 1.
Now, onto the toolkit.
1. Dioxus
Dioxus has a react-like interface architecture, but on Rust. For Desktop builds, it uses Tauri, focuses on the Web, has experimental Terminal support, and has an SSR feature for generating server-side markup. Dioxus also implements React-like interior mutability for data handling with, for example, use_state
. Because of its global state management through useContext-like API, potential Redux-like tools can be integrated through it (“redux/recoil/mobx on top of context”). Below is an example widget built with Dioxus:
// main.rs
fn main() {
// launch the web app
dioxus_web::launch(App);
}
fn echo_to_angle(s: &str) -> u32 {
let mut angle = 0;
for c in s.chars() {
angle += c as u32 % 360;
}
angle
}
fn App(cx: Scope) -> Element {
let echo = use_state(&cx, || "Echo".to_string());
let angle = use_state(&cx, || echo_to_angle(&echo.get()));
cx.spawn({
let mut angle = angle.to_owned();
async move {
TimeoutFuture::new(50).await;
angle.with_mut(|a| {
*a += 1;
*a = *a % 360;
});
}
});
cx.render(rsx! {
div {
div {
"Angle: {angle}"
}
div {
transform: "rotateX({angle}deg) rotateY({angle}deg)",
width: "100px",
height: "100px",
transform_style: "preserve-3d",
position: "relative",
div {
position: "absolute",
width: "100%",
height: "100%",
background: "red",
transform: "translateZ(50px)",
}
// more cube sides CSS definitions omitted
div {
position: "absolute",
width: "100%",
height: "100%",
background: "orange",
transform: "rotateY(90deg) translateZ(50px)",
}
}
}
})
}
2. Tauri
Tauri is a framework to create desktop apps (with mobile platforms coming soon), like Electron, written in Rust. Tauri gives its own window interface to the app and lets you run TS code that talks with Rust through a custom promise-based protocol. It’s not Tauri’s purpose to be buildable for the web, although it leverages web technologies for desktop apps. You can use many frontend libraries, like React (provided in their init scripts), or just go vanilla.
<!-- index.html -->
<div class="row">
<div>
<input id="greet-input" placeholder="Enter a name..."/>
<button id="greet-button" type="button">Greet</button>
</div>
</div>
// main.ts
import { invoke } from "@tauri-apps/api/tauri";
let greetInputEl: HTMLInputElement | null;
let greetMsgEl: HTMLElement | null;
async function greet() {
if (greetMsgEl && greetInputEl) {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
greetMsgEl.textContent = await invoke("greet", {
name: greetInputEl.value,
});
}
}
window.addEventListener("DOMContentLoaded", () => {
greetInputEl = document.querySelector("#greet-input");
greetMsgEl = document.querySelector("#greet-msg");
document
.querySelector("#greet-button")
?.addEventListener("click", () => greet());
});
//main.rs
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
3. Xilem
Xilem is an experimental but very promising attempt at the data-first architecture that aligns well with Rust’s language architecture^[Notably, it means avoiding a shared mutable state and using Rust type inference at its fullest.]. It’s the successor of Druid. The library’s author Raph Levien stated that “Xilem aims to be the premier UI library for Rust, drawing considerable inspiration from SwiftUI, with a serious focus on performance.” Please check out his entire article on Rust UI architecture: https://raphlinus.github.io/rust/gui/2022/05/07/ui-architecture.html. It’s the best article on this topic to date, in my opinion.
Based on this article, the natural decision is to choose a centralized state like in Elm architecture. The library uses its own renderer and is about to start heavily utilizing GPU features for even faster rendering. The project is at a very early experimental stage at the moment. It currently targets WebGPU, which is problematic to run in the browser at this point, but it has a lot of promise.
4. Iced
Iced is “a cross-platform GUI library for Rust focused on simplicity and type-safety.”. It uses Elm architecture and a reactive programming model. By default, web builds use DOM to render, but Iced can be used with other backends, too, like wgpu or glow. The web part seems a bit overlooked right now (https://github.com/iced-rs/iced_web), and it hasn’t been updated in a while. Here, their built Bezier tool employs Canvas:
// main.rs
//! This example showcases an interactive `Canvas` for drawing Bézier curves.
use iced::widget::{button, column, text};
use iced::{Alignment, Element, Length, Sandbox, Settings};
pub fn main() -> iced::Result {
Example::run(Settings {
antialiasing: true,
..Settings::default()
})
}
#[derive(Default)]
struct Example {
bezier: bezier::State,
curves: Vec<bezier::Curve>,
}
#[derive(Debug, Clone, Copy)]
enum Message {
AddCurve(bezier::Curve),
Clear,
}
impl Sandbox for Example {
type Message = Message;
fn new() -> Self {
Example::default()
}
fn title(&self) -> String {
String::from("Bezier tool - Iced")
}
fn update(&mut self, message: Message) {
match message {
Message::AddCurve(curve) => {
self.curves.push(curve);
self.bezier.request_redraw();
}
Message::Clear => {
self.bezier = bezier::State::default();
self.curves.clear();
}
}
}
fn view(&self) -> Element<Message> {
column![
text("Bezier tool example").width(Length::Shrink).size(50),
self.bezier.view(&self.curves).map(Message::AddCurve),
button("Clear").padding(8).on_press(Message::Clear),
]
.padding(20)
.spacing(20)
.align_items(Alignment::Center)
.into()
}
}
mod bezier {
use iced::mouse;
use iced::widget::canvas::event::{self, Event};
use iced::widget::canvas::{
self, Canvas, Cursor, Frame, Geometry, Path, Stroke,
};
use iced::{Element, Length, Point, Rectangle, Theme};
#[derive(Default)]
pub struct State {
cache: canvas::Cache,
}
impl State {
pub fn view<'a>(&'a self, curves: &'a [Curve]) -> Element<'a, Curve> {
Canvas::new(Bezier {
state: self,
curves,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
pub fn request_redraw(&mut self) {
self.cache.clear()
}
}
struct Bezier<'a> {
state: &'a State,
curves: &'a [Curve],
}
impl<'a> canvas::Program<Curve> for Bezier<'a> {
type State = Option<Pending>;
fn update(
&self,
state: &mut Self::State,
event: Event,
bounds: Rectangle,
cursor: Cursor,
) -> (event::Status, Option<Curve>) {
let cursor_position =
if let Some(position) = cursor.position_in(&bounds) {
position
} else {
return (event::Status::Ignored, None);
};
match event {
Event::Mouse(mouse_event) => {
let message = match mouse_event {
mouse::Event::ButtonPressed(mouse::Button::Left) => {
match *state {
None => {
*state = Some(Pending::One {
from: cursor_position,
});
None
}
Some(Pending::One { from }) => {
*state = Some(Pending::Two {
from,
to: cursor_position,
});
None
}
Some(Pending::Two { from, to }) => {
*state = None;
Some(Curve {
from,
to,
control: cursor_position,
})
}
}
}
_ => None,
};
(event::Status::Captured, message)
}
_ => (event::Status::Ignored, None),
}
}
fn draw(
&self,
state: &Self::State,
_theme: &Theme,
bounds: Rectangle,
cursor: Cursor,
) -> Vec<Geometry> {
let content =
self.state.cache.draw(bounds.size(), |frame: &mut Frame| {
Curve::draw_all(self.curves, frame);
frame.stroke(
&Path::rectangle(Point::ORIGIN, frame.size()),
Stroke::default().with_width(2.0),
);
});
if let Some(pending) = state {
let pending_curve = pending.draw(bounds, cursor);
vec![content, pending_curve]
} else {
vec![content]
}
}
fn mouse_interaction(
&self,
_state: &Self::State,
bounds: Rectangle,
cursor: Cursor,
) -> mouse::Interaction {
if cursor.is_over(&bounds) {
mouse::Interaction::Crosshair
} else {
mouse::Interaction::default()
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Curve {
from: Point,
to: Point,
control: Point,
}
impl Curve {
fn draw_all(curves: &[Curve], frame: &mut Frame) {
let curves = Path::new(|p| {
for curve in curves {
p.move_to(curve.from);
p.quadratic_curve_to(curve.control, curve.to);
}
});
frame.stroke(&curves, Stroke::default().with_width(2.0));
}
}
#[derive(Debug, Clone, Copy)]
enum Pending {
One { from: Point },
Two { from: Point, to: Point },
}
impl Pending {
fn draw(&self, bounds: Rectangle, cursor: Cursor) -> Geometry {
let mut frame = Frame::new(bounds.size());
if let Some(cursor_position) = cursor.position_in(&bounds) {
match *self {
Pending::One { from } => {
let line = Path::line(from, cursor_position);
frame.stroke(&line, Stroke::default().with_width(2.0));
}
Pending::Two { from, to } => {
let curve = Curve {
from,
to,
control: cursor_position,
};
Curve::draw_all(&[curve], &mut frame);
}
};
}
frame.into_geometry()
}
}
}
5. Egui
Egui is “the easiest to use GUI library.” [https://github.com/emilk/egui]. It utilizes Immediate Mode GUI architecture [https://en.wikipedia.org/wiki/Immediate_mode_GUI]. Some of its goals are to be portable and easy to use. The native look is specifically a non-goal. For example, it fits simple GUIs, games, or other apps that need a quick GUI drop-in library. Check out their example build for WebAssembly:
// app.rs
// main.rs / lib.rs boilerplate omitted
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct TemplateApp {
// Example stuff:
label: String,
// this how you opt-out of serialization of a member
#[serde(skip)]
value: f32,
}
impl Default for TemplateApp {
fn default() -> Self {
Self {
// Example stuff:
label: "Hello World!".to_owned(),
value: 2.7,
}
}
}
impl TemplateApp {
/// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// This is also where you can customize the look and feel of egui using
// `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
// Load previous app state (if any).
// Note that you must enable the `persistence` feature for this to work.
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
}
Default::default()
}
}
impl eframe::App for TemplateApp {
/// Called by the frame work to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
/// Called each time the UI needs repainting, which may be many times per second.
/// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let Self { label, value } = self;
// Examples of how to create different panels and windows.
// Pick whichever suits you.
// Tip: a good default choice is to just keep the `CentralPanel`.
// For inspiration and more examples, go to https://emilk.github.io/egui
#[cfg(not(target_arch = "wasm32"))] // no File->Quit on web pages!
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
_frame.close();
}
});
});
});
egui::SidePanel::left("side_panel").show(ctx, |ui| {
ui.heading("Side Panel");
ui.horizontal(|ui| {
ui.label("Write something: ");
ui.text_edit_singleline(label);
});
ui.add(egui::Slider::new(value, 0.0..=10.0).text("value"));
if ui.button("Increment").clicked() {
*value += 1.0;
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("powered by ");
ui.hyperlink_to("egui", "https://github.com/emilk/egui");
ui.label(" and ");
ui.hyperlink_to(
"eframe",
"https://github.com/emilk/egui/tree/master/crates/eframe",
);
ui.label(".");
});
});
});
egui::CentralPanel::default().show(ctx, |ui| {
// The central panel the region left after adding TopPanel's and SidePanel's
ui.heading("Shades of Rust - egui");
ui.hyperlink("https://github.com/Firfi/shades-of-rust/tree/master/shades-egui");
egui::warn_if_debug_build(ui);
});
}
}
Note their bigger demo on https://www.egui.rs/#demo
6. Kas
Kas is an “efficient retained-state toolkit,” and a blend of several GUI data models. For example, it handles messages like Elm but is able to keep state in components. It doesn’t work in the browser environment yet; at least, I haven’t been able to build an example quickly enough. This is because it’s a bit ahead of browsers using WebGPU – not the other way around.
7. Slint
With Slint, the web is not the focus; it can be compiled into WebAssembly only for demo purposes. Slint targets native looks, embedded systems, microcontrollers and desktop. It uses its own script language to describe UI, which provides components’ state and look description. Slint does allow some scripting, although it’s possible to run the most heavyweight code in Rust. Overall, it’s an imperative tool.
Here is a built code of their tutorial:
// main.rs
#[cfg_attr(target_arch = "wasm32",
wasm_bindgen::prelude::wasm_bindgen(start))]
pub fn main() {
use slint::Model;
let main_window = MainWindow::new().unwrap();;
// Fetch the tiles from the model
let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
// Duplicate them to ensure that we have pairs
tiles.extend(tiles.clone());
// Randomly mix the tiles
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
tiles.shuffle(&mut rng);
// Assign the shuffled Vec to the model property
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.clone().into());
let main_window_weak = main_window.as_weak();
main_window.on_check_if_pair_solved(move || {
let mut flipped_tiles =
tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);
if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
(flipped_tiles.next(), flipped_tiles.next())
{
let is_pair_solved = t1 == t2;
if is_pair_solved {
t1.solved = true;
tiles_model.set_row_data(t1_idx, t1);
t2.solved = true;
tiles_model.set_row_data(t2_idx, t2);
} else {
let main_window = main_window_weak.unwrap();
main_window.set_disable_tiles(true);
let tiles_model = tiles_model.clone();
slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
main_window.set_disable_tiles(false);
t1.image_visible = false;
tiles_model.set_row_data(t1_idx, t1);
t2.image_visible = false;
tiles_model.set_row_data(t2_idx, t2);
});
}
}
});
main_window.run().unwrap();
}
slint::slint! {
struct TileData {
image: image,
image_visible: bool,
solved: bool,
}
component MemoryTile inherits Rectangle {
callback clicked;
in property <bool> open_curtain;
in property <bool> solved;
in property <image> icon;
height: 64px;
width: 64px;
background: solved ? #34CE57 : #3960D5;
animate background { duration: 800ms; }
Image {
source: icon;
width: parent.width;
height: parent.height;
}
// Left curtain
Rectangle {
background: #193076;
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
}
// Right curtain
Rectangle {
background: #193076;
x: open_curtain ? parent.width : (parent.width / 2);
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
animate x { duration: 250ms; easing: ease-in; }
}
TouchArea {
clicked => {
// Delegate to the user of this element
root.clicked();
}
}
}
export component MainWindow inherits Window {
width: 326px;
height: 326px;
callback check_if_pair_solved();
in property <bool> disable_tiles;
in property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
{ image: @image-url("icons/balance-scale.png") },
{ image: @image-url("icons/bicycle.png") },
{ image: @image-url("icons/bus.png") },
{ image: @image-url("icons/cloud.png") },
{ image: @image-url("icons/cogs.png") },
{ image: @image-url("icons/motorcycle.png") },
{ image: @image-url("icons/video.png") },
];
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
if (!root.disable_tiles) {
tile.image_visible = !tile.image_visible;
root.check_if_pair_solved();
}
}
}
}
}
8. Sycamore
Sycamore is “a reactive library for creating web apps in Rust and WebAssembly.”. Indeed, nice reactivity is one of its main features. It looks like mobx at first glance and uses bind/signal semantics (just like Angular or the much-earlier React). This provides a nice developer experience:
fn App<G: Html>(cx: Scope) -> View<G> {
let a = create_signal(cx, 0_f64);
view! { cx,
p { "Input value: " (a.get()) }
input(type="number", bind:valueAsNumber=a)
}
}
The code above generates DOM.
By default, Sycamore is made for the web, but it can be easily embedded into other tools like Tauri to create desktop apps: https://github.com/JonasKruckenberg/tauri-sycamore-template. Currently, the Sycamore template isn’t in Tauri’s create-tauri-app tool https://github.com/tauri-apps/create-tauri-app, but Yew is.
// app.rs
#[derive(Serialize, Deserialize)]
struct GreetArgs<'a> {
name: &'a str,
}
#[component]
pub fn App<G: Html>(cx: Scope) -> View<G> {
let name = create_signal(cx, String::new());
let greet_msg = create_signal(cx, String::new());
let greet = move |_| {
spawn_local_scoped(cx, async move {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
let new_msg =
invoke("greet", to_value(&GreetArgs { name: &name.get() }).unwrap()).await;
log(&new_msg.as_string().unwrap());
greet_msg.set(new_msg.as_string().unwrap());
})
};
view! { cx,
main(class="container") {
div(class="row") {
input(id="greet-input",bind:value=name,placeholder="Enter a name...")
button(type="button",on:click=greet) {
"Greet"
}
}
p {
b {
(greet_msg.get())
}
}
}
}
}
// main.rs
mod app;
use app::App;
#[cfg(all(not(debug_assertions), not(feature = "ssg")))]
fn main() {
sycamore::hydrate(App);
}
#[cfg(all(debug_assertions, not(feature = "ssg")))]
fn main() {
sycamore::render(App);
}
#[cfg(feature = "ssg")]
fn main() {
let out_dir = std::env::args().nth(1).unwrap();
println!("out_dir {}", out_dir);
let template = std::fs::read_to_string(format!("{}/index.html", out_dir)).unwrap();
let html = sycamore::render_to_string(App);
let html = template.replace("<!--app-html-->\n", &html);
let path = format!("{}/index.html", out_dir);
println!("Writing html to file \"{}\"", path);
std::fs::write(path, html).unwrap();
}
9. Yew
Yew seems rather popular right now. It’s a GUI framework made specifically for the web and has a bit of a React feel to it, albeit with some differences. For example, components are made like state machines with defined messages and reactions to those messages. The components keep state in themselves. Yewdux can be used to implement redux-like data handling. Among other features, Yew also has hydration and server-side rendering. It also lets us define components in a way not unlike functional components defined in React.
Here is one of the lib’s examples compiled:
// main.rs
use cell::Cellule;
use gloo::timers::callback::Interval;
use rand::Rng;
use yew::html::Scope;
use yew::{classes, html, Component, Context, Html};
mod cell;
pub enum Msg {
Random,
Start,
Step,
Reset,
Stop,
ToggleCellule(usize),
Tick,
}
pub struct App {
active: bool,
cellules: Vec<Cellule>,
cellules_width: usize,
cellules_height: usize,
_interval: Interval,
}
impl App {
pub fn random_mutate(&mut self) {
for cellule in self.cellules.iter_mut() {
if rand::thread_rng().gen() {
cellule.set_alive();
} else {
cellule.set_dead();
}
}
}
fn reset(&mut self) {
for cellule in self.cellules.iter_mut() {
cellule.set_dead();
}
}
fn step(&mut self) {
let mut to_dead = Vec::new();
let mut to_live = Vec::new();
for row in 0..self.cellules_height {
for col in 0..self.cellules_width {
let neighbors = self.neighbors(row as isize, col as isize);
let current_idx = self.row_col_as_idx(row as isize, col as isize);
if self.cellules[current_idx].is_alive() {
if Cellule::alone(&neighbors) || Cellule::overpopulated(&neighbors) {
to_dead.push(current_idx);
}
} else if Cellule::can_be_revived(&neighbors) {
to_live.push(current_idx);
}
}
}
to_dead
.iter()
.for_each(|idx| self.cellules[*idx].set_dead());
to_live
.iter()
.for_each(|idx| self.cellules[*idx].set_alive());
}
fn neighbors(&self, row: isize, col: isize) -> [Cellule; 8] {
[
self.cellules[self.row_col_as_idx(row + 1, col)],
self.cellules[self.row_col_as_idx(row + 1, col + 1)],
self.cellules[self.row_col_as_idx(row + 1, col - 1)],
self.cellules[self.row_col_as_idx(row - 1, col)],
self.cellules[self.row_col_as_idx(row - 1, col + 1)],
self.cellules[self.row_col_as_idx(row - 1, col - 1)],
self.cellules[self.row_col_as_idx(row, col - 1)],
self.cellules[self.row_col_as_idx(row, col + 1)],
]
}
fn row_col_as_idx(&self, row: isize, col: isize) -> usize {
let row = wrap(row, self.cellules_height as isize);
let col = wrap(col, self.cellules_width as isize);
row * self.cellules_width + col
}
fn view_cellule(&self, idx: usize, cellule: &Cellule, link: &Scope<Self>) -> Html {
let cellule_status = {
if cellule.is_alive() {
"cellule-live"
} else {
"cellule-dead"
}
};
html! {
<div key={idx} class={classes!("game-cellule", cellule_status)}
onclick={link.callback(move |_| Msg::ToggleCellule(idx))}>
</div>
}
}
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let callback = ctx.link().callback(|_| Msg::Tick);
let interval = Interval::new(200, move || callback.emit(()));
let (cellules_width, cellules_height) = (53, 40);
Self {
active: false,
cellules: vec![Cellule::new_dead(); cellules_width * cellules_height],
cellules_width,
cellules_height,
_interval: interval,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Random => {
self.random_mutate();
log::info!("Random");
true
}
Msg::Start => {
self.active = true;
log::info!("Start");
false
}
Msg::Step => {
self.step();
true
}
Msg::Reset => {
self.reset();
log::info!("Reset");
true
}
Msg::Stop => {
self.active = false;
log::info!("Stop");
false
}
Msg::ToggleCellule(idx) => {
let cellule = self.cellules.get_mut(idx).unwrap();
cellule.toggle();
true
}
Msg::Tick => {
if self.active {
self.step();
true
} else {
false
}
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let cell_rows =
self.cellules
.chunks(self.cellules_width)
.enumerate()
.map(|(y, cellules)| {
let idx_offset = y * self.cellules_width;
let cells = cellules
.iter()
.enumerate()
.map(|(x, cell)| self.view_cellule(idx_offset + x, cell, ctx.link()));
html! {
<div key={y} class="game-row">
{ for cells }
</div>
}
});
html! {
<div>
<section class="game-container">
<section class="game-area">
<div class="game-of-life">
{ for cell_rows }
</div>
<div class="game-buttons">
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Random)}>{ "Random" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Step)}>{ "Step" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Start)}>{ "Start" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Stop)}>{ "Stop" }</button>
<button class="game-button" onclick={ctx.link().callback(|_| Msg::Reset)}>{ "Reset" }</button>
</div>
</section>
</section>
</div>
}
}
}
fn wrap(coord: isize, range: isize) -> usize {
let result = if coord < 0 {
coord + range
} else if coord >= range {
coord - range
} else {
coord
};
result as usize
}
fn main() {
wasm_logger::init(wasm_logger::Config::default());
log::trace!("Initializing yew...");
yew::Renderer::<App>::new().render();
}
10. Bracket
Bracket has been rebranded from rltk (Roguelike Toolkit). Consisting of a console rendering library and several helper tools for writing roguelikes, Bracket runs both web and desktop. Check out this killer tutorial where you can write a roguelike game in Rust.
By default, bracket-lib runs in OpenGL mode (or WebGL if it detects that you are compiling for wasm32-unknown-unknown). It can use wgpu as a backend, which will come in very handy in the future. Here is the last level of their tutorial built for the web:
Source code would be too large to include in the article, so there is a “hello world” example written with this library:
// main.rs
use bracket_lib::prelude::*;
struct State {}
impl GameState for State {
fn tick(&mut self, ctx: &mut BTerm) {
ctx.print(1, 1, "Hello Bracket World");
}
}
fn main() -> BError {
let context = BTermBuilder::simple80x50()
.with_title("Hello Minimal Bracket World")
.build()?;
let gs: State = State {};
main_loop(context, gs)
}
Since I mentioned Bracket, I must also mention Cursive, which is a console lib for terminal interfaces, although it can’t do web right now because it seems to have no compilable backends for browsers.
Below is one example running on the Desktop:
As with the Bracket example, I’ve included a “Hello world” code here.
// main.rs
use cursive::views::TextView;
fn main() {
let mut siv = cursive::default();
siv.add_global_callback('q', |s| s.quit());
siv.add_layer(TextView::new("Hello cursive! Press <q> to quit."));
siv.run();
}
11. Vizia
Vizia is a declarative and reactive GUI framework. It can be used on multiple platforms (Windows, Linux, MacOS, Web), has Elm-like data handling, and is rendered on GPU.
Here is one of their examples:
// main.rs
use std::str::FromStr;
use vizia::fonts::icons_names::DOWN;
use vizia::prelude::*;
use chrono::{NaiveDate, ParseError};
const STYLE: &str = r#"
/*
* {
border-width: 1px;
border-color: red;
}
*/
textbox.invalid {
background-color: #AA0000;
}
"#;
#[derive(Clone)]
pub struct SimpleDate(NaiveDate);
impl Data for SimpleDate {
fn same(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl std::fmt::Display for SimpleDate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.format("%Y:%m:%d"))
}
}
impl FromStr for SimpleDate {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
NaiveDate::parse_from_str(s, "%Y:%m:%d").map(|date| SimpleDate(date))
}
}
#[derive(Lens)]
pub struct AppData {
options: Vec<&'static str>,
choice: String,
start_date: SimpleDate,
end_date: SimpleDate,
}
pub enum AppEvent {
SetChoice(String),
SetStartDate(SimpleDate),
SetEndDate(SimpleDate),
}
impl Model for AppData {
fn event(&mut self, _: &mut EventContext, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::SetChoice(choice) => {
self.choice = choice.clone();
}
AppEvent::SetStartDate(date) => {
self.start_date = date.clone();
}
AppEvent::SetEndDate(date) => {
self.end_date = date.clone();
}
});
}
}
impl AppData {
pub fn new() -> Self {
Self {
options: vec!["one-way flight", "return flight"],
choice: "one-way flight".to_string(),
start_date: SimpleDate(NaiveDate::from_ymd_opt(2022, 02, 12).unwrap()),
end_date: SimpleDate(NaiveDate::from_ymd_opt(2022, 02, 26).unwrap()),
}
}
}
fn main() {
Application::new(|cx| {
cx.add_theme(STYLE);
AppData::new().build(cx);
VStack::new(cx, |cx| {
Dropdown::new(
cx,
move |cx|
// A Label and an Icon
HStack::new(cx, move |cx|{
Label::new(cx, AppData::choice)
.width(Stretch(1.0))
.text_wrap(false);
Label::new(cx, DOWN).font("icons").left(Pixels(5.0)).right(Pixels(5.0));
}).width(Stretch(1.0)),
// List of options
move |cx| {
List::new(cx, AppData::options, |cx, _, item| {
Label::new(cx, item)
.width(Stretch(1.0))
.child_top(Stretch(1.0))
.child_bottom(Stretch(1.0))
.bind(AppData::choice, move |handle, choice| {
let selected = item.get(handle.cx) == choice.get(handle.cx);
handle.background_color(if selected {
Color::from("#f8ac14")
} else {
Color::white()
});
})
.on_press(move |cx| {
cx.emit(AppEvent::SetChoice(item.get(cx).to_string().to_owned()));
cx.emit(PopupEvent::Close);
});
});
},
)
.width(Pixels(150.0));
Textbox::new(cx, AppData::start_date)
.on_edit(|cx, text| {
if let Ok(val) = text.parse::<SimpleDate>() {
cx.emit(AppEvent::SetStartDate(val));
cx.toggle_class("invalid", false);
} else {
cx.toggle_class("invalid", true);
}
})
.width(Pixels(150.0));
Textbox::new(cx, AppData::end_date)
.on_edit(|cx, text| {
if let Ok(val) = text.parse::<SimpleDate>() {
cx.emit(AppEvent::SetEndDate(val));
cx.toggle_class("invalid", false);
} else {
cx.toggle_class("invalid", true);
}
})
.width(Pixels(150.0))
.disabled(AppData::choice.map(|choice| choice == "one-way flight"));
Button::new(cx, |_| {}, |cx| Label::new(cx, "Book").width(Stretch(1.0)))
.width(Pixels(150.0));
})
.row_between(Pixels(10.0))
.child_space(Stretch(1.0));
})
.title("Flight Booker")
.inner_size((250, 250))
.run();
}
12. Leptos
Leptos is an isomorphic web framework that defines GUI like React does, but with more fine-grained reactivity done with the help of Rust closures. The whole render function isn’t executed on every signal update, but rather considered a “setup” function. There’s also no virtual dom. Included in Leptos is a router that works on both the server and the client.
Check out Leptos’ router example:
// lib.rs
// main.rs boilerplate omitted
mod api;
use leptos::*;
use leptos_router::*;
use crate::api::{get_contact, get_contacts};
#[component]
pub fn RouterExample(cx: Scope) -> impl IntoView {
log::debug!("rendering <RouterExample/>");
view! { cx,
<Router>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
// 1) ensuring that relative routing works properly for nested routes
// 2) setting the `aria-current` attribute on the current link,
// for a11y and styling purposes
<A exact=true href="/">"Contacts"</A>
<A href="about">"About"</A>
<A href="settings">"Settings"</A>
<A href="redirect-home">"Redirect to Home"</A>
</nav>
<main>
<Routes>
<Route
path=""
view=move |cx| view! { cx, <ContactList/> }
>
<Route
path=":id"
view=move |cx| view! { cx, <Contact/> }
/>
<Route
path="/"
view=move |_| view! { cx, <p>"Select a contact."</p> }
/>
</Route>
<Route
path="about"
view=move |cx| view! { cx, <About/> }
/>
<Route
path="settings"
view=move |cx| view! { cx, <Settings/> }
/>
<Route
path="redirect-home"
view=move |cx| view! { cx, <Redirect path="/"/> }
/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn ContactList(cx: Scope) -> impl IntoView {
log::debug!("rendering <ContactList/>");
let location = use_location(cx);
let contacts = create_resource(cx, move || location.search.get(), get_contacts);
let contacts = move || {
contacts.read().map(|contacts| {
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
contacts
.into_iter()
.map(|contact| {
view! { cx,
<li><A href=contact.id.to_string()><span>{&contact.first_name} " " {&contact.last_name}</span></A></li>
}
})
.collect::<Vec<_>>()
})
};
view! { cx,
<div class="contact-list">
<h1>"Contacts"</h1>
<Suspense fallback=move || view! { cx, <p>"Loading contacts..."</p> }>
{move || view! { cx, <ul>{contacts}</ul>}}
</Suspense>
<Outlet/>
</div>
}
}
#[derive(Params, PartialEq, Clone, Debug)]
pub struct ContactParams {
id: usize,
}
#[component]
pub fn Contact(cx: Scope) -> impl IntoView {
log::debug!("rendering <Contact/>");
let params = use_params::<ContactParams>(cx);
let contact = create_resource(
cx,
move || params().map(|params| params.id).ok(),
// any of the following would work (they're identical)
// move |id| async move { get_contact(id).await }
// move |id| get_contact(id),
// get_contact
get_contact,
);
let contact_display = move || match contact.read() {
// None => loading, but will be caught by Suspense fallback
// I'm only doing this explicitly for the example
None => None,
// Some(None) => has loaded and found no contact
Some(None) => Some(view! { cx, <p>"No contact with this ID was found."</p> }.into_any()),
// Some(Some) => has loaded and found a contact
Some(Some(contact)) => Some(
view! { cx,
<section class="card">
<h1>{contact.first_name} " " {contact.last_name}</h1>
<p>{contact.address_1}<br/>{contact.address_2}</p>
</section>
}
.into_any(),
),
};
view! { cx,
<div class="contact">
<Transition fallback=move || view! { cx, <p>"Loading..."</p> }>
{contact_display}
</Transition>
</div>
}
}
#[component]
pub fn About(cx: Scope) -> impl IntoView {
log::debug!("rendering <About/>");
// use_navigate allows you to navigate programmatically by calling a function
let navigate = use_navigate(cx);
view! { cx,
<>
// note: this is just an illustration of how to use `use_navigate`
// <button on:click> to navigate is an *anti-pattern*
// you should ordinarily use a link instead,
// both semantically and so your link will work before WASM loads
<button on:click=move |_| { _ = navigate("/", Default::default()); }>
"Home"
</button>
<h1>"About"</h1>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."</p>
</>
}
}
#[component]
pub fn Settings(cx: Scope) -> impl IntoView {
log::debug!("rendering <Settings/>");
view! { cx,
<>
<h1>"Settings"</h1>
<form>
<fieldset>
<legend>"Name"</legend>
<input type="text" name="first_name" placeholder="First"/>
<input type="text" name="last_name" placeholder="Last"/>
</fieldset>
<pre>"This page is just a placeholder."</pre>
</form>
</>
}
}
// api.rs
use futures::{
channel::oneshot::{self, Canceled},
Future,
};
use leptos::set_timeout;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContactSummary {
pub id: usize,
pub first_name: String,
pub last_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contact {
pub id: usize,
pub first_name: String,
pub last_name: String,
pub address_1: String,
pub address_2: String,
pub city: String,
pub state: String,
pub zip: String,
pub email: String,
pub phone: String,
}
pub async fn get_contacts(_search: String) -> Vec<ContactSummary> {
// fake an API call with an artificial delay
_ = delay(Duration::from_millis(300)).await;
vec![
ContactSummary {
id: 0,
first_name: "Bill".into(),
last_name: "Smith".into(),
},
ContactSummary {
id: 1,
first_name: "Tim".into(),
last_name: "Jones".into(),
},
ContactSummary {
id: 2,
first_name: "Sally".into(),
last_name: "Stevens".into(),
},
]
}
pub async fn get_contact(id: Option<usize>) -> Option<Contact> {
// fake an API call with an artificial delay
_ = delay(Duration::from_millis(500)).await;
match id {
Some(0) => Some(Contact {
id: 0,
first_name: "Bill".into(),
last_name: "Smith".into(),
address_1: "12 Mulberry Lane".into(),
address_2: "".into(),
city: "Boston".into(),
state: "MA".into(),
zip: "02129".into(),
email: "bill@smith.com".into(),
phone: "617-121-1221".into(),
}),
Some(1) => Some(Contact {
id: 1,
first_name: "Tim".into(),
last_name: "Jones".into(),
address_1: "56 Main Street".into(),
address_2: "".into(),
city: "Chattanooga".into(),
state: "TN".into(),
zip: "13371".into(),
email: "timjones@lmail.com".into(),
phone: "232-123-1337".into(),
}),
Some(2) => Some(Contact {
id: 2,
first_name: "Sally".into(),
last_name: "Stevens".into(),
address_1: "404 E 123rd St".into(),
address_2: "Apt 7E".into(),
city: "New York".into(),
state: "NY".into(),
zip: "10082".into(),
email: "sally.stevens@wahoo.net".into(),
phone: "242-121-3789".into(),
}),
_ => None,
}
}
fn delay(duration: Duration) -> impl Future<Output = Result<(), Canceled>> {
let (tx, rx) = oneshot::channel();
set_timeout(
move || {
_ = tx.send(());
},
duration,
);
rx
}
13. Perseus
Perseus is a framework whose focus is to be fast and to support every rendering strategy, providing great developer experience at the same time. Its scope is the web, and it uses the reactive semantic for state variables. It’s built on top of Sycamore, but Perseus adds State and Templates concepts on top of it.
Example:
// template/index.rs
use crate::global_state::AppStateRx;
use perseus::prelude::*;
use sycamore::prelude::*;
// Note that this template takes no state of its own in this example, but it
// certainly could
fn index_page<G: Html>(cx: Scope) -> View<G> {
// We access the global state through the render context, extracted from
// Sycamore's context system
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
view! { cx,
// The user can change the global state through an input, and the changes they make will be reflected throughout the app
p { (global_state.test.get()) }
input(bind:value = global_state.test)
a(href = "about", id = "about-link") { "About" }
}
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "Index Page" }
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("index").view(index_page).head(head).build()
// main.rs
mod global_state;
mod templates;
use perseus::prelude::*;
#[perseus::main(perseus_axum::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new()
.template(crate::templates::index::get_template())
.template(crate::templates::about::get_template())
.error_views(ErrorViews::unlocalized_development_default())
.global_state_creator(crate::global_state::get_global_state_creator())
}
14. Sauron
Library for building UI for web with focus on simplicity. React-like UI declaration and ELM-like state management.
// main.rs
use sauron::{jss, prelude::*};
enum Msg {
Increment,
Decrement,
Reset,
}
struct App {
count: i32,
}
impl App {
fn new() -> Self {
App { count: 0 }
}
}
impl Application<Msg> for App {
fn view(&self) -> Node<Msg> {
node! {
<main>
<input type="button"
value="+"
on_click=|_| {
Msg::Increment
}
/>
<button class="count" on_click=|_|{Msg::Reset} >{text(self.count)}</button>
<input type="button"
value="-"
on_click=|_| {
Msg::Decrement
}
/>
</main>
}
}
fn update(&mut self, msg: Msg) -> Cmd<Self, Msg> {
match msg {
Msg::Increment => self.count += 1,
Msg::Decrement => self.count -= 1,
Msg::Reset => self.count = 0,
}
Cmd::none()
}
fn style(&self) -> String {
jss! {
"body":{
font_family: "verdana, arial, monospace",
},
"main":{
width:px(30),
height: px(100),
margin: "auto",
text_align: "center",
},
"input, .count":{
font_size: px(40),
padding: px(30),
margin: px(30),
}
}
}
}
#[wasm_bindgen(start)]
pub fn start() {
Program::mount_to_body(App::new());
}
15. MoonZoon
A fullstack framework from a former Seed maintainer. From the examples, its frontend state management has signal semantics.
The “counter” example code looks like this:
// main.rs
use zoon::*;
#[static_ref]
fn counter() -> &'static Mutable<i32> {
Mutable::new(0)
}
fn increment() {
counter().update(|counter| counter + 1)
}
fn decrement() {
counter().update(|counter| counter - 1)
}
fn root() -> impl Element {
Column::new()
.item(Button::new().label("-").on_press(decrement))
.item(Text::with_signal(counter().signal()))
.item(Button::new().label("+").on_press(increment))
}
// ------ Alternative ------
fn _root() -> impl Element {
let (counter, counter_signal) = Mutable::new_and_signal(0);
let on_press = move |step: i32| *counter.lock_mut() += step;
Column::new()
.item(
Button::new()
.label("-")
.on_press(clone!((on_press) move || on_press(-1))),
)
.item_signal(counter_signal)
.item(Button::new().label("+").on_press(move || on_press(1)))
}
// ---------- // -----------
fn main() {
start_app("app", root);
}
16. Relm4
Relm4 is an idiomatic GUI library inspired by Elm and based on gtk4-rs. Relm4 is a new version of relm that’s built from scratch and compatible with GTK4 and libadwaita. Although the original relm is still maintained, these two libraries (relm and relm4) seem to be maintained by different people.
Currently, only devices and desktop are available. No web is available, probably because of the gtk4 backend. Still, Relm4 can make use of CSS; for example, in https://github.com/Relm4/Relm4/blob/53b9a6bf6e514a5cce979c8307a4fefea422bdd7/examples/tracker.rs, global CSS is used for UI rendering. Here is a screenshot of one of their examples running on desktop:
// main.rs
use gtk::glib::clone;
use gtk::prelude::{BoxExt, ButtonExt, GtkWindowExt};
use relm4::{gtk, ComponentParts, ComponentSender, RelmApp, RelmWidgetExt, SimpleComponent};
struct App {
counter: u8,
}
#[derive(Debug)]
enum Msg {
Increment,
Decrement,
}
struct AppWidgets {
// window: gtk::Window,
// vbox: gtk::Box,
// inc_button: gtk::Button,
// dec_button: gtk::Button,
label: gtk::Label,
}
impl SimpleComponent for App {
type Init = u8;
type Input = Msg;
type Output = ();
type Widgets = AppWidgets;
type Root = gtk::Window;
fn init_root() -> Self::Root {
gtk::Window::builder()
.title("Simple app")
.default_width(300)
.default_height(100)
.build()
}
// Initialize the component.
fn init(
counter: Self::Init,
window: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let model = App { counter };
let vbox = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(5)
.build();
let inc_button = gtk::Button::with_label("Increment");
let dec_button = gtk::Button::with_label("Decrement");
let label = gtk::Label::new(Some(&format!("Counter: {}", model.counter)));
label.set_margin_all(5);
window.set_child(Some(&vbox));
vbox.set_margin_all(5);
vbox.append(&inc_button);
vbox.append(&dec_button);
vbox.append(&label);
inc_button.connect_clicked(clone!(@strong sender => move |_| {
sender.input(Msg::Increment);
}));
dec_button.connect_clicked(clone!(@strong sender => move |_| {
sender.input(Msg::Decrement);
}));
let widgets = AppWidgets { label };
ComponentParts { model, widgets }
}
fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) {
match msg {
Msg::Increment => {
self.counter = self.counter.wrapping_add(1);
}
Msg::Decrement => {
self.counter = self.counter.wrapping_sub(1);
}
}
}
// Update the view to represent the updated model.
fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender<Self>) {
widgets
.label
.set_label(&format!("Counter: {}", self.counter));
}
}
fn main() {
let app = RelmApp::new("relm4.example.simple_manual");
app.run::<App>(0);
}
17. Fltk-rs
Fltk-rs is bindings for https://www.fltk.org. FLTK currently doesn’t support Wasm or iOS. It has experimental support for Android (YMMV) and is focused on desktop applications. This is one of the low-level bindings that can be used as graphic foundations by apps or GUI frameworks.
The image below shows one of their examples running on the Desktop:
// main.rs
use fltk::{
app, button, draw, enums::*, frame::Frame, image::SvgImage, prelude::*, valuator::*,
widget::Widget, window::Window,
};
use std::ops::{Deref, DerefMut};
use std::{cell::RefCell, rc::Rc};
const POWER: &str = r#"<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 315.083 315.083" style="enable-background:new 0 0 315.083 315.083;" xml:space="preserve">
<g id="Layer_1">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="157.5417" y1="4.5417" x2="157.5417" y2="310.5417">
<stop offset="0" style="stop-color:#939598"/>
<stop offset="0.25" style="stop-color:#414042"/>
<stop offset="0.5" style="stop-color:#252223"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<circle style="fill:url(#SVGID_1_);" cx="157.542" cy="157.542" r="153"/>
</g>
<g id="Layer_2">
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="157.5417" y1="292.5417" x2="157.5417" y2="22.5417">
<stop offset="0" style="stop-color:#58595B"/>
<stop offset="0.1" style="stop-color:#414042"/>
<stop offset="0.2" style="stop-color:#242122"/>
<stop offset="1" style="stop-color:#000000"/>
</linearGradient>
<circle style="fill:url(#SVGID_2_);stroke:#58595B;stroke-miterlimit:10;" cx="157.542" cy="157.542" r="135"/>
</g>
<g id="Layer_4">
<radialGradient id="SVGID_3_" cx="157.5417" cy="89.9217" r="62.2727" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#58595B"/>
<stop offset="0.5" style="stop-color:#414042"/>
<stop offset="1" style="stop-color:#231F20"/>
</radialGradient>
<radialGradient id="SVGID_4_" cx="157.5417" cy="89.9217" r="62.7723" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="0.6561" style="stop-color:#231F20"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<ellipse style="fill:url(#SVGID_3_);stroke:url(#SVGID_4_);stroke-miterlimit:10;" cx="157.542" cy="89.922" rx="59.833" ry="64.62"/>
</g>
<g id="Layer_6">
<path style="fill:none;stroke:red;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;" d="M119.358,119.358
c-9.772,9.772-15.816,23.272-15.816,38.184c0,14.912,6.044,28.412,15.816,38.184s23.272,15.816,38.184,15.816
c14.912,0,28.412-6.044,38.184-15.816s15.816-23.272,15.816-38.184c0-14.912-6.044-28.412-15.816-38.184"/>
<line style="fill:none;stroke:red;stroke-width:10;stroke-linecap:round;stroke-miterlimit:10;" x1="157.542" y1="154.542" x2="157.542" y2="100.542"/>
</g>
</svg>"#;
pub struct FlatButton {
wid: Widget,
}
impl FlatButton {
pub fn new(x: i32, y: i32, w: i32, h: i32, label: &str) -> FlatButton {
let mut x = FlatButton {
wid: Widget::new(x, y, w, h, None).with_label(label),
};
x.draw();
x.handle();
x
}
// Overrides the draw function
fn draw(&mut self) {
self.wid.draw(move |b| {
draw::draw_box(
FrameType::FlatBox,
b.x(),
b.y(),
b.width(),
b.height(),
Color::from_u32(0x304FFE),
);
draw::set_draw_color(Color::White);
draw::set_font(Font::Courier, 24);
draw::draw_text2(
&b.label(),
b.x(),
b.y(),
b.width(),
b.height(),
Align::Center,
);
});
}
// Overrides the handle function.
// Notice the do_callback which allows the set_callback method to work
fn handle(&mut self) {
let mut wid = self.wid.clone();
self.wid.handle(move |_, ev| match ev {
Event::Push => {
wid.do_callback();
true
}
_ => false,
});
}
}
impl Deref for FlatButton {
type Target = Widget;
fn deref(&self) -> &Self::Target {
&self.wid
}
}
impl DerefMut for FlatButton {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.wid
}
}
pub struct PowerButton {
frm: Frame,
on: Rc<RefCell<bool>>,
}
impl PowerButton {
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
let mut frm = Frame::new(x, y, w, h, "");
frm.set_frame(FrameType::FlatBox);
frm.set_color(Color::Black);
let on = Rc::from(RefCell::from(false));
frm.draw({
// storing two almost identical images here, in a real application this could be optimized
let on = Rc::clone(&on);
let mut on_svg = SvgImage::from_data(&POWER.to_string().replace("red", "green")).unwrap();
on_svg.scale(frm.width(), frm.height(), true, true);
let mut off_svg = SvgImage::from_data(POWER).unwrap();
off_svg.scale(frm.width(), frm.height(), true, true);
move |f| {
if *on.borrow() {
on_svg.draw(f.x(), f.y(), f.width(), f.height());
} else {
off_svg.draw(f.x(), f.y(), f.width(), f.height());
};
}
});
frm.handle({
let on = on.clone();
move |f, ev| match ev {
Event::Push => {
let prev = *on.borrow();
*on.borrow_mut() = !prev;
f.do_callback();
f.redraw();
true
}
_ => false,
}
});
Self { frm, on }
}
pub fn is_on(&self) -> bool {
*self.on.borrow()
}
}
impl Deref for PowerButton {
type Target = Frame;
fn deref(&self) -> &Self::Target {
&self.frm
}
}
impl DerefMut for PowerButton {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.frm
}
}
pub struct FancyHorSlider {
s: Slider,
}
impl FancyHorSlider {
pub fn new(x: i32, y: i32, width: i32, height: i32) -> Self {
let mut s = Slider::new(x, y, width, height, "");
s.set_type(SliderType::Horizontal);
s.set_frame(FrameType::RFlatBox);
s.set_color(Color::from_u32(0x868db1));
s.draw(|s| {
draw::set_draw_color(Color::Blue);
draw::draw_pie(
s.x() - 10 + (s.w() as f64 * s.value()) as i32,
s.y() - 10,
30,
30,
0.,
360.,
);
});
Self { s }
}
}
impl Deref for FancyHorSlider {
type Target = Slider;
fn deref(&self) -> &Self::Target {
&self.s
}
}
impl DerefMut for FancyHorSlider {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.s
}
}
fn main() {
let app = app::App::default().with_scheme(app::Scheme::Gtk);
// app::set_visible_focus(false);
let mut wind = Window::default()
.with_size(800, 600)
.with_label("Custom Widgets");
let mut but = FlatButton::new(350, 350, 160, 80, "Increment");
let mut power = PowerButton::new(600, 100, 100, 100);
let mut dial = FillDial::new(100, 100, 200, 200, "0");
let mut frame = Frame::default()
.with_size(160, 80)
.with_label("0")
.above_of(&*but, 20);
let mut fancy_slider = FancyHorSlider::new(100, 550, 500, 10);
let mut toggle = button::ToggleButton::new(650, 400, 80, 35, "@+9circle")
.with_align(Align::Left | Align::Inside);
wind.end();
wind.show();
wind.set_color(Color::Black);
frame.set_label_size(32);
frame.set_label_color(Color::from_u32(0xFFC300));
dial.set_label_color(Color::White);
dial.set_label_font(Font::CourierBold);
dial.set_label_size(24);
dial.set_color(Color::from_u32(0x6D4C41));
dial.set_color(Color::White);
dial.set_selection_color(Color::Red);
toggle.set_frame(FrameType::RFlatBox);
toggle.set_label_color(Color::White);
toggle.set_selection_color(Color::from_u32(0x00008B));
toggle.set_color(Color::from_u32(0x585858));
toggle.clear_visible_focus();
toggle.set_callback(|t| {
if t.is_set() {
t.set_align(Align::Right | Align::Inside);
} else {
t.set_align(Align::Left | Align::Inside);
}
t.parent().unwrap().redraw();
});
dial.draw(|d| {
draw::set_draw_color(Color::Black);
draw::draw_pie(d.x() + 20, d.y() + 20, 160, 160, 0., 360.);
draw::draw_pie(d.x() - 5, d.y() - 5, 210, 210, -135., -45.);
});
dial.set_callback(|d| {
d.set_label(&format!("{}", (d.value() * 100.) as i32));
app::redraw();
});
but.set_callback(move |_| {
frame.set_label(&(frame.label().parse::<i32>().unwrap() + 1).to_string())
});
power.set_callback(move |_| {
println!("power button clicked");
});
fancy_slider.set_callback(|s| s.parent().unwrap().redraw());
app.run().unwrap();
}
I should also mention https://gtk-rs.org here as it’s similar in its level of development primitives provided. Also, Relm4 (see the according tool in this post) is a GUI library based on it.
18. Makepad
Makepad is a UI framework (native+web), and it has its own “satellite” IDE Makepad Studio.
According to the framework description, “applications built using Makepad Framework can run both natively and on the web, are rendered entirely on the GPU, and support a novel feature called live design.” [https://github.com/makepad/makepad/blob/21032963ca1d89fc2449d578faf0a1f17c08ec8c/widgets/README.md].
The styling of Makepad Framework applications is described using a domain-specific language with Rust macros. It allows for a live reload of these templates since they’re being fed right into a running Rust app, so the screen changes on the fly.
This is a great example of this framework in action:
Open the example directly to hear sounds. They can’t make it through the iframe!
Instead of the provided player example code, there is an example code for a more simplistic Counter “hello world” app:
const wasm = await WasmWebGL.fetch_and_instantiate_wasm(
"/makepad/target/wasm32-unknown-unknown/release/makepad-example-simple.wasm"
);
class MyWasmApp {
constructor(wasm) {
let canvas = document.getElementsByClassName('full_canvas')[0];
this.bridge = new WasmWebGL (wasm, this, canvas);
}
}
let app = new MyWasmApp(wasm);
// main.rs
use makepad_draw_2d::*;
use makepad_widgets;
use makepad_widgets::*;
// The live_design macro generates a function that registers a DSL code block with the global
// context object (`Cx`).
//
// DSL code blocks are used in Makepad to facilitate live design. A DSL code block defines
// structured data that describes the styling of the UI. The Makepad runtime automatically
// initializes widgets from their corresponding DSL objects. Moreover, external programs (such
// as a code editor) can notify the Makepad runtime that a DSL code block has been changed, allowing
// the runtime to automatically update the affected widgets.
live_design! {
import makepad_widgets::button::Button;
import makepad_widgets::label::Label;
// The `{{App}}` syntax is used to inherit a DSL object from a Rust struct. This tells the
// Makepad runtime that our DSL object corresponds to a Rust struct named `App`. Whenever an
// instance of `App` is initialized, the Makepad runtime will obtain its initial values from
// this DSL object.
App = {{App}} {
// The `ui` field on the struct `App` defines a frame widget. Frames are used as containers
// for other widgets. Since the `ui` property on the DSL object `App` corresponds with the
// `ui` field on the Rust struct `App`, the latter will be initialized from the DSL object
// here below.
ui: {
// The `layout` property determines how child widgets are laid out within a frame. In
// this case, child widgets flow downward, with 20 pixels of spacing in between them,
// and centered horizontally with respect to the entire frame.
//
// Because the child widgets flow downward, vertical alignment works somewhat
// differently. In this case, children are centered vertically with respect to the
// remainder of the frame after the previous children have been drawn.
layout: {
flow: Down,
spacing: 20,
align: {
x: 0.5,
y: 0.5
}
},
// The `walk` property determines how the frame widget itself is laid out. In this
// case, the frame widget takes up the entire window.
walk: {
width: Fill,
height: Fill
},
bg: {
shape: Solid
// The `fn pixel(self) -> vec4` syntax is used to define a property named `pixel`,
// the value of which is a shader. We use our own custom DSL to define shaders. It's
// syntax is *mostly* compatible with GLSL, although there are some differences as
// well.
fn pixel(self) -> vec4 {
// Within a shader, the `self.geom_pos` syntax is used to access the `geom_pos`
// attribute of the shader. In this case, the `geom_pos` attribute is built in,
// and ranges from 0 to 1. over x and y of the rendered rectangle
return mix(#7, #3, self.geom_pos.y);
}
}
// The `name:` syntax is used to define fields, i.e. properties for which there are
// corresponding struct fields. In contrast, the `name =` syntax is used to define
// instance properties, i.e. properties for which there are no corresponding struct
// fields. Note that fields and instance properties use different namespaces, so you
// can have both a field and an instance property with the same name.
//
// Widgets can hook into the Makepad runtime with custom code and determine for
// themselves how they want to handle instance properties. In the case of frame widgets,
// they simply iterate over their instance properties, and use them to instantiate their
// child widgets.
// A button to increment the counter.
//
// The `<Button>` syntax is used to inherit a DSL object from another DSL object. This
// tells the Makepad runtime our DSL object has the same properties as the DSL object
// named `Button`, except for the properties defined here below, which override any
// existing values.
button1 = <Button> {
text: "Click to count"
}
// A label to display the counter.
label1 = <Label> {
label: {
color: #f
},
text: "Counter: 0"
}
}
}
}
// This main_app macro generates the code necessary to initialize and run your application.
//
// This code is almost always the same between different applications, so it is convenient to use a
// macro for it. The two main tasks that this code needs to carry out are: initializing both the
// main application struct (`App`) and the global context object (`Cx`), and setting up event
// handling. On desktop, this means creating and running our own event loop from a fn main(). On web, this means
// creating an event handler function that the browser event loop can call into.
main_app!(App);
// The main application struct.
//
// The #[derive(Live, LiveHook)] attribute implements a bunch of traits for this struct that enable
// it to interact with the Makepad runtime. Among other things, this enables the Makepad runtime to
// initialize the struct from a DSL object.
#[derive(Live, LiveHook)]
pub struct App {
// A chromeless window for our application. Used to contain our frame widget.
window: BareWindow,
// A frame widget. Used to contain our button and label.
ui: FrameRef,
// The value for our counter.
//
// The #[rust] attribute here is used to indicate that this field should *not* be initialized
// from a DSL object, even when a corresponding property exists.
#[rust]
counter: usize,
}
impl App {
// This function is used to register any DSL code blocks that you defined in this file. It is
// called automatically by the code we generated with the call to the macro `main_app` above.
// In this function you have to register any dependency crates live design code
pub fn live_design(cx: &mut Cx) {
makepad_widgets::live_design(cx);
}
// This function is used to handle any incoming events from the host system. It is called
// automatically by the code we generated with the call to the macro `main_app` above.
pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Draw(event) = event {
// This is a draw event, so create a draw context and use that to draw our application.
let mut draw_cx = Cx2d::new(cx, event);
return self.draw(&mut draw_cx);
}
// Forward the event to the window.
self.window.handle_event(cx, event);
// Forward the event to the frame. In this case, handle_event returns a list of actions.
// Actions are similar to events, except that events are always forwarded downward to child
// widgets, while actions are always returned back upwards to parent widgets.
let actions = self.ui.handle_event(cx, event);
// Get a reference to our button from the frame, and check if one of the actions returned by
// the frame was a notification that the button was clicked.
if self.ui.get_button(id!(button1)).clicked(&actions) {
// Increment the counter.
self.counter += 1;
// Get a reference to our label from the frame, update its text, and schedule a redraw
// for it.
let label = self.ui.get_label(id!(label1));
label.set_text(&format!("Counter: {}", self.counter));
label.redraw(cx);
}
}
// This is the immediate mode draw flow, as called above in response to the Draw event
pub fn draw(&mut self, cx: &mut Cx2d) {
// Indicate that we want to begin drawing to the window.
if self.window.begin(cx).not_redrawing() {
return;
}
// Draw the frame to the window.
let _ = self.ui.draw(cx);
// Indicate that we finished drawing to the window.
self.window.end(cx);
}
}
19. Rui
Rui is an experimental Rust UI library inspired by SwiftUI. To date, this library is in the early days of development.
Rui is an immediate mode GUI library. It’s GPU rendered, updates reactively when state changes, and has richer layout options than other immediate mode UIs. The lib uses its own rendering tool vger-rs, like Druid would use Piet or Vello. One of its main goals is to “Encode UI in types to ensure stable identity.”. For more info on identity and id paths, read Xilem’s creator UI architecture article.
The initial purpose of Rui was that the creators wanted to move their own app (a modular music processing app) to a better platform (namely, Rust). The lib is not properly runnable on the web right now because the library’s default backend is WebGPU. However, I think it could be run on the web, albeit with some effort.
20. Silkenweb
Silkenweb is a web-first library that emphasizes having no virtual DOM, no macro usage for describing DOM, and signal-based reactivity.
There’s their example that uses CSS and signals:
// main.rs
use silkenweb::{elements::html::*, log_panics, prelude::*};
silkenweb::css!("color.css");
fn change_color(
color: &Mutable<&'static str>,
description: &str,
new_color: &'static str,
) -> Button {
clone!(color);
button()
.text(description)
.on_click(move |_, _| color.set(new_color))
}
fn main() {
log_panics();
let color = Mutable::new("green");
let app = div()
.style_property(var::COLOR, Sig(color.signal_cloned()))
.child(change_color(&color, "Red", "red"))
.child(change_color(&color, "Green", "green"))
.child(
div()
.class(class::COLOR)
.text("Click either the 'Red' or 'Green' button to set the color of this text."),
);
mount("app", app);
}
.color {
color: var(--color);
}
21. Rust-dominator
Rust-dominator is another very fast library that uses direct DOM manipulation and leverages the Signals pattern.
It is describe as one of the fastest, and very scalable with O(1) cost of updates even in larger DOM.
// lib.rs
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use gloo_timers::future::TimeoutFuture;
use serde_derive::{Serialize, Deserialize};
use futures_signals::signal::{Mutable, not};
use dominator::{html, class, events, clone, with_node, Dom};
use web_sys::HtmlInputElement;
use once_cell::sync::Lazy;
use util::*;
mod util;
#[derive(Debug, Serialize, Deserialize)]
struct User {
login: String,
id: u32,
node_id: String,
avatar_url: String,
gravatar_id: String,
url: String,
html_url: String,
followers_url: String,
following_url: String,
gists_url: String,
starred_url: String,
subscriptions_url: String,
repos_url: String,
events_url: String,
received_events_url: String,
#[serde(rename = "type")]
type_: String,
site_admin: bool,
name: Option<String>,
company: Option<String>,
blog: String,
location: Option<String>,
email: Option<String>,
hireable: Option<bool>,
bio: Option<String>,
public_repos: u32,
public_gists: u32,
followers: u32,
following: u32,
created_at: String,
updated_at: String,
}
impl User {
async fn fetch(user: &str) -> Result<Self, JsValue> {
let user = fetch_github(&format!("https://api.github.com/users/{}", user)).await?;
Ok(serde_json::from_str::<Self>(&user).unwrap())
}
}
struct App {
user: Mutable<Option<User>>,
input: Mutable<String>,
loader: AsyncLoader,
}
impl App {
fn new(name: &str, user: Option<User>) -> Arc<Self> {
Arc::new(Self {
user: Mutable::new(user),
input: Mutable::new(name.to_string()),
loader: AsyncLoader::new(),
})
}
fn render(app: Arc<Self>) -> Dom {
static APP: Lazy<String> = Lazy::new(|| class! {
.style("white-space", "pre")
});
html!("div", {
.class(&*APP)
.children(&mut [
html!("input" => HtmlInputElement, {
.prop_signal("value", app.input.signal_cloned())
.with_node!(element => {
.event(clone!(app => move |_: events::Input| {
app.input.set(element.value());
}))
})
}),
html!("button", {
.text("Lookup user")
.event(clone!(app => move |_: events::Click| {
let input = app.input.lock_ref();
if *input == "" {
app.user.set(None);
} else {
let input = input.to_string();
app.loader.load(clone!(app => async move {
// Simulate a slow network
TimeoutFuture::new(5_000).await;
let user = User::fetch(&input).await.ok();
app.user.set(user);
}));
}
}))
}),
html!("button", {
.text("Cancel")
.event(clone!(app => move |_: events::Click| {
app.loader.cancel();
}))
}),
html!("div", {
.visible_signal(app.loader.is_loading())
.text("LOADING")
}),
html!("div", {
.visible_signal(not(app.loader.is_loading()))
.text_signal(app.user.signal_ref(|user| format!("{:#?}", user)))
}),
])
})
}
}
#[wasm_bindgen(start)]
pub async fn main_js() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let user = User::fetch("Pauan").await.ok();
let app = App::new("Pauan", user);
dominator::append_dom(&dominator::body(), App::render(app));
Ok(())
}
Six tips for building WebAssembly bundles
Now that we’ve established the contemporary toolkit, I’d like to share how I built WebAssembly bundles using these libraries.
First, Trunk is a nice tool to build Wasm and roll out a dev web server for live updates. In some cases, just running trunk serve
was enough for me. In other cases, you have to build the classy way (cargo build --release --target wasm32-unknown-unknown
and wasm-bindgen
), and serve with any static server, for example, python3 -m http.server
.
Second, the getrandom lib used in the dependency tree was often an issue, but it can be resolved if we just add it to the project with the “js” feature enabled.
Third, any asynchronous features have issues with Wasm builds. This is currently mitigated by the lib gloo-timers. Other ways to cope with these issues are addressed in my blog post How to set up a Wasm API for a CHIP-8 emulator.
Fourth, some libs/framework ship their own futures, like Sycamore: features=[“suspense”]
.
Fifth, I had to run most examples from the latest master branches (the published lib APIs were already outdated there) because the libraries are so volatile at this point.
Sixth, although the standard is called WebGPU, the apps written as backend with WebGPU actually work better on Desktop. However, this will change soon when WebGPU gets better support in browsers.
Pet Sematary
It’s also worth mentioning tools that are currently discontinued or no longer actively maintained. Most of these tools were the foundation and inspiration of the currently maintained libraries I’ve mentioned above. Since Rust UI is extremely turbulent, I’ll include the repositories that haven’t been updated in a year.
Druid has been discontinued by the core development team who moved their efforts to xilem. However, some support and updates have been done, like the recent 0.8v release.
Moxie is either dead or in a very deep sleep.
Seed has an Elm-like architecture and is not maintained at the moment.
Conrod is no longer maintained.
Orbtk has experienced its sunset.
Conclusions
Overall, the current state of Rust GUI tools is precarious, so the tools you choose today can deprecate by tomorrow. These tools move so fast that they’re even ahead of browsers (WebGPU–I’m talking about you). And that’s not a bad thing.
There’s a lot of momentum in development right now, which will lead to both new and better ways to deal with GUI as devs improve on the techniques that were established in the (shockingly) recent past.
If it exists at all, the Holy Grail of Rust GUI architecture is yet to be found, as GUI has its own set of complex problems. However, the community is searching for the best solutions right now, which means some tools will inevitably be abandoned. And soon, this post will become obsolete and retire to the Pet Sematary as well. When this inevitably happens, I’ll publish a new post. You should also expect to discover new tools popping up from time to time as you explore on your own. For better or for worse, it’s an interesting time to be alive as we discover the capacities of Rust GUI tools, new and old!
References
Although I used multiple different articles to feed and validate this work, the two that impressed me the most were Raph Levien’s blog, Xilem: an architecture for UI in Rust and Diggory Hardy’s blog, State of GUI 2022.
Each blog provides an in-depth, educated look into the problems and solutions of GUI, specifically in Rust. Fittingly, both blogs are written by real library creators. The former comes from the creator of Druid/Xilem, and the latter from the creator of Kas. They both know their trade indeed.
Also, here is my Github repository with complete code examples for most of the tools presented in the article.