cunarist / rinf

Rust for native business logic, Flutter for flexible and beautiful GUI
MIT License
1.82k stars 64 forks source link

Allow dart messages to be easily handled as bevy events #349

Closed Deep-co-de closed 1 month ago

Deep-co-de commented 2 months ago

Changes

I have added a feature bevy, and added bevy as an optional dependency for this feature. (I'm not sure if I did everything right in Cargo.toml). I also added the derive macro optional for DartSignal. This would make it possible to use ecs in rust with bevy as follows and use the events:

async fn main() {    
    let mut app = App::new()
        .add_plugins(MinimalPlugins)
        // This function is defined like mentioned here: https://github.com/bevyengine/bevy/issues/8983#issuecomment-1623432049
        .add_event_channel(SmallText::get_dart_signal_receiver())
        .add_systems(Update, handle_smalltext)
        .run()
}

fn receive_smalltext(
    mut smalltexts: EventReader<SmallText>
) {
    for smalltext in smalltexts.read() {
        println!("{}", smalltext.text);
    }
}

With this feature users would still need to manually add these two lines to the generated message files:

use bevy_ecs::event::Event;
#[derive(Event)]
pub struct SmallText {
...

I didn't know how to accomplish this automatically with prost-build.

I'm not sure if this option is in your favour at all. I thought it would make these two great projects wonderfully combinable and open up new possibilities.

temeddix commented 2 months ago

Hi @Deep-co-de , thanks for the idea :)

I'm not sure yet that Bevy, as a game engine, will benefit from being used together with Rinf. Perhaps it's because of my little knowledge about Bevy.

Could you elaborate how Rinf will be used in combination with Bevy? Would Rinf be showing the output from Bevy on the screen? I wonder if Bevy doesn't have its own GUI solution.

Deep-co-de commented 2 months ago

Thank you very much for your answer. Yes, bevy is a game engine, but I wanted to use bevy's ecs for state management. That's why I only used the bevy_ecs crate. One advantage of entity component systems is the modularity, so different concerns can be easily separated and developed independently. This makes it easy to implement clean architecture by using one system for each concern in bevy. This can then be exchanged at a later date without affecting the others. I had imagined that stateful widgets could be partially replaced by stateless widgets, as the state is influenced by messages (-> StreamBuilder). This would make the structure more of a reactive one. I hope I was able to make my idea clear, please feel free to ask if you have any questions. I don't have that much time next week, but I could program a small example application to illustrate this.

temeddix commented 2 months ago

I see. I think this bevy feature can make it into Rinf. I don't think a small app would be needed, but may I ask you for some example code snippets here? I (naively) assume that Bevy is event-driven, but am still curious about how it is used in async tokio environment.

Deep-co-de commented 2 months ago

Please find below a few code snippets:

lib.rs

mod flutter_events;
...
async fn main() {    
    let mut app = App::new()
        .add_plugins(MinimalPlugins)
        // This function is defined like mentioned here: https://github.com/bevyengine/bevy/issues/8983#issuecomment-1623432049
        .add_event_channel(SmallText::get_dart_signal_receiver())
        .add_systems(Update, handle_smalltext)

        // for purpose see below
        .add_systems(Update, (
            initialize_database_connection
                .run_if(in_state(MyAppState::LoadingScreen)),
           ))
        .run()
}

fn receive_smalltext(
    mut smalltexts: EventReader<SmallText>
) {
    for smalltext in smalltexts.read() {
        println!("{}", smalltext.text);
    }
}

flutter_events.rs

...
#[derive(Resource, Deref, DerefMut)]
struct ChannelReceiver<T>(Mutex<UnboundedReceiver<DartSignal<T>>>);
...
// This method is used to add a dart signa as event for usage in bevy
fn add_event_channel<T: Event>(&mut self, receiver: UnboundedReceiver<DartSignal<T>>) -> &mut Self {
        assert!(
            !self.world.contains_resource::<ChannelReceiver<T>>(),
            "this event channel is already initialized",
        );

        self.add_event::<T>();
        self.insert_resource(ChannelReceiver(Mutex::new(receiver)));
        println!("ChannelReceiver added");
        self.add_systems(PreUpdate,
            channel_to_event::<T>
                .after(event_update_system::<T>),
        );
        self
    }
...

somewhere in rust: (global state, or even for each page)

#[derive(States, Default, Debug, Clone, PartialEq, Eq, Hash)]
enum MyAppState {
    LoadingScreen,
    #[default]
    SimplePage,
    SettingsPage,
}

main.dart

...
StreamBuilder<GlobalState>(
          stream: GlobalState.rustSignalStream,
          builder: (BuildContext context, AsyncSnapshot<GlobalState> snapshot) {            
              if (snapshot.hasData) {
                // e.g. GlobalState is a message from rust code
                // with field state representing percentage of 
                // initialized backend procedures
                if (snapshot.data.state != 1.0) {
                  return CircularProgressIndicator(value: snapshot.data.state);
                } else {
                  return Text("Finished Loading");
                }
              } else {
                return CircularProgressIndicator();
              }

          },
        )
...

There would be a system in rust that would be loaded at startup, which could then repeatedly increase the value of the CircularprogressIndicator: rust:

fn setup_tasks() {
    GlobalState{state: 0.1}.send_signal_to_dart();
    ...
    GlobalState{state: 0.2}.send_signal_to_dart();
}

I have just realised that if the generated rust code of the messages were not just #[derive(Event)] but #[derive(Resource)], then you could save the messages on the rust side in the state. But here I don't know much about the generation of the automatic code (if you should want custom derives as a second feature from the dart side at all)

temeddix commented 2 months ago

Thanks for the details, I think I now understand how things work(maybe).

I did come up with one concern: Rinf spawns its own async multithreaded tokio runtime. It looks like Bevy has its own event loop(or runtime). As a consequence there will be tokio threads from Rinf(which is doing nothing), alongside Bevy threads. Wouldn't this be inefficient? Since each of the thread has to make its stack inside memory.

Maybe we can discuss a little further about the combination of tokio system and Bevy system

Deep-co-de commented 2 months ago

I see. I would try one more thing, after looking at the generated code again and the output of rinf::write_interface!();, it should be possible to implement the functions pub extern ‘C’ fn start_rust_logic_extern() { and . .stop.. without tokio threads but with bevy, whereby type SignalHandlers = OnceLock<Mutex<HashMap<i32, Box<dyn Fn(Vec<u8>, Vec<u8>) + Send>>>>; would then be saved as a resource in bevy. This would then require App::new(), but would itself have to be saved as OnceLock<>. So that when using the feature bevy rinf::write_interface!(); generates slightly different code. I will try again and post the snippet here. Nevertheless, tokio would be used, only managed by bevy.

temeddix commented 2 months ago

Yeah, rinf::write_interface! is the point.

FYI, the codebase will undergo some refactoring for one or more weeks, so I recommend diving in a bit later :)

Deep-co-de commented 2 months ago

All right, thanks for the advice!

Deep-co-de commented 2 months ago

I just wanted to ask how far you've got with refactoring the code?

temeddix commented 2 months ago

The big changes are almost done, but there would be a bit more tweaking about building the tokio runtime and spawning the main function.

I would say it will take a week at most :)

temeddix commented 1 month ago

Also, could you add the following information in the documentation/docs/configuration.md docs?

Deep-co-de commented 1 month ago

I now have to make a small adjustment to enable the exit of the app without Ctrl+C. Since bevy runs in a loop that is not interrupted by stop_rust_logic_extern(), if bevy is configured in a kind of EventLoop. For this, the following would have to exist in a location accessible for both native/hub/src/lib.rs and rinf/rust_crate/src/interface_os.rs: pub static ASYNC_WORLD: OnceLock<AsyncWorld> = OnceLock::new();. I would have placed it in interface.rs, as it would also be necessary for the target web, as far as I know. But I wonder to what extent stop_rust_logic_extern() is/will be implemented for the web, or whether it would then only be implemented for the feature bevy.

Deep-co-de commented 1 month ago

It would look like this:

#[no_mangle]
pub extern "C" fn stop_rust_logic_extern() {
    #[cfg(feature = "bevy")]
    {
        match crate::ASYNC_WORLD.get() {
            Some(world) => {
                use bevy_app::AppExit;
                futures::executor::block_on(async {world.send_event(AppExit::default()).await;});
            },
            None => {}
        }
    }
    let sender_lock = SHUTDOWN_SENDER.get_or_init(move || ThreadLocal::new(|| RefCell::new(None)));
    let sender_option = sender_lock.with(|cell| cell.take());
    if let Some(shutdown_sender) = sender_option {
        // Dropping the sender tells the tokio runtime to stop running.
        // Also, it blocks the main thread until
        // it gets the report that tokio shutdown is dropped.
        drop(shutdown_sender);
    }
}
temeddix commented 1 month ago

stop_rust_logic_extern() does nothing on the web, because it's not possible to detect closing of browser tabs.

However, you can utilize the shutdown logic of Rinf to properly exit ASYNC_WORLD, which is why I don't think the additional changes in stop_rust_logic_extern is needed.

If you take a look at this docs section above, you can understand how to properly run your finalization logic(as well as shutting down the whole tokio runtime before closing the app. )

Also, starting from Rinf 6.13 the default tokio runtime is single-threaded by default, so it wouldn't be too inefficient even if you have a separate 'Bevy world' threads now.

Deep-co-de commented 1 month ago

I have found a solution that does not require any further adjustments. You can find a small example that has not yet been tidied up here: mwp Basically, bevy is in a loop that keeps synchronising with the main thread and is thus also terminated during shutdown, which was not the case before.

let mut app = App::new();
// do setup stuff
loop {
    // run app
    app.update();
    std::thread::sleep(Duration::from_millis(100)); // or slower for less performance critical apps
    tokio::task::yield_now().await;
}

I think everything should work this way, I would code a few examples next anyway, but would consider this feature finished.

temeddix commented 1 month ago

Thank you very much for your contribution :)

Deep-co-de commented 1 month ago

You're welcome