Events as streams in rust, with gtk-rs

4 minutes read. Published:

Introduction

Gtk is a GUI toolkit built around OOP abtractions. Using Gtk with rust is a bit akward at first, but thanks to the great gtk-rs bindings, it's not too hard.

You can find the source code here: https://github.com/ranfdev/ev-stream-gtk-rs

Callbacks

Callbacks are usually handled in this way:

button.connect_clicked(|_| println!("Hey, the button got clicked!"))

Async functions

Writing a networked applications often means writing an application using async functions. Rust has decent support for async functions, but since it's a low level language, many things must be managed manually. If we combine this with the fact that Gtk is OOP-oriented, you can see how everything get's hard really quickly.

Data used inside an async function must be borrowed indefinetly ('static), since the async function may be executed now, in 10 minutes or never.

Example:

let data = String::from("stuff");
let data_clone = data.clone();
button.connect_clicked(move |_target| {
  let long_lived_data = data_clone.clone();
  glib::MainContext::default().spawn_local(async move {
    do_async_stuff(long_lived_data).await;
  })
});

There are macros to reduce the clone and spawn boilerplate. In the end we get:

let data = String::from("stuff");
button.connect_clicked(clone!(@strong data => move |_target| {
  spawn!(clone!(@strong data => async move {
    do_async_stuff(data).await;
  }))
}));

It's not "great" but it's bearable. The situations becomes worse when there are multiple things to clone, the spawned future needs to be cancelled, the callback needs to be removed... Can we simplify at least Something?

Rust streams

Rust has really great support for iterators. It's one of the primary abstractions of the language. When working with async data, we don't use iterators, but streams. The futures crate has a lot of methods to work with streams, making streams seem to be a first-class language feature.

This means Stream is a common abstraction and functions built around it are reusable in different contexts.

Events as streams

We could process events as streams of async data: every time a user clicks a button, another chunks of data describing the event comes into the stream.

I've created a macro to do exactly this.

Example:

let my_stream = ev_stream!(button, clicked, |target|);

Advantages

Streams can have inner state

Instead of having mutable shared state, streams can have some inner state. Skipping shared state means less cloning => more performance and better ergonomics.

Let's say I want to print the number of times a button gets clicked:

let click_stream = ev_stream!(button, clicked, |target|);
let fut = click_stream
  .zip(0..)
  .for_each(|(_, n)| println!("Clicked {} times!", n));

You can also keep some data between one event and the next, using fold. I use this to keep a request in the background, until a new event comes. When the next event comes, the previous request gets dropped and cancelled (automatically).

let search_changed = ev_stream!(search_text_box, search_changed, |target|);
let fut = click_stream
  .fold(None::<RemoteHandle<()>>, |state, target| {
    fetch_data(target.text())
  });

Multiple streams can be combined:

// Basic:
let stream1 = /*...*/;
let stream2 = /*...*/;
let stream3 = /*...*/;
let big_stream = stream1.chain(stream2).chain(stream3);


// Print "Hi" one time. Then print "Hi" again after every click.
let click_stream = /*as before*/;
let click_stream_tokenized = click_stream.map(|_| ())
let fut = future::once(async {()})
  .chain(click_stream_tokenized)
  .for_each(|_| println!("Hi"));

The last example in a real context may become: "Load data once, then load next data when bottom_reached event is fired" (I use this in my app).

They do automatic cleanup

The callback gets disconnected from the target widget when the stream is dropped.

They require a single async ctx.

It's true that streams must be awaited for them to work. But you can await multiple streams in a single async ctx, without the need to create an async ctx for each callback!

spawn!(async move {
  join!(button_clicked, bottom_reached)
});