It’s an exciting time for GUI in Rust.
There are now quite a few, well written windowing libraries in Rust. winit
is the leader of the pack, with the best platform support and a newly merged keyboard support PR that positions it to become the dominant windowing system in the ecosystem. There are a couple of other contenders, but none of them are serious contenders.
Nowadays there’s a pretty good immediate mode GUI library in egui
and a pretty good retained mode library in iced
. Both of these libraries have found usage in the real world, egui
being used in [rerun.io] and iced
being heavily used by the [Redox OS] project.
At a low-level, tooling has gotten pretty good. cosmic-text
is on its way to becoming the text library for Rust, handling all the edge cases and then some. With softbuffer
, you don’t need a 3D rendering API to draw to a window anymore; anyone can just put pixels in a framebuffer and push that to a window. tiny-skia
is at arm’s reach for anyone to do any kind of drawing. With these packages combined, I’ve been working on a rendering framework that handles drawing out of the box.
Now that the basics are being established, it’s time to experiment with what kind of model works best with Rust. In addition to the models I mentioned above, the Xilem model has a decent amount of hype behind it. But, I think it still falls a little bit short of what we should be aiming for with a Rust GUI framework.
Now, bear with me here
On the Rustacean Station podcast, I asserted that the future of async
in Rust and the future of GUI in Rust are going to be heavily intertwined. GUI frameworks needs a way to handle events in a component system, and (in my opinion!) async
Rust provides a way to create compelling event handlers and components.
Here’s one of this year’s dozen new Rust GUIs.
For almost a year as of the time of writing I’ve been a maintainer for the smol
async
runtime. This means that I’ve seen my fair share of async
code and how it works in network applications. So I hope that you understand that, when I see this iced
code:
impl Counter {
pub fn view(&mut self) -> Column<Message> {
column![
button("+").on_press(Message::IncrementPressed),
text(self.value).size(50),
button("-").on_press(Message::DecrementPressed),
]
}
pub fn update(&mut self, message: Message) {
match message {
Message::IncrementPressed => {
self.value += 1;
}
Message::DecrementPressed => {
self.value -= 1;
}
}
}
}
…I start to think, “hey, doesn’t this look a little like a Future
?”
Let’s pretend like we live in an alternative version of Rust, where the Context
contains rendering state in addition to the Waker
. It would take some rearranging: rather than having an update()
and a view()
callback, you would need to combine them into a single function, and use Poll
to figure out exactly when something has fired.
impl Future for Counter {
// Actually, what *would* a widget return? Let's put a pin in that for now.
type Output = std::convert::Infallible;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
let plus_button = button("+");
let minus_button = button("-");
// Check to see if the buttons have been clicked.
if plus_button.poll_click(cx).is_ready() {
self.value += 1;
} else if minus_button.poll_click(cx).is_ready() {
self.value -= 1;
}
// Once again, bear with me here.
cx.render_components(
column![
plus_button,
text(self.value).size(50),
minus_button
]
);
Poll::Pending
}
}
Now, this is awkward. It’s also a little bit reminiscent of the [React] pattern, and a little bit too close to immediate mode for my liking, but let’s ignore that for now. What we have here is a future that takes some state, creates some widgets, polls them for their status and then returns some drawing logic. Kind of like a Future
, kind of like a widget.
The Future
pattern on its own is awkward, but thanks to async
/await
syntax, it doesn’t have to be. Let’s reimagine this widget as an async
method, but this time we don’t have to pretend that Context
is magic, since we can pass in some other kind of GUI state parameter.
use futures_lite::prelude::*;
use std::cell::Cell;
use unsend::{Event, EventListener};
async fn counter(state: &GuiState) -> WidgetReturnValue {
// Create the components we interact with.
let mut plus_button = button("+");
let mut minus_button = button("-");
// Create some state.
let counter = Cell::new(0);
// Create a notification mechanism for when the counter changes.
let counter_changed = Event::new();
// Make some futures to handle the button presses.
let plus_click = async {
loop {
// Wait for the button to be clicked.
plus_button.clicked().await;
// Set the shared state.
counter.set(counter.get() + 1);
// Notify the drawer that the counter has changed.
counter_changed.notify(1);
}
};
let minus_click = async {
loop {
// As above, so below.
minus_button.clicked().await;
counter.set(counter.get() - 1);
counter_changed.notify(1);
}
};
// Create a future that draws those buttons in a column.
let renderer = async {
let text = text(counter.get()).size(50);
let column = column((
&plus_button,
&text,
&minus_button
));
let listener = EventListener::new(&column_changed);
futures_lite::pin!(listener);
// Draw the column, but interrupt it when we get a notification.
let watcher = async {
loop {
listener.as_mut().await;
text.set_text(counter.get());
}
};
watcher.or(column.draw(state)).await
};
// Combine all of these into one future and then `await` it.
renderer.or(plus_click).or(minus_click).await
}
Let’s go over the disadvantages now. First of all, it’s somewhat unwieldy. There’s a lot more code needed to get widgets into place. Some of it is unintuitive; especially splitting up event handlers into different futures. Since the state is shared between multiple concurrent tasks, interior mutability is all but necessary. Not to mention, what’s that Event
doing there?
However, in doing this we’ve exposed something very powerful: the user gets to choose their own event delivery mechanism. That’s where the power is.
Roll Your Own Event Delivery
There’s no central update state like their is in [Druid], nor an update callback like there is in Elm-inspired models, nor any kind of tree for delivering events. The closest thing is [React], but Instead of using the framework’s event notification mechanism, you build your own event notification mechanism.
You see, my main problem with existing frameworks is that event handling is treated like second-class data. To handle events, you pass in some kind of hook to the framework to update. For instance, in web environments, you pass in a closure to the onclick
function, and then it’s called once something is clicked. While that works for a lot of cases, it’s always felt a little second-rate to me. If you treat events as what they actually are— things that are waiting to happen— then you can do a lot more with them.
Note that I haven’t actually put pen to paper and written the API yet; this is all still theoretical. However, in this theoretical space, there are a handful of advantages to this model.
Easy Components
Notice above that, using nothing but an async
function, we created a very simple, self-contained component. If you imagine that our Widget
trait is implemented over async fn(&GuiState) -> WidgetReturnValue
, we can see that a Widget
can be created out of thin air using nothing but a closure and an async
block.
I can imagine a pattern where parameters and async
primitives are passed into a widget like so:
let parameter = 5;
let notification = Event::new();
let my_widget = move |state| async move {
button(format!("Click me! {parameter}")).draw(state)
};
I’ve yet to explore the possibilities yet, but I can imagine that this would be a very powerful pattern.
Ecosystem Integration
By using async
tools, we get the entire async
ecosystem out of the box. Without lifting a finger, async
would let us take advantage of all of the executors, channels, locks and other tools that crates like tokio
and smol
have to offer.
This would serve as a highly efficient way of handling events. tokio
’s executor is already designed to easily handle message passing futures, and that’s basically what a GUI system is.
Another goal would be to integrate business logic directly into your presentation logic. If you have a networking app, you already have crates like hyper
that are designed to work with async
code. This means that you could knead the networking code directly into your GUI code, without having to worry about the two stepping on each other’s toes. This might be downside depending on how you look at it; I guess we’ll just have to wait and see!
Simplicity
Since the event handling is handled mostly by the user, all we have left to do is implement display logic. This means that we can make our crates smaller. It also puts more power into the hands of the user to use the model that works for them.
There are quite a few disadvantages to this model, but I think that the potential advantages outweigh them. All in all, it’s certainly at least worth exploring.
What’s left to do?
I’ve already created async-winit
, which should serve as a decent foundation for hooking into native platforms, as well as unsend
for an easy runtime. It shouldn’t be too far from here to being able to have a crate that actually works. Famous last words, right?