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:
- The
PliantDb
response comes back while noGooey
code is executing. In this situation, theRasterizer
invokes a callback that consumers can use to wake up their event loop. ForKludgine
, that means invokingawaken()
. - The
PliantDb
response comes back whileGooey
is executing anupdate()
orrender()
. In this situation, theRasterizer
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.
-
Kludgine
callsWindow::process_input
. -
App
callsRasterizer::handle_event
. - Rasterizer calls
WidgetRasterizer::mouse_up
. - Button calls
Callback::invoke
. - Counter calls
flume::Sender::send
withDatabaseCommand::Increment
. -
process_database_commands
invokesincrement_counter
which sends a request to thePliantDb
server. - Upon receiving the response (async), it calls
Context::send_command_to
to sendButtonCommand::SetLabel
to the button. - Button receives the command, updates its label, and posts the
ButtonCommand
as a transmogrifier command. - The button transmogrifier receives the command
- On WASM: calls
HtmlElement::set_inner_text
- On
Rasterizer
, callsset_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