The graphical user interface (GUI) is the cornerstone of most modern applications. It is often the main interface to use a computer program, providing a bridge from your software to someone out there in the world. Therefore, to best represent our hard-earned application logic, a GUI should be responsive, robust, and accessible.
So let's use Rust, a modern language for efficient and robust software! Well...
I've been working on open-source Rust projects for about 8 years now, with my last year spent on contracts working on Rust UI at various companies, including DioxusLabs. In my experiences with Rust user interfaces I've seen a lot of unfinished code, had battles with the borrow-checker, and ultimately felt frusturated on the direction of most current frameworks.
Modern user-interfaces (UI) often use a programming paradigm known as "declarative programming":
Declarative programming is a non-imperative style of programming in which programs describe their desired results without explicitly listing commands or steps that must be performed [1]
React (web), Jetpack Compose (Android), and SwiftUI (iOS) are all example of a declarative programming paradigm. These frameworks all create somewhat of a "virtual DOM" over a UI, where components are the building blocks for reactive applications. Components often return other components, which then use some mechanism to re-run when their state is changed.
For example, let's take a look at this counter UI component from ReactJS:
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
return (
<div>
<h1>{count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
This creates a component that will create HTML elements (a title h1
and two button
s) then update them on changes to state.
In this example, the variable count
is created with something called a hook, React's way of creating or accessing state.
In this case count
is created during the initial run of the component, and then re-used if the component is re-run.
One key aspect of this is that state can be accessed by multiple components, something that is often worked-around with pointers like RefCell
in Rust frameworks [2].
This however creates a number of problems related to dynamic borrowing, a pattern that makes it all too easy to cause an error during production.
Actuate is my solution to declarative programming in Rust. By following a similar pattern to ReactJS, Actuate uses components (called composables) and hooks. However, this framework uses zero-cost smart pointers to guarantee the lifetimes of state in Rust.
Since components outlive their children, an optimization can be made to use regular lifetimes.
This is completely safe when the data is guaranteed to be pinned between frames, which is enforced by the Data
trait.
Now let's take a look at that same counter component example from ReactJS, but now in Rust with Actuate and Bevy:
#[derive(Data)]
struct Counter {
start: i32,
}
impl Compose for Counter {
fn compose(cx: Scope<Self>) -> impl Compose {
let count = use_mut(&cx, || cx.me().start);
spawn(Node {
flex_direction: FlexDirection::Column,
..default()
})
.content((
spawn(Text::new(format!("High five count: {}", count))),
spawn(Text::new("Up high"))
.observe(move |_: Trigger<Pointer<Click>>| SignalMut::update(count, |x| *x += 1)),
spawn(Text::new("Down low"))
.observe(move |_: Trigger<Pointer<Click>>| SignalMut::update(count, |x| *x -= 1))
))
}
}
Here use_mut
produces a SignalMut<'a>
, a simple wrapper around a pinned value.
This lets us have a guaranteed reference to read from during composition, as well write to during updates (with SignalMut::update
).
Updates are queued to happen after composition, just like ReactJS, which actually gives us a guaranteed way to seperate reads from writes
(no mutable reference to state can be held during a composition, where immutable references can be used).
spawn
is used to spawn bundles of Component
s onto Bevy's ECS, a powerful engine for UI and games in Rust.
The main benefit of Actuate is its lifetime-friendly architecture, which allows for borrowed state.
Let's build a quick list of data from an external API, such as dog breeds.
First off let's make a Breed
composable that borrows from some data.
#[derive(Data)]
struct Breed<'a> {
name: &'a String,
families: &'a Vec<String>,
}
impl Compose for Breed<'_> {
fn compose(cx: Scope<Self>) -> impl Compose {
spawn(Node {
flex_direction: FlexDirection::Row,
..default()
})
.content((
spawn((
Text::new(cx.me().name),
Node {
width: Val::Px(300.0),
..default()
},
)),
spawn(Node {
flex_direction: FlexDirection::Column,
..default()
})
.content(compose::from_iter(
Signal::map(cx.me(), |me| me.families),
|family| spawn(Text::from(family.to_string())),
)),
))
}
}
Since multi-threaded async tasks can also borrow from our state, we can just load the data from HTTP with the reqwest
crate,
and return a list of Breed
composables.
// Dog breed list composable.
#[derive(Data)]
struct BreedList;
impl Compose for BreedList {
fn compose(cx: Scope<Self>) -> impl Compose {
let breeds = use_mut(&cx, HashMap::new);
// Spawn a task that loads dog breeds from an HTTP API.
use_task(&cx, move || async move {
let json: Response = reqwest::get("https://dog.ceo/api/breeds/list/all")
.await
.unwrap()
.json()
.await
.unwrap();
SignalMut::update(breeds, |breeds| *breeds = json.message);
});
// Render the currently loaded breeds.
spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(30.),
overflow: Overflow::scroll_y(),
..default()
})
.content(compose::from_iter(breeds, |breed| Breed {
name: breed.0,
families: breed.1,
}))
}
}
[1] https://en.wikipedia.org/wiki/Declarative_programming
[2] Xilem takes a different approach to this, but provides a much less composable enviornment.
State must be re-accessed during events, combining View
s often leads to imperative code, and a child View
cannot render without its
parent first being rendered.