Skip to content

Signal API improvements #1152

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

Houtamelo
Copy link
Contributor

Rustc has a unfortunate limitation where it struggles with type inference when dealing with indirect implementations of the Fn family traits.

Currently, the Signal API uses such an indirection through the trait SignalReceiver, and this causes compiler fails in case users don't specify the explicit types of receiver parameters on closures passed to connect_** methods.

This PR fixes that by replacing the SignalReceiver trait with direct usage of FnMut(**).

Example of how this affects user code:

let mut my_node = MyNode::new_alloc();

// Before this PR
my_node.signals().ready().connect_self(|this: &mut MyNode| { }); // compiler error if `&mut Node` isn't specified

// After this PR
my_node.signals().ready().connect_self(|this| { }); // compiles just fine, type is inferred as `&mut MyNode`

As a side bonus, I also included versions of connect_** that accept native engine classes as receivers instead of user classes.

@GodotRust
Copy link

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-1152

@Yarwin
Copy link
Contributor

Yarwin commented May 2, 2025

Nice!
Looks good to me – albeit it would be good to end most comments with a dot (currently it is missing in many places).

One question – could we use connect_gd and method_gd (for builder) which would accept any Gd<T> instead of connect_engine?

The implementation itself is easy (remove bounds Bounds<Declarer = bounds::DeclEngine> on given methods in typed_signal.rs, connect_builder.rs) and I don't see any problems at first glance.

The only issue is the abundance of methods that allow to do the very same thing (connect with any closure, connect_obj for functions with other object that has Self as a receiver, connect_gd for functions with Gd receiver) 🤔 – maybe it would be good to rename connect_obj to connect_other (counterpart to connect_self) if we proceed with connect_gd.

@Bromeon Bromeon added quality-of-life No new functionality, but improves ergonomics/internals c: core Core components labels May 2, 2025
Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot! Great observation with the type inference 👍


This PR fixes that by replacing the SignalReceiver trait with direct usage of FnMut(**).

It's really unfortunate that this means reverting the tuple trait back to macros 😕
Macros are also the approach we started with in the generated engine APIs, but over time we could generalize a lot using tuples, thus moving more logic to the Rust type system and make it a bit easier to reason/debug.

I understand why macros are needed, but this has some minor implications:

  • The code is slightly harder to work with (IDEs don't always treat macro code first-class, e.g. regarding static analysis, formatting, syntax highlighting).
  • It's no longer possible for users to do generic programming based on SignalReceiver trait. I wonder if it's still possible to abstract over Ps (e.g. via ParamTuple trait) if a user wishes so?
  • We now generate 10x the amount of code. It's probably minuscule but might have a tiny impact on parse times. On the other hand, there's less trait solving going on, so it might compensate.

What I wonder is: would it be possible to have just the "frontend" as macros, so the signatures can take FnMut directly -- but then they would delegate logic to actual generic code? Might cause even more complexity though 🤔
(Don't start changing anything here just yet, just wondering about the options...)


As a side bonus, I also included versions of connect_** that accept native engine classes as receivers instead of user classes.

Like I wrote in Discord, I'd prefer this to be only in the builder for now and keep the top-level API minimal. But I'm still not fully convinced if this functionality is needed at all, it seems to provide only minor benefits over the already existing connect(), see my code comment.

If anything, we should consider adding this part in a separate PR (which can be done backwards-compatibly, unlike the other changes here). Or possibly discuss in an issue first, get overall community input and see which usage patterns are most burning. Background is that I really don't want to block 0.3 release further, and this change comes already at the last second 🙂

Comment on lines 387 to 374
let mut user = Emitter::new_alloc();
let mut engine = Node::new_alloc();

user.signals()
.child_entered_tree()
.connect_engine(&engine, |this, mut child| {
// Use methods that `Node` declares
let _ = this.get_path(); // ref
this.set_unique_name_in_owner(true); // mut

// `child` is also a node
let _ = child.get_path(); // ref
child.set_unique_name_in_owner(true); // mut
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my understanding, where do you see the main advantage over connect() here?

Wouldn't the following work (if engine is no longer used -- otherwise with an extra clone)?

Suggested change
let mut user = Emitter::new_alloc();
let mut engine = Node::new_alloc();
user.signals()
.child_entered_tree()
.connect_engine(&engine, |this, mut child| {
// Use methods that `Node` declares
let _ = this.get_path(); // ref
this.set_unique_name_in_owner(true); // mut
// `child` is also a node
let _ = child.get_path(); // ref
child.set_unique_name_in_owner(true); // mut
});
let mut user = Emitter::new_alloc();
let mut engine = Node::new_alloc();
user.signals()
.child_entered_tree()
.connect(move |mut child| {
// Use methods that `Node` declares
let _ = engine.get_path(); // ref
engine.set_unique_name_in_owner(true); // mut
// `child` is also a node
let _ = child.get_path(); // ref
child.set_unique_name_in_owner(true); // mut
});

Copy link
Member

@Bromeon Bromeon May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this gives minor ergonomic benefits when a single object is used (no need to copy or have move closure), but you'd still need that pattern if more than 1 Gd needs to be accessed within the closure.

In comparison, connect_obj exists because it does the heavy lifting of rebinding the object so that the user can safely access it, especially when self.signals().xy().emit() is called. This isn't easily possible with other existing methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a minor advantage, but Gd-Rust already has enough boilerplate, and each line removed counts towards reducing it.

Moving engine Gds into the closures is easy, but if you want to reuse said GD elsewhere, you need to add a line where you explicitly create a new variable, cloning the instance you want to reuse:

let user_class = Emitter::new_alloc();
let mut some_node = Node::new_alloc();

let node_clone = some_node.clone(); // extra line
user_class.signals().ready().connect(move || {
    node_clone.do_something();
});

// keep using `some_node`

Compare connecting to a signal in GDScript/C# to Rust:

var bound_obj = ...
emitter.signal_name.connect(func(): do_something(bound_obj))
var bound_obj = ...;
emitter.signal_name += () => do_something(bound_obj);
let source_obj = ...;
let bound_obj = source_obj.clone();
emitter.signals().signal_name().connect(move || { do_something(bound_obj) });

So in my opinion, any boilerplate reduction is welcome.


The bellow is not directly related to the issue above, consider it as food for thought:

The most common use case in my experience is connecting to a signal that's on a exported field of your class, look at how many extra lines this takes:

#[derive(GodotClass)]
#[class(base = Control, init)]
pub struct SettingsUI {
    base: Base<Control>,
    #[init(node = "close_menu_button")]
    close_menu_button: OnReady<Gd<BaseButton>>,
}

#[godot_api]
impl IControl for SettingsUI {
    fn ready(&mut self) {
        self.close_menu_button
            .clone()                      // needed due to borrow checker, since `signals` takes `&mut self`, even though internally it doesn't really need a mutable reference
            .signals()                   // extra call other languages don't have
            .pressed()
            .connect_builder()   // extra call
            .object_self(self)      // extra call
            .method_mut(|this| this.base_mut().hide())
            .flags(ConnectFlags::DEFERRED) // connecting deferred is almost always preferable, so I just do it by default. 
            .done();                      // extra call
    }
}

In my opinion, the API should be (and we can!) changed to allow the following:

#[godot_api]
impl IControl for SettingsUI {
    fn ready(&mut self) {
        self.close_menu_button
            .signals()         // we could get rid of this call, but for now, it's fine.
            .pressed()       // make this take `&self` instead of `&mut self`: no borrow checker issues, functionality remains the same.
            .connect_other_deferred(self, |this| this.base_mut().hide());
    }

We get users having issues with double bind almost every day, which are easily solved by
connecting deferred. I believe that should be incentivized and facilitated.

Copy link
Member

@Bromeon Bromeon May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed elaboration!

Agree on unnecessary clone, and we can possibly collapse object_self + method_mut in the builder as well. The ToSignalObj trait abstracts over &mut WithUserSignals and Gd<T> and should (hopefully) be able to provide some groundwork here.

I don't agree with deferred, at least not right now.

  • First, I think we should see if we can establish patterns that make borrow errors less frequent. emit() for example is carefully crafted so it can be re-entrant on the same object.
  • Deferred isn't the solution to everything. It introduces asynchronous execution, and sometimes you want effects to be applied immediately, because later logic in the same frame depends on it.
  • The approach of promoting everything to the top level still causes combinatoric explosion, even if used frequently. Some people use one-shot signals a lot. For some it's important to have cross-thread signals due to their architecture.

Not saying we can never do this, but these are very clearly QoL features that can possibly be added later, after more careful evaluation.


Rather than supporting every use case in the shortest possible syntax, we should make the builder modular, so that people can write their own abstractions on top. Imagine you could write extension traits like this:

#[godot_api]
impl IControl for SettingsUI {
    fn ready(&mut self) {
        self.close_menu_button
            .signals() 
            .pressed() // ... extension trait on TypedSignal
            .my_custom_connect(|this| this.base_mut().hide());
    }

but this might be harder with the macro-based approach now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred isn't the solution to everything. It introduces asynchronous execution, and sometimes you want effects to be applied immediately, because later logic in the same frame depends on it.

I believe it is for 90% of cases, which has been (anecdotally) proven by the help requests we get on discord.
For the cases were a user wants to apply something immediately, they can just not connect with the Deferred flag and handle the consequences on their own.

When communication needs to happen immediately, there's a good chance that you're better off communicating directly in Rust rather than going through the Godot layer (again, in my experience).

Rather than supporting every use case in the shortest possible syntax

I don't think we should ever support every use case in the shortest possible syntax, my argument is that connect_deferred is more common than a regular connect, and thus it should be supported.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it is for 90% of cases, which has been (anecdotally) proven by the help requests we get on discord.

With the same argument, you could justify call_deferred everywhere because people constantly run into borrow issues with Gd::bind/bind_mut. And then it turns out that most of them can be solved by proper use of base/base_mut. And we can probably do better in conveying these concepts.

Deferred signals as a result of borrow errors are a workaround because of a Rust-specific problem, they are not the default in Godot itself. While I don't think we can 100% solve every case, I still believe more effort should be put into promoting safe usage of non-deferred signals, both with API design and documentation.

@Bromeon
Copy link
Member

Bromeon commented May 2, 2025

The only issue is the abundance of methods that allow to do the very same thing (connect with any closure, connect_obj for functions with other object that has Self as a receiver, connect_gd for functions with Gd receiver) 🤔 – maybe it would be good to rename connect_obj to connect_other (counterpart to connect_self) if we proceed with connect_gd.

Yes -- we need to keep in mind that the typed signals API is a completely new addition for users. While it seems simple, there are still tons of little nuances (type inference, signal visibility, borrow semantics, thread safety, ...). I don't think we're doing users a favor by adding yet another slightly different method, there's just too much to ingest at once.

I'm not opposed to extending the API in the future, but we should probably wait some time and see which usage patterns emerge. There are also things like disconnection which are still missing and more imporant, as they can't be emulated on user side. Once we have a crates.io release, we'll likely receive more user feedback as well.

@Houtamelo
Copy link
Contributor Author

Nice! Looks good to me – albeit it would be good to end most comments with a dot (currently it is missing in many places).

Fixed.

One question – could we use connect_gd and method_gd (for builder) which would accept any Gd<T> instead of connect_engine?

I provided a solution on discord (which I already tested and confirmed that works), which relies on using this trait for parameter resolving:

#[allow(clippy::needless_lifetimes)] // False positive.
pub trait GodotDeref<Decl>: GodotClass {
    type TargetRef<'a>;
    type TargetMut<'a>;
    
    fn get_ref<'a>(gd: &'a Gd<Self>) -> Self::TargetRef<'a>;
    fn get_mut<'a>(gd: &'a mut Gd<Self>) -> Self::TargetMut<'a>;
}

#[allow(clippy::needless_lifetimes)] // False positive.
impl<T: GodotClass<Declarer = DeclEngine>> GodotDeref<DeclEngine> for T {
    type TargetRef<'a> = &'a Gd<T>;
    type TargetMut<'a> = &'a mut Gd<T>;
    
    fn get_ref<'a>(gd: &'a Gd<Self>) -> Self::TargetRef<'a> { gd }
    fn get_mut<'a>(gd: &'a mut Gd<Self>) -> Self::TargetMut<'a> { gd }
}

#[allow(clippy::needless_lifetimes)] // False positive.
impl<T: WithBaseField> GodotDeref<DeclUser> for T {
    type TargetRef<'a> = GdRef<'a, T>;
    type TargetMut<'a> = GdMut<'a, T>;
    
    fn get_ref<'a>(gd: &'a Gd<Self>) -> Self::TargetRef<'a> { gd.bind() }
    fn get_mut<'a>(gd: &'a mut Gd<Self>) -> Self::TargetMut<'a> { gd.bind_mut() }
}

The implementation itself is easy (remove bounds Bounds<Declarer = bounds::DeclEngine> on given methods in typed_signal.rs, connect_builder.rs) and I don't see any problems at first glance.

It's not that simple, currently we have different behavior for User vs Engine classes, engine classes are provided as parameters "as is", while user classes need to go through bind_mut.

The only issue is the abundance of methods that allow to do the very same thing (connect with any closure, connect_obj for functions with other object that has Self as a receiver, connect_gd for functions with Gd receiver) 🤔 – maybe it would be good to rename connect_obj to connect_other (counterpart to connect_self) if we proceed with connect_gd.

That would be fixed if my GodotDeref solution is accepted, we could just have connect_self and connect_other, nothing more.

@Houtamelo
Copy link
Contributor Author

* The code is slightly harder to work with (IDEs don't always treat macro code first-class, e.g. regarding static analysis, formatting, syntax highlighting).

RustRover had no issues whatsoever, IDEs generally love declarative macros.

* It's no longer possible for users to do generic programming based on `SignalReceiver` trait. I wonder if it's still possible to abstract over `Ps` (e.g. via [`ParamTuple`](https://godot-rust.github.io/docs/gdext/master/godot/meta/trait.ParamTuple.html) trait) if a user wishes so?

I don't think it's worth the hassle, do we have any report of users even wanting such a feature? In the worst case scenario they can just use untyped signals.

* We now generate 10x the amount of code. It's probably minuscule but might have a tiny impact on parse times. On the other hand, there's less trait solving going on, so it might compensate.

In my experience (and based on my journey through the internals of rustc), trait solving takes magnitudes of times longer than parsing. Also, the output of declarative macros is cached and re-used, trait-solving isn't.

What I wonder is: would it be possible to have just the "frontend" as macros, so the signatures can take FnMut directly -- but then they would delegate logic to actual generic code? Might cause even more complexity though 🤔 (Don't start changing anything here just yet, just wondering about the options...)

I tried this, but we end up with the same problem, just internally.

Like I wrote in Discord, I'd prefer this to be only in the builder for now and keep the top-level API minimal. But I'm still not fully convinced if this functionality is needed at all, it seems to provide only minor benefits over the already existing connect(), see my code comment.

If anything, we should consider adding this part in a separate PR (which can be done backwards-compatibly, unlike the other changes here). Or possibly discuss in an issue first, get overall community input and see which usage patterns are most burning. Background is that I really don't want to block 0.3 release further, and this change comes already at the last second 🙂

As mentioned in my answer to yarvin, this issue can be fixed by the GodotDeref solution, which unifies all these signatures.

@Bromeon
Copy link
Member

Bromeon commented May 2, 2025

Thanks, that looks very interesting, will look into the GodotDeref later.

Regarding macros vs. traits:

I don't think it's worth the hassle, do we have any report of users even wanting such a feature? In the worst case scenario they can just use untyped signals.

My main worry is that it becomes harder to extend the signal API because you can no longer use generic programming. See also my comment here.

Is this not the case?

@Houtamelo
Copy link
Contributor Author

Houtamelo commented May 2, 2025

Thanks, that looks very interesting, will look into the GodotDeref later.

Regarding macros vs. traits:

I don't think it's worth the hassle, do we have any report of users even wanting such a feature? In the worst case scenario they can just use untyped signals.

My main worry is that it becomes harder to extend the signal API because you can no longer use generic programming. See also my comment here.

Is this not the case?

I don't see how that makes things harder, it feels like planning for something we don't know that will actually be a problem. (And here I don't even understand what the problem is)

Edit: I see what you mean now.

#[godot_api]
impl IControl for SettingsUI {
    fn ready(&mut self) {
        self.close_menu_button
            .signals() 
            .pressed() // ... extension trait on TypedSignal
            .my_custom_connect(|this| this.base_mut().hide());
    }

but this might be harder with the macro-based approach now.

That might seen true, since previously you could just implement a generic trait over Receiver<_, _, Ps>, which I did here: https://github.com/Houtamelo/houtamelo_utils_gdext/blob/main/src/connect_deferred.rs.

But you can still do it, just differently: make the extension return a ConnectBuilder instead, since it "doesn't care" about the closure signature:

self.close_menu_button
           .signals() 
           .pressed()
           .builder_deferred() // extension trait on TypedSignal => just does `connect_builder().flags(ConnectFlags::DEFERRED).object_self()`
           .method_mut(|this| this.base_mut().hide()) // regular connect call on the builder
           .done();

@Houtamelo
Copy link
Contributor Author

Speaking of the builder, I do think we can make it better, in my opinion it overly uses generics right now, and we can get rid of the done call.

Here's what I suggest:

self.signals().pressed() // unchanged
    .connect_builder() // unchanged
    .flags(some_flags) // unchanged
    .connect(some_static_fn_or_closure); // ends builder
    
self.signals().pressed() // unchanged
    .connect_builder() // unchanged
    .flags(some_flags) // unchanged
    .connect_self(some_self_method_or_closure); // ends builder


self.signals().pressed() // unchanged
    .connect_builder() // unchanged
    .flags(some_flags) // unchanged
    .connect_other(&other_gd, some_other_method_or_closure); // ends builder

The idea is that we always finish building when receiving the closure. This gets rid of:

  • Generics Crcv and GodotFn
  • Fields receiver_obj and godot_fn
  • Methods object and object_self

The generics only reflect the TypedSignal's generics.

(I also suspect we can get rid of the extra lifetime parameter. )

#[must_use]
pub struct ConnectBuilder<'ts, 'c, CSig: WithSignals, Ps> {
    parent_sig: &'ts mut TypedSignal<'c, CSig, Ps>,
    data: BuilderData,
}

Then we only need two impls for the builder.

Data impl:

#[allow(clippy::needless_lifetimes)] // 'ts + 'c are used conditionally.
impl<'ts, 'c, CSig, Ps> ConnectBuilder<'ts, 'c, CSig, Ps>
where
    CSig: WithSignals,
    Ps: meta::ParamTuple,
{
    pub fn name(mut self, name: impl meta::AsArg<GString>) -> Self {
        assert!(
            self.data.callable_name.is_none(),
            "name() called twice on the same builder."
        );

        meta::arg_into_owned!(name);
        self.data.callable_name = Some(name);
        self
    }

    pub fn flags(mut self, flags: ConnectFlags) -> Self {
        assert!(
            self.data.connect_flags.is_none(),
            "flags() called twice on the same builder."
        );

        self.data.connect_flags = Some(flags);
        self
    }
}

Connect impls are still generated by the declarative macro, but we mimic TypedSignal's methods:

  • connect
  • connect_self
  • connect_other

There is a world where we even merge TypedSignal and ConnectBuilder, which would get rid of bunch of nearly-duplicated code between the two:

pub struct TypedSignal<'c, C: WithSignals, Ps> {
    object: C::__SignalObj<'c>,
    name: Cow<'static, str>,
    _signature: PhantomData<Ps>,
    data: BuilderData, // gets filled with default values upon creation
}

Which would allow you to:

self.signals()
    .pressed()
    .flags(ConnectFlags::Deferred)
    .connect_self(|this| foo(this));

@Houtamelo
Copy link
Contributor Author

I just implemented everything I suggested in my previous post, and it all works. (Check the tests for examples)

@Bromeon
Copy link
Member

Bromeon commented May 2, 2025

Very cool ideas! 🔭

That might seen true, since previously you could just implement a generic trait over Receiver<_, _, Ps>, which I did here: https://github.com/Houtamelo/houtamelo_utils_gdext/blob/main/src/connect_deferred.rs.

But you can still do it, just differently: make the extension return a ConnectBuilder instead, since it "doesn't care" about the closure signature:

self.close_menu_button
           .signals() 
           .pressed()
           .builder_deferred() // extension trait on TypedSignal => just does `connect_builder().flags(ConnectFlags::DEFERRED).object_self()`
           .method_mut(|this| this.base_mut().hide()) // regular connect call on the builder
           .done();

But that's not the same -- that limits you again to the ConnectBuilder API. Even if we reduce the above call according to your latest suggestion

self.close_menu_button
           .signals() 
           .pressed()
           .builder_deferred()
           .connect_self(|this| this.base_mut().hide()); // ends builder

it's still one more line than with direct extension

self.close_menu_button
            .signals() 
            .pressed() // ... extension trait on TypedSignal
            .my_custom_connect(|this| this.base_mut().hide());

and users cannot further customize it (such as wrap the function call, aspect-style).

The user is also required to return a ConnectBuilder<...> which is rather unwieldy with its signature (maybe a bit less so after your suggestions, but still). Worth noting that as of today, ConnectBuilder explicitly discourages writing the exact type, as it's very hard for us to add more type-states without affecting the generic/lifetime parameter list. Maybe that's a questionable choice, but then again this comes with an extensibility obstacle, for us this time.

I just want to make sure we're aware of the trade-offs here. Is the only benefit of not having the trait currently that : &mut Self can be inferred in the closure? Not to dismiss it, that's a huge improvement, but at the same time extensibility is rather important to reduce boilerplate on user side.

I guess there's always the escape of letting the user write macros -- which would also address the .signals() call you really don't like 😛 -- but I'm not sure if there isn't a better approach before jumping to macros?

@Bromeon
Copy link
Member

Bromeon commented May 2, 2025

Speaking of the builder, I do think we can make it better, in my opinion it overly uses generics right now, and we can get rid of the done call.

I just implemented everything I suggested in my previous post, and it all works. (Check the tests for examples)

You're too fast 😅 I still need to read into all the latest changes, but this sounds already awesome.
Thanks a lot for everything so far 💪

@@ -446,7 +486,7 @@ mod emitter {
#[cfg(since_api = "4.2")]
pub fn connect_signals_internal(&mut self, tracker: Rc<Cell<i64>>) {
let mut sig = self.signals().signal_int();
sig.connect_self(Self::self_receive);
sig.connect_self(|mut this, int| this.self_receive(int));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the old syntax of

sig.connect_self(Self::self_receive);

no longer possible?

Also, mut is now needed because it's GdMut<T> rather than &mut T?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Precisely

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I wonder if in that case, it wouldn't still be better to provide 2 separate methods connect_ref (for user methods and connect_gd (for any Gd<T>)... 🤔

Copy link
Member

@Bromeon Bromeon May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing, if you have

fn another_method(emitter: &mut Emitter) {...}

you have to write (with GdMut<T>):

sig.connect_self(|mut this, _int| another_method(&mut *this));

versus (with &mut T):

sig.connect_self(|this, _int| another_method(this));

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's a problem at all, and being forbidden from dropping the bind from inside functions has caused many issues for users. The main issue being with IClass trait implementations where methods take self by & or &mut (for example fn ready(&mut self)).

I'd argue that even these trait implementations should use GdMut or GdRef instead of &mut T and &T.

That is outside the scope of this PR of course.

Do you want me to get rid of GdMut and revert to &mut usage?
You wanted to keep the API slim so maybe it doesn't make sense to add a version for both.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing, if you have

fn another_method(emitter: &mut Emitter) {...}

you have to write (with GdMut<T>):

sig.connect_self(|mut this, _int| another_method(&mut *this));

versus (with &mut T):

sig.connect_self(|this, _int| another_method(this));

I could not implement GodotDeref with the associated type being the user class, the main issue is that dereferencing into it requires calling bind_mut, but the bind is dropped upon exiting the method:

#[allow(clippy::needless_lifetimes)] // False positive.
impl<T: WithBaseField> GodotDeref<DeclUser> for T {
    type GodotTarget = T;

    // The bind is dropped  upon exiting the method:
    // Rustc Error: cannot return value referencing temporary variable.  
    fn get_ref(gd: &Gd<Self>) -> &Self::GodotTarget {
        & *gd.bind()
    }
    fn get_mut(gd: &mut Gd<Self>) -> &mut Self::GodotTarget {
        &mut *gd.bind_mut()
    }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want me to get rid of GdMut and revert to &mut usage?
You wanted to keep the API slim so maybe it doesn't make sense to add a version for both.

I'd say yes, because accessing the user object for the full duration of the function is still very common. Having a direct &mut T is just the most ergonomic API in these cases, it also allows directly connecting to already existing methods via method-reference syntax.

I don't think that's a problem at all, and being forbidden from dropping the bind from inside functions has caused many issues for users.

Dropping guards early may be valid in some cases, but passing in GdMut/GdRef seems the wrong API to me: it only allows early release, but not late acquire. Having a function that passes in Gd instead of the guards would give users full flexibility. In the interest of reducing scope creep, I'd suggest we add this (alongside support for engine pointers) at a later point.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping guards early may be valid in some cases, but passing in GdMut/GdRef seems the wrong API to me: it only allows early release, but not late acquire.

I might have expressed myself poorly, this is not a user issue, it's a implementation one.
I can't make GodotDeref work (with safe code) while making get_mut return &mut T, the borrow checker won't allow. I could use unsafe and just deref the pointer returned by bind_mut.

Getting rid of GodotDeref means connect_self/connect_other methods won't accept both user and engine classes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My view here is:

  1. ✔️ &mut T parameter is good

  2. ✔️ Gd<T> parameter is good

    • necessary for engine classes
    • also helpful for user classes, to give full flexibility about if/when to bind
  3. GdMut<T> parameter is bad

    • it's strictly less powerful than Gd<T> in all cases, while still not giving the ergonomics of &mut T
    • there's also no prior art like this; #[func(gd_self)] for example changes signature to Gd as well

So I think it's better API design to focus on (1) and (2), with (2) being implemented later.


Getting rid of GodotDeref means connect_self/connect_other methods won't accept both user and engine classes.

Just a random idea, instead of your get_ref/get_mut approach, would it be possible to use something like

fn with_ref<F, R>(user_fn: F) -> R
where
    F: FnMut(&Self::GodotTarget) -> R,
{
    user_fn(&*gd.bind())
}

If not, that's a pity, but I believe supporting &mut T directly is worth it, even if it means users have to call different connect functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I managed to fix it, already pushed the new commit.

Comment on lines 51 to 56
/// - [`connect()`][Self::connect] for global functions, associated functions or closures.
/// - [`connect_self()`][Self::connect_self] for methods with `&mut self` as the first parameter.
/// - [`connect_obj()`][Self::connect_obj] for methods with any `Gd<T>` (not `self`) as the first parameter.
/// - [`connect_builder()`][Self::connect_builder] for more complex setups.
/// - [`connect_self()`][Self::connect_self] for methods with `GdMut<Self>` (equivalent to `&mut self`) as the first parameter.
/// - [`connect_other()`][Self::connect_other] for methods with any `Gd<T>` (not `self`) as the first parameter.
/// - `connect_sync` for thread-safe functions (allows signal to be called from other threads).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mixes two orthogonal dimensions (receiver, thread-safety) into one.

They're currently not fully orthogonal, since Gd<T> doesn't support cross-thread access at the moment, but once we have multi-threaded object pointers, this would again fall to combinatoric explosion here.

I know the builder isn't nice, but I found it's the only way to properly separate these dimensions and still allow flexibility. It enforced a Send bound which could then be checked against the function, no matter how that function came together.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you like me to change?

Copy link
Member

@Bromeon Bromeon May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more a general question if you see a way forward without having dedicated ConnectBuilder, even for future extensibility? This then also forces every method to be on TypedSignal itself, so we can no longer have a "easy API" and "advanced API".

While I really don't like type-states, it seemed the most scalable/flexible to me. Especially your revamped version of having connect_* at the end (instead of done) would likely be a good middle ground, or how do you see it?

(No need to change anything just now 🙂 )

Comment on lines 181 to 184
/// If not provided, the Rust type name of the function/method is used.
///
/// Only useful before using one of the `connect_**` methods.
pub fn name<'a: 'b, 'b>(&'a mut self, name: impl meta::AsArg<GString>) -> &'b mut Self {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason why this doesn't take and return by value?

I don't think we should encourage both

signal.name("hello");

let signal = signal.name("hello");

patterns; the functional approach is imo less confusing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I confused the signatures and thought that we never owned the signal type at any point, I'll fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just checked this and actually, the issue is that signal types (such as SigTreeExiting) turn into TypedSignal by dereferencing, the borrow checker doesn't allow me to move out of the dereference.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I had this very problem many times during signal implementation 🧐

Maybe it still makes sense to separate builder and signal. I agree there's some duplication, but we wouldn't need to force a builder pattern onto TypedSignal (and this is just a symptom of that, combined with the Deref hack of course...).

We could separate "basic" and "advanced" APIs and thus make people less overwhelmed when looking at the TypedSignal API. There's also no risk that we run into combinatoric explosion for anything that affects the type itself (like Send, and possibly other traits in the future), as we can just return a differently configured ConnectBuilder in each fluent method.

@Houtamelo Houtamelo force-pushed the signal_api_improvements branch 2 times, most recently from 6a08fc5 to aa62e39 Compare May 3, 2025 16:59
@Houtamelo
Copy link
Contributor Author

I implemented (most - the ones that were possible) changes requested, and squashed all previous commits together.

There are now some random CI errors, none of which seem to be related to code that I changed?

@Bromeon
Copy link
Member

Bromeon commented May 3, 2025

There are now some random CI errors, none of which seem to be related to code that I changed?

Indeed, I'm attempting to fix them in #1153.
Will rebase this PR once that's working...

@Bromeon Bromeon force-pushed the signal_api_improvements branch from aa62e39 to 6ff4ed8 Compare May 3, 2025 19:24
@Houtamelo Houtamelo force-pushed the signal_api_improvements branch from 6ff4ed8 to a442b47 Compare May 3, 2025 20:05
@Houtamelo
Copy link
Contributor Author

There are now some random CI errors, none of which seem to be related to code that I changed?

Indeed, I'm attempting to fix them in #1153. Will rebase this PR once that's working...

I think I just force-pushed over your fix

Copy link
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much for the continuous updates, this starts to look really great 👍

Some comments:

  1. Re change Gd::signals(&mut self) -> Gd::signals(&mut self):

    • I think it's OK, this is anyway not safety-relevant as you can always clone, but I wondered due to discrepancy now between Object.connect/emit_signal which are both &mut self...
    • Using &mut self here is problematic because it disallows passing in the same Gd as a capture into the closure, or was there another reason?
    • With GodotDeref supporting Gd receivers on engine classes, is this still burning? Maybe for user ones?
  2. The problem that macros are much harder to extend than a tuple trait still remains. What do we recommend to users to write their own abstractions over recurring connect patterns? Not just to call multiple builder fns at once, but e.g. wrap the provided user function in their own 🤔

  3. Also my previous concerns with sync() + possible combinatoric explosion if we add multithreaded support later. But I think your approach is more pragmatic for now, and also this can be extended if there's an actual need.

Comment on lines +11 to +49
#[allow(clippy::needless_lifetimes)] // False positive.
pub trait GodotDeref<Decl>: GodotClass {
type TargetRef<'a>: Deref<Target = Self>;
type TargetMut<'a>: DerefMut<Target = Self>;

fn get_ref<'a>(gd: &'a Gd<Self>) -> Self::TargetRef<'a>;
fn get_mut<'a>(gd: &'a mut Gd<Self>) -> Self::TargetMut<'a>;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe brief docs what it is about.

I would also name it more specific to this context, e.g. SignalObjectDeref or so...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⬆️ Could you rename the trait?

I would also name it more specific to this context, e.g. SignalObjectDeref or so...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to explain this, I didn't rename because the trait plays a very similar role to "AsObjectArg" and I was planning to merge them in the future. Renaming right now is fine though.

/// - [`method_immut`][Self::method_immut]: Connect a `&self` method.
///
/// ## Stage 3
/// # Customization
/// All these methods are optional, and they can be combined.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very nice that there are no more strict stages, but I think there are still some dependencies on the order, that should maybe be mentioned? Also that the connect_* overloads terminate the builder?

Could probably be kept much shorter than before 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⬆️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did update those docs

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, normally GitHub shows "outdated" when the diff changes. Maybe you could confirm with a " 👍 " or short comment to acknowledge next time 🙂

Comment on lines 155 to 172
// Self-modifying method.
sig.connect_builder()
.object_self()
.method_mut(Emitter::self_receive)
.done();
sig.connect_self(Emitter::self_receive);

// Connect to other object.
let receiver_mut = Receiver::new_alloc();
sig.connect_builder()
.object(&receiver_mut)
.method_mut(Receiver::receive_int_mut)
.done();

// Connect to yet another object, immutable receiver.
let receiver_immut = Receiver::new_alloc();
sig.connect_builder()
.object(&receiver_immut)
.method_immut(Receiver::receive_int)
.done();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests have existed to explicitly test the builder, could you add them back?

We should already cover the high-level methods on TypedSignal elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added them except the immut version which we don't use anymore.

Comment on lines -177 to -179
sig.connect_builder()
.function(move |i| tracker.set(i))
.done();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, this was a deliberate builder test with "associated" function.

Comment on lines -195 to -200
// Check that *other* instance method is invoked.
assert_eq!(
receiver_immut.bind().last_received(),
LastReceived::Int(552),
"Emit failed (other object, immut method)"
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition should still be verified.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We no longer have immut versions of the connect methods.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you didn't mention that you would remove them 🤔

As far as I see, ConnectBuilder::connect_other() is hardcoded to GodotDeref::get_mut():

pub fn connect_other<F, R, OtherC, Decl>(self, object: &impl ToSignalObj<OtherC>, mut method: F)
where
    F: FnMut(&mut OtherC, $($Ps),*) -> R + 'static,
    OtherC: GodotDeref<Decl>,
{
    let mut gd = object.to_signal_obj();

    let godot_fn = make_godot_fn(move |($($args,)*):($($Ps,)*)| {
        let mut target = OtherC::get_mut(&mut gd);
        let target_mut = target.deref_mut();
        method(target_mut, $($args),*);
    });

    self.inner_connect_godot_fn::<F>(godot_fn);
}

But you also added GodotDeref::get_ref(), which now seems unused?

While users can go the route of |this: &mut Self| { self.method_immut() }, this still requires an exclusive bind on the object and prevents interior mutability. So I think it would be good to offer that functionality, even if in an obscure API gated behind ConnectBuilder.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you also added GodotDeref::get_ref(), which now seems unused?
Yeah, I kept it for future usage.

While users can go the route of |this: &mut Self| { self.method_immut() }, this still requires an exclusive bind on the object and prevents interior mutability. So I think it would be good to offer that functionality, even if in an obscure API gated behind ConnectBuilder.

Understood

- `Class::signals()` now takes self by reference (`&self`) instead of `&mut self`, similar changes to the `connect_**` methods. This was introduced to avoid `clone()` boilerplate when a class wants to connect to a signal that belongs to one of their fields.
- Replace trait `SignalReceiver` with direct usage of `FnMut($(Ps,)*)`. This fixes type inference issues with Rustc.
- Standardized `connect` methods in the Signal API (both `TypedSignal` and `ConnectBuilder`), changes:
    - `connect`: (unchanged).
    - `connect_self`: now accepts both engine and user classes (by using the new trait `GodotDeref`).
    - `connect_obj`: renamed to `connect_other`, now accepts both engine and user classes (by using the new trait `GodotDeref`).
    - `ConnectBuilder::function`: renamed to `connect`.
    - `ConnectBuilder::method_mut`: split into `connect_self` and `connect_other`.
    - `ConnectBuilder::method_immut`: removed.
    - `ConnectBuilder::done`: removed, building is finalized upon calling one of the `connect_**` methods.
- Removed type-state pattern on ConnectBuilder, and trimmed down generics.
@Houtamelo Houtamelo force-pushed the signal_api_improvements branch from a442b47 to 74a11b0 Compare May 4, 2025 21:53
@Houtamelo
Copy link
Contributor Author

Houtamelo commented May 4, 2025

  • Using &mut self here is problematic because it disallows passing in the same Gd as a capture into the closure, or was there another reason?
    The main reason is my "self-declared" most common use case: connecting to a signal on a field of your class:
#[derive(GodotClass)]
struct UIClass {
    #[init(node = "Button")]
    button: OnReady<Gd<BaseButton>>,
}

#[godot_api]
impl IControl for UIClass {
    fn ready(&mut self) {
        self.button
            .signals()  // borrows self by &mut
            .pressed()
            .connect_other(&self, Self::on_button_pressed); // borrows self by  &
    }
}

If signals requires taking by &mut self, then you have to clone the signal emitter (button here) every time you do this - which is a lot of boilerplate across the entire project.


I believe I implemented all the requested changes, should be ready to merge now.

@Bromeon
Copy link
Member

Bromeon commented May 5, 2025

I believe I implemented all the requested changes, should be ready to merge now.

Thanks for the update! I think you missed some of my comments, marked them again with ⬆️.


What about this?

2. The problem that macros are much harder to extend than a tuple trait still remains. What do we recommend to users to write their own abstractions over recurring connect patterns? Not just to call multiple builder fns at once, but e.g. wrap the provided user function in their own 🤔

The problem is now also that the docs are very bloated:

image

All in all, we're enabling to write |this| instead of |this: &mut Self| which is certainly nice, but the docs are harder to read and it's not really possible to extend the builder by users anymore. While the improvement is probably worth it, we still need to be aware that we're dealing one problem for another...

@Yarwin
Copy link
Contributor

Yarwin commented May 5, 2025

The problem that macros are much harder to extend than a tuple trait still remains. What do we recommend to users to write their own abstractions over recurring connect patterns?

I would argue that this implementation makes it actually easier 🤔, albeit feels very hacky and clunky. One can change arguments into generic tuple and then forward them via decorator.

Examples:

fn decorate_with_logger<Ps>(mut receiver: impl FnMut(Ps) + 'static) -> impl FnMut(Ps) + 'static {
    #[cfg(debug_assertions)]
    {
        move |tuple| {
            godot_print!("Given signal has been called");
            receiver(tuple);
        }
    }
    #[cfg(not(debug_assertions))]
    receiver
}


fn handle_cleanup_decorator<Ps>(
    mut receiver: impl FnMut(Ps, Gd<Node>) + 'static,
) -> impl FnMut(Ps, Gd<Node>) + 'static {
    move |tuple, entity| {
        if entity.get_name() == StringName::from("oh noes!") {
            EventBus::singleton().signals().end_turn();
        } else {
            receiver(tuple, entity);
        }
    }
}

// Sample usages

#[godot_api]
impl MyClass {
    // A collection of two concrete wrappers.
    fn connect_to_button(&self, mut func: impl FnMut() + 'static) {
        let mut this_obj = self.to_gd();
        self.button.signals().pressed().connect(move || {
            godot_print!("A button has been pressed.");
            this_obj.bind_mut().something += 1;
            func();
        });
    }

    fn connect_to_signal_b(&mut self, mut func: impl FnMut(i32) + 'static) {
        self.signals().signal_b().connect(move |int, mut node| {
            godot_print!("you shall not pass!");
            node.queue_free();
            func(int);
        });
    }

    #[signal]
    fn signal_b(a: i32, b: Gd<Node>);

    #[signal]
    fn signal_c(at: Vector2, who: Gd<Node>, idx: u32);
}

        // Some complicated connection to signal with no arguments at all.
        let this_obj = self.to_gd();
        let func = move |_| {
            this_obj
                .clone()
                .signals()
                .selected()
                .emit(&this_obj.clone())
        };
        let mut func = decorate_with_logger(func);

        // Requires us to pass empty tuple as an argument. `(()))` looks awful
        self.connect_to_button(move || func(()));

        // Side effect – in this case our Ps is not generic tuple but… i32. Silly!
        let func_2 = |i| {
            godot_print!("a lone {i}");
        };
        self.connect_to_signal_b(decorate_with_logger(func_2));

        // args to tuple. Again, hard to read
        self.signals()
            .signal_b()
            .connect(|a, b| decorate_with_logger(|(a, b)| godot_print!("{a}, {b}")) ( (a, b) ) );

        // Actual usecase in which such approach can be benefical – args to tuple & concrete type
        self.signals().signal_c().connect(move |at, who, idx| {
            handle_cleanup_decorator(|(at, idx), who| {
                godot_print!("{who} has {idx} and is at {at}");
            })((at, idx), who);
        });

        let mut another_generic = decorate_with_logger(|(a, b, c)| {
            godot_print!("{a}, {b}, {c}");
        });
        self.signals()
            .signal_c()
            .connect(move |a, b, c| another_generic((a, b, c)));

Albeit it is hard to say something concrete without solid examples and usecases.

ah, and in real-world usage one would probably come out with some macro to improve the readability

@Houtamelo
Copy link
Contributor Author

I believe I implemented all the requested changes, should be ready to merge now.

Thanks for the update! I think you missed some of my comments, marked them again with ⬆️.

What about this?

  1. The problem that macros are much harder to extend than a tuple trait still remains. What do we recommend to users to write their own abstractions over recurring connect patterns? Not just to call multiple builder fns at once, but e.g. wrap the provided user function in their own 🤔

The problem is now also that the docs are very bloated:

All in all, we're enabling to write |this| instead of |this: &mut Self| which is certainly nice, but the docs are harder to read and it's not really possible to extend the builder by users anymore. While the improvement is probably worth it, we still need to be aware that we're dealing one problem for another...

We are also getting:

  • Reduced boilerplate in the builder now that we don't need to call done anymore, also cloning the signals emitter is no longer needed in some cases.
  • Some invalid state is no longer representable (e.g.: can no longer provide two closures to the builder, same for object binds).
  • connect_this & connect_other now accept engine objects, this is now possible: button.signals().pressed().connect_other(&canvas_item, CanvasItem::show).

@Houtamelo
Copy link
Contributor Author

The problem that macros are much harder to extend than a tuple trait still remains. What do we recommend to users to write their own abstractions over recurring connect patterns?

I would argue that this implementation makes it actually easier 🤔, albeit feels very hacky and clunky. One can change arguments into generic tuple and then forward them via decorator.

Examples:

fn decorate_with_logger<Ps>(mut receiver: impl FnMut(Ps) + 'static) -> impl FnMut(Ps) + 'static {
    #[cfg(debug_assertions)]
    {
        move |tuple| {
            godot_print!("Given signal has been called");
            receiver(tuple);
        }
    }
    #[cfg(not(debug_assertions))]
    receiver
}


fn handle_cleanup_decorator<Ps>(
    mut receiver: impl FnMut(Ps, Gd<Node>) + 'static,
) -> impl FnMut(Ps, Gd<Node>) + 'static {
    move |tuple, entity| {
        if entity.get_name() == StringName::from("oh noes!") {
            EventBus::singleton().signals().end_turn();
        } else {
            receiver(tuple, entity);
        }
    }
}

This doesn't work, the closures passed to connect methods don't (unless the user wants to) take tuples as parameters.
Users provide closures of this format: impl FnMut(C, A1, A2)
Not this one: impl FnMut(C, (A1, A2))

So you can't abstract over Ps, somewhere down the line you need to provide impls for each tuple size you want.

@Houtamelo
Copy link
Contributor Author

@Bromeon I won't be able to pick this up again for a indeterminate amount of time.
You may want to consider shelving this PR since you are in a hurry to release 3.0.

@Bromeon
Copy link
Member

Bromeon commented May 5, 2025

ah, and in real-world usage one would probably come out with some macro to improve the readability

Yeah, that's a bit the problem. Once you make your APIs macro-based, i.e. abstractions no longer operate at the type system level, then the user is forced to use macros as well. It's a bit as if a user would want to abstract over Vector2 and Vector2i -- they need macros (or traits written from zero), but that's something we explicitly accept.

But thanks for experimenting with functions and showing possible approaches!


We are also getting: [...]

I'm not disputing the other improvements in this PR, the points you mention have very clear benefits 👍

But as far as I understand, they're orthogonal to the fact whether we have : &mut Class type inference or not; it would technically be possible to have all those with the tuple-based API (not saying it's a good idea) 🤔


@Bromeon I won't be able to pick this up again for a indeterminate amount of time.
You may want to consider shelving this PR since you are in a hurry to release 3.0.

Thanks for the heads-up, and huge thanks for the work! I definitely plan to integrate the PR -- I might experiment with some follow-up tweaks, or at least obtain a better understanding how we could use certain patterns in the future. I'll also try to stay true to the spirit of keeping common usage as non-verbose as possible.

One last question maybe, did you consider having the macro-based approach on the TypedSignal high-level API, while using the tuple-based one for ConnectBuilder? It would mean that methods on the latter have no : &mut Self inference, but they're already slightly more verbose and we would gain back extensibility. (To be clear I'm not asking for more changes, just wondering what you think of this).

@Yarwin
Copy link
Contributor

Yarwin commented May 5, 2025

This doesn't work, the closures passed to connect methods don't (unless the user wants to) take tuples as parameters.
Users provide closures of this format: impl FnMut(C, A1, A2)
Not this one: impl FnMut(C, (A1, A2))

This is what I meant –- it works in literal sense…

        // impl FnMut(Ps) -> Ps=(P0, P1, P2)
        let mut another_generic = decorate_with_logger(|(a, b, c)| {
            godot_print!("{a}, {b}, {c}");
        });
        self.signals()
            .signal_c()
            // takes impl FnMut(P0, P1, P2)
            .connect(
                // Our closure – impl FnMut(P0, P1, P2)
                move |a, b, c| {
                    // Ps = (P0, P1, P2)
                    let our_param_tuple: (_, _, _) = (a, b, c);
                    another_generic(our_param_tuple);
                },
            );

        (...)
        // FnMut(Ps) – in our case PS = ()
        let mut func = decorate_with_logger(func);
        // impl FnMut calls FnMut(Ps) – empty tuple ()
        self.connect_to_button(move || func(()));



        self.signals()
            .signal_b()
            // takes something with impl FnMut(P0, P1)
            .connect(
                        |a, b|
                        // takes something with impl FnMut(Ps), in our case Ps = tuple (P01, P1)
                         decorate_with_logger(
                                    // good luck understanding it imminently  – `closure(Ps)` where Ps = (P0, P1)… 
                                    // but in general this whole chain is mind-bending (signal function that calls decorated closure, but for some reason it provides all the arguments as a tuple…)
                                    |(a, b)| godot_print!("{a}, {b}")) ( (a, b) )
                        );

… but is hard to read and just feels hacky – in general such abstractions would be rather clunky

(by literal sense I mean that this compiles and works)

@Houtamelo
Copy link
Contributor Author

One last question maybe, did you consider having the macro-based approach on the TypedSignal high-level API, while using the tuple-based one for ConnectBuilder? It would mean that methods on the latter have no : &mut Self inference, but they're already slightly more verbose and we would gain back extensibility. (To be clear I'm not asking for more changes, just wondering what you think of this).

I didn't, because:

  • There isn't that much customization that can be done, and we can provide the most common ones.
  • The declarative macros are small and simple, any user who is familiar enough with generics to be able to customize this would most likely also be able to write the needed macros (or simply re-use the ones we made).

@Bromeon Bromeon added this to the 0.3 milestone May 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: core Core components quality-of-life No new functionality, but improves ergonomics/internals
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants