viridia / quill_v1

Reactive UI framework for Bevy game engine
MIT License
60 stars 7 forks source link

Synchronous event handling #11

Open viridia opened 9 months ago

viridia commented 9 months ago

Currently Quill uses bevy_mod_picking and bevy_eventlistener for handling events. However, there are some issues.

First, bevy_eventlistener broadcasts events concurrently; there's no guarantee that the event handler will run on the same thread as the presenter that set up the handler. This means that any closure captures shared between the presenter and its event handlers has to be Send + Sync.

Also, bevy_eventlistener can sometimes deliver different event types out of order - for example, when you start a drag, you can get Drag events before you get the DragStart event. Most widgets that listen to multiple events are state machines that depend on the events arriving in the correct order. Working around the out-of-order issue makes widget design a lot more complicated.

There's no real reason that event handling needs to be multi-threaded - event handlers in real-world UI code are almost invariably little functions that set a small handful of state variables and then trigger a re-render.

In Xilem, events are handled differently, and are dispatched via the View. That is, when a View is constructed, a .message() method defines an event callback. This callback is called on the previous view before constructing the new view.

In Quill, we would do this by adding an EventReader to the global system that constructs Views. This means that events would be handled in the same thread as the presenters (although not the same call, since messages are processed on the output of the previous call.)

Events would be guaranteed to be in order as well.

Xilem's event structure also has fewer event types: "Pointer" events are a single enum rather than being separate trait implementations. This means fewer event handler registrations.

One thing to be cautious of is that we currently rely on bevy_eventlistener's dependency injection for message handlers: that is, handlers are treated like one-shot systems and can inject resources and queries. This is critical because it's really cumbersome to share resources (or anything acquired from Cx) into even handlers without a lot of extra boilerplate, so having the ability for handlers to access resources independently is very handy. We don't want to lose this feature.

Finally, Xilem approaches local state management in a way that is very different from Quill: instead of declaring local state via a hook or injection, the message handlers return a new local state for the widget: Action+OldState => NewState. This is something that might be worth experimenting with at some point.

viridia commented 8 months ago

Some further details about how Xilem dispatches events:

Because Xilem Views are not addressable (they are nested tuples, not pointers to allocations), we can't use Arc or any other means to hold a reference to a specific view. Instead, Xilem generates a unique id for every View (although, most of those ids can be thrown away). The id is an array of integers, for example [1, 1, 7], which can be interpreted as a path to the given view as tuple indices.

Thus, when a message handler is declared, any messages dispatched to that handler will have the view id; and given that id, we can start from the root view and quickly locate the handler closure to be run by following the tuple indices - presumably the View type would have a method that accepts an event and a target id, which would recursively call its children.

However, we don't necessarily have to do things this way. It might be preferable to store a Bevy SystemId in the view's State.

The basic requirements for event dispatch are: