-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Description
What problem does this solve or what need does it fill?
Building a hierarchical UI is much easier with callbacks. Having to poll the state of each individual widget makes it difficult to create components that are truly modular.
For example, suppose you have something like an audio preferences dialog with a volume slider. The slider doesn't know anything about audio, it's just a generic slider. It's possible to build such a dialog by polling the slider state directly, but that requires injecting the internal state of the slider into the parent dialog, breaking encapsulation. This gets even more difficult if the dialog itself is a generic widget, which means it can no longer depend on dependency injection, but must receive context from its own caller. If the slider can accept a callback parameter to notify its parent whenever the slider value changes, however, then its relatively straightforward to modify the state of the world in response to the call.
In older UI frameworks (JavaSwing, Gtk) this was done with events rather than callbacks. The problem with events is that the parent has to examine each event and decide which widget it game from. Modern frameworks like React/Solid/Svelte are organized around passing callbacks to each child widget, which produces code that is simpler and more modular. It has a better architectural separation of concerns.
However, passing closures around in Rust is problematic for a number of reasons. First, there is the problem of closure variable lifetimes - a button that has a .click() method is probably going to live longer than the setup function that creates it. (Move semantics can help, but often you will have several closures that all want to capture the same variable).
Second, in a complex UI, callbacks are often passed down through multiple layers of the widget hierarchy, which means that every widget now has to be generic on the type of the closures being passed through it.
Third, in a reactive framework, callbacks are often used as dependencies to other derivations, such as "computed" or "effects". But this leads to a lot of extra churn unless the callbacks are memoized. Memoization requires both equality-comparison and a way to copy the value, neither of which are supported by bare closures.
Finally, it would be nice for callbacks to participate in dependency injection the way that systems do. Dependency injection can, in some cases, substitute for closure variables in a callback. For example, in the case of the audio preferences dialog, the callback which is attached to the volume slider could get a handle to the audio volume resource by capturing it from the parent, but alternatively it could inject it directly with something like Res<AudioSettings>.
What solution would you like?
I propose some trait, Callback<In> which represents a generic callback that accepts some parameter, much like a one-shot system. However, Callback is actually a wrapper which gives us a number of advantages over one-shot systems:
- The closure variables are type-erased.
- The wrapper is both cloneable and equals-comparable.
Internally, Callbacks might be implemented as one-shot systems, or they might be implemented some other way. The actual closure is wrapped in an Arc, allowing the wrapper to be cloned without duplicating the closure.
The equals comparison can be very simple, just a pointer comparison: we don't actually care if two callbacks have the same closure values, all we care about is whether the callback was constructed via a distinct call. Two separate calls to create_callback should always compare as unequal.
Callback objects can be passed around freely as parameters to systems, widgets, event handlers and so on. For example:
pub struct SettingsProps {
on_change_volume: Callback<f32>,
}
pub fn settings_dialog(cx: Cx<SettingsProps>) {
Slider::new("Hello World").on_change(cx.props.on_change_volume, value);
}The Callback trait has one method, .call(world, args). Yes, callbacks run exclusively like one-shot systems, but so do most event handlers (look at bevy_mod_picking). There's a discussion around this somewhere, but I generally believe that it's OK to have low-frequency "command and control" code run in an exclusive system, so long as the high-frequency code, the stuff that's CPU intensive, is non-exclusive.
Memoization is outside of the scope of this proposal, as it would be handled by the third-party UI framework (or any other framework using callbacks). A framework can provide a create_callback_memo(func, deps), for example, which always returns the same object as long as deps is the same every time. Other APIs are possible, but it's up to the framework to decide how that should work.
Callbacks are meant to be synchronous - there's no delay between the time the callback is sent and the time it's received. For asynchronous communication, use other methods.
What alternative(s) have you considered?
A bunch, too many to list here.
If we jettison the idea that Callback supports dependency injection, then the implementation of Callback is simpler since it no longer requires a World to call it. However, the downside is that you now have to rely much more heavily on capturing closure variables. Dependency injection can substitute for variable capture in some cases (where the value being captured is also available as an injection) but not others (such as when the value being captured is locally scoped). Unfortunately, closure captures introduce a lot of complexity around lifetime bounds, which the person defining the callback will have to deal with.
Additional context
This idea is part of a general research project to see how well we can adapt ideas of reactivity in an ECS world. One of the tensions is that UIs are inherently hierarchical, not just in structure but in execution scope, and ECS architectures tend to atomize hierarchies and flatten everything. The idea of callbacks is to try and bridge those two paradigms.
Calling a Callback requires a World, because otherwise dependency injection doesn't work. (You can't store a World inside the callback object because of lifetime issues). My assumption is that most uses of callbacks will have a world available at the point where it's called, such as an event handler. For callbacks which call other callbacks (a fairly common case in UI code, often widgets elevate the level of abstraction when forwarding events), the callback will need to inject a World.
I know that a lot of folks will object to the fact that Callbacks only make sense in an exclusive context. However, many kinds of "command and control" logic only make sense in an exclusive context. UI hierarchies are often quite deep, with multiple layers of widgets, and it's not uncommon in UI code for a message originating at the bottom of the stack to proceed upward in multiple "hops", with each hop transforming the message to a higher-level, more abstract form. It would be unfortunate if each hop incurred a one-frame delay.