Sunday, September 3, 2023

A Proposal for an asynchronous Rust GUI framework

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?



from Hacker News https://ift.tt/fQxdby2

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.