Basic `Gooey` and `PliantDb` Example Development

Today, I started working on a simple example for Gooey and PliantDb. It’s very much a work in progress, and it was a lot more tricky than I had anticipated getting everything working. I want to talk about the challenges I faced and then offer up for review the solutions I picked today.

The first challenge was in PliantDb. The goal of the CustomApi was that the Request and Response types could be shared in a platform-neutral crate. Unfortunately, by putting Backend in pliantdb-core, that was impossible— simple oversight.

This allows the examples shared/lib.rs to be the Request, Response, and ExampleApi enums.

The server is pretty much straight out of all of the existing pliantdb examples.

The client is where all the remaining interesting projects happened.

Async Integration

PliantDb's client uses an async interface, and when running natively, it needs a tokio runtime. Kludgine supports tokio or smol currently, and Gooey doesn’t use async at all. Yet, as I tried to make async easy in this example, I couldn’t figure out how to do it.

I did find a cool project: async_executors. It makes it easier to initialize a cross-platform async environment using some traits. Unfortunately, there are some limitations, but the biggest issue I had is that the code was still too complex for my liking. The only way to make it easier was to add a very minimalistic spawn interface to App. These functions are only available if the async feature is enabled.

With it, the example’s initialization was made very straightforward: client/src/main.rs:23-36.

The WASM version is yet to be implemented but should be straightforward. The limitation on WASM support is that PliantDb needs a WASM client.

Communicating from another thread/task efficiently

@dAxpeDDa and I believe that using flume to communicate between sync and async code is a joy. As such, I use it in this example to communicate between the Gooey event handling code and an async task that is waiting for messages (client/src/main.rs:79-82):

let _ = component
     .behavior
     .command_sender
     .send(DatabaseCommand::Increment);

On the receiving end, though, what does that async task use to communicate back to Gooey? I decided the Context type was the best candidate and implemented Clone for it. I also added send_command_to() allowing the code to look like this (client/src/main.rs:111-125):

async fn increment_counter(client: &Client<ExampleApi>, context: &DatabaseContext) {
    match client.send_api_request(Request::IncrementCounter).await {
        Ok(response) => {
            let Response::CounterIncremented(count) = response;
            context.context.send_command_to::<Button>(
                &context.widget_id,
                ButtonCommand::SetLabel(count.to_string()),
            );
        }
        Err(err) => { /* ... */ }
    }
}

But how does that work? That’s the fun that consumed the remainder of my day. For WASM, this will be straightforward as long as you’re not using multithreading in the browser. Behind the scenes, the frontend could immediately process widget messages because you’re known to be in a single-threaded environment. Thus, Gooey can’t be in the middle of doing anything else.

But, on the Rasterizer level, things are a lot more complex. The Gooey window is implemented by Kludgine. Kludgine's app implementation has each window use a thread for its own event loop. The main winit thread dispatches events to each window’s thread.

After this change today, Kludgine now offers two ways to control the window’s event loop. You can implement two methods on the Window trait that are useful for this situation: update() and render().

Window::update() is always called before Window::render(). However, after update() is called, if the current RedrawStatus is not ready to redraw, Window::render() will not execute unless the OS has explicitly requested a redraw.

This took a lot longer to iron out than I had expected, but I’m pleased with the way Kludgine is operating now.

Updating the Kludgine App implementation required adding support to the Rasterizer frontend to be able to handle multiple possible situations:

  1. The PliantDb response comes back while no Gooey code is executing. In this situation, the Rasterizer invokes a callback that consumers can use to wake up their event loop. For Kludgine, that means invoking awaken().
  2. The PliantDb response comes back while Gooey is executing an update() or render(). In this situation, the Rasterizer will not do anything extra – those code paths will process widget messages already.

That may sound trivial, but here’s the actual breakdown of channel messages being sent to make this work, starting with the MouseUp event.

  1. Kludgine calls Window::process_input.
  2. App calls Rasterizer::handle_event.
  3. Rasterizer calls WidgetRasterizer::mouse_up.
  4. Button calls Callback::invoke.
  5. Counter calls flume::Sender::send with DatabaseCommand::Increment.
  6. process_database_commands invokes increment_counter which sends a request to the PliantDb server.
  7. Upon receiving the response (async), it calls Context::send_command_to to send ButtonCommand::SetLabel to the button.
  8. Button receives the command, updates its label, and posts the ButtonCommand as a transmogrifier command.
  9. The button transmogrifier receives the command
  • On WASM: calls HtmlElement::set_inner_text
  • On Rasterizer, calls set_needs_redraw on the frontend.

What do you think?

Check out the client example here and leave any thoughts, suggestions, or questions below!

I’m just glad I’m done debugging those event flows. As I commented in Discord earlier, my example reached triple digits of clicks before I finished debugging it all :slight_smile: