Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Portable Callback objects #10582

Closed
viridia opened this issue Nov 16, 2023 · 6 comments
Closed

Portable Callback objects #10582

viridia opened this issue Nov 16, 2023 · 6 comments
Labels
A-ECS Entities, components, systems, and events A-UI Graphical user interfaces, styles, layouts, and widgets C-Enhancement A new feature

Comments

@viridia
Copy link
Contributor

viridia commented Nov 16, 2023

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.

@viridia viridia added C-Enhancement A new feature S-Needs-Triage This issue needs to be labelled labels Nov 16, 2023
@UkoeHB
Copy link
Contributor

UkoeHB commented Nov 16, 2023

Running systems manually was recently merged. Wouldn't your Callback just need to be a wrapper around SystemId<I, O>?

@viridia
Copy link
Contributor Author

viridia commented Nov 16, 2023

Running systems manually was recently merged. Wouldn't your Callback just need to be a wrapper around SystemId<I, O>?

Yeah, that would probably work.

@nicopap nicopap added A-ECS Entities, components, systems, and events A-UI Graphical user interfaces, styles, layouts, and widgets and removed S-Needs-Triage This issue needs to be labelled labels Nov 16, 2023
@viridia
Copy link
Contributor Author

viridia commented Nov 17, 2023

@UkoeHB One problem with using registered systems is ownership and dropping. A closure used as a one-shot system can be dropped at any point, and can be wrapped in an Arc. A registered system can only be destructed with a reference to World, and you can't keep a World reference around because of lifetimes.

This means that a manually-run system has to have an "owner" that is responsible for de-registering it from the world. This defeats the purpose of Arc, which is to have a more flexible, distributed ownership. In web apps, callbacks are often threaded through multiple levels of UI hierarchy, both up and down the visual hierarchy as well as connecting to and from asynchronous data stores. This means that there is no clear ownership - something easy to do in a GC language.

Part of what I am looking for here, is the decoupling of UI widgets: being able to build widgets that can be composed and re-used in different contexts. In the current architecture, this is already true on the rendering side - you can build a sub-tree of child entities and attach them to a parent entity without the parent having any special knowledge about the nature of its children or vice versa. But that's the easy part - once you start defining the command-and-control aspects, where signals and/or events are being transmitted back and forth between entities, that loose coupling goes away, and widgets now need to have intimate knowledge of the lifetimes of the widgets they are connected to, and the lifetimes of the data being passed back and forth. This results in an insurmountable reduction in the degree of modularity and reusability.

@UkoeHB
Copy link
Contributor

UkoeHB commented Nov 17, 2023

One problem with using registered systems is ownership and dropping. A closure used as a one-shot system can be dropped at any point, and can be wrapped in an Arc. A registered system can only be destructed with a reference to World, and you can't keep a World reference around because of lifetimes.

Yep this is a problem. My solution, which I am right now in the middle of implementing, is to add a custom entity garbage collector.

  1. Store callbacks in entities (that's what Add 'World::run_system_with_input' function + allow World::run_system to get system output #10380 does).
  2. When you spawn a callback entity, create an RAII handle around the entity id and an MPSC sender (AutoDespawnSignal). The sender points at a receiver in an AutoDespawn resource.
  3. When the last clone of an AutoDespawnSignal is dropped, the entity will be sent to the AutoDespawn resource.
  4. When the AutoDespawn resource is added to an app via a plugin, it adds a system to the Last schedule that polls for despawnable entities, and then despawns them.

The inconvenience here is you need to the AutoDespawn resource around in order to create AutoDespawnSignals. I am actually using an MPMC channel, which means I can either A) clone the resource and pass it by value, B) access it directly as Res<AutoDespawn>, C) access it indirectly as a member of a custom SystemParam (this is what I will do for UI - I will just add it to my UiBuilder).

@asafigan
Copy link
Contributor

I have implemented Callbacks in my own project. It runs the system manually rather than using SystemId. I implemented it two different ways. First by using an internal Arc and Mutex, this makes it really easy to construct and clone. My second implementation was to disallow cloning but to allow sharing callbacks through Handles. For both of these implementations I added extension traits to both Commands and World to make them easy to call.

Advantages of not using SystemId:

  • Can be created without &mut World
  • Systems are automatically cleaned up when no longer referenced

Advantages of using and internal Arc and Mutex:

  • Callbacks are extremely easy to construct and clone

Advantages of using Handles:

  • No need for Mutexs
  • I believe this will work better with the future of Scenes. There is already design going into embedding Assets into Scenes and embedding Callbacks can piggy back off of that. Without Handles and Assets it won't be clear that Callbacks are shared during serialization and de serialization disallowing shared Callbacks in Scene files.

@alice-i-cecile
Copy link
Member

Closing in favor of the observer pattern in #10839 as our "reactivity" solution. We can revisit patterns here in the future as we see how those play out.

@alice-i-cecile alice-i-cecile closed this as not planned Won't fix, can't repro, duplicate, stale Mar 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events A-UI Graphical user interfaces, styles, layouts, and widgets C-Enhancement A new feature
Projects
None yet
Development

No branches or pull requests

5 participants