Skip to content

Add #[export_tool_button]#1499

Merged
Bromeon merged 2 commits intogodot-rust:masterfrom
Yarwin:feature/export-tool-button
Feb 13, 2026
Merged

Add #[export_tool_button]#1499
Bromeon merged 2 commits intogodot-rust:masterfrom
Yarwin:feature/export-tool-button

Conversation

@Yarwin
Copy link
Copy Markdown
Contributor

@Yarwin Yarwin commented Feb 8, 2026

What problem does it PR solve?

Adds #[export_tool_button] – ability to create clickable button in the editor inspector to do things.

ExportToolButton is a Var; to be more precise - a PhantomVar<Callable>.

#[derive(GodotClass)]
#[class(init, tool, base = Node)]
struct MyClass {
    // Custom name and icon can be provided – check the docs for more details.
    #[export_tool_button(fn = Self::my_fn, icon = "2DNodes", name = "My Tool Button Name")]
    // A convenience wrapper for PhantomVar<Callable> - improves readability. 
    my_fn: ExportToolButton,

    // Closures are fair game, albeit I'm not a fan (they do clutter the implementation).
    #[export_tool_button(fn = |this| godot_print!("wow!"))]
    my_tool_button: ExportToolButton,

    // Works very nicely with generics, allowing to expose some shared functionality.
    #[export_tool_button(fn = generic_fn)]
    my_generic_tool_button: ExportToolButton,

    // `PhantomVar<Callable>` can be used directly:
    #[export_tool_button(fn = generic_fn)]
    my_phantom_button: PhantomVar<Callable>,

    base: Base<Node>,
}


fn generic_fn<T: GodotClass<Base=Node> + WithBaseField>(this: &mut T) {
    let mut node = Node::new_alloc();
    this.base_mut().add_child(&node);
    let owner = this.base().get_tree().unwrap().get_edited_scene_root().unwrap();
    node.set_owner(&owner);
}

#[godot_api]
impl MyClass {
    fn my_fn(&mut self) {}
}
Old description

What problem does it PR solve?

Adds #[export_tool_button] – ability to create clickable button in the editor inspector to do things.

I've been using export tool button a lot recently – they are nice and fast way to expose some functionality, without bothering with editor plugin and whatnot, especially when combined with validate_property.

For example I have exposed tool buttons to spawn win conditions - there can be only one win condition per level, so given tool button checks all the children of the levels, remove old win condition, spawn new one, moves it to the top (so it is the very first child) and manages all the connections. Neat, and implementing it took me less time than writing this paragraph (objectively rust is easier than natural language though. I wish people would talk C irl 😔)!

example - with init! image
..
    #[var(
        usage_flags = [EDITOR],
        hint = TOOL_BUTTON,
        hint_string = "Win after achieving given score."
    )]
    win_at_score: Callable,

    #[var(
        usage_flags = [EDITOR],
        hint = TOOL_BUTTON,
        hint_string = "Spawn exit after achieving given score."
    )]
    spawn_exit: Callable,
..

    fn init(base: Base<Self::Base>) -> Self {
        let that = base.to_init_gd();
        let win_at_score =
            Self::create_win_condition_callable(&that, "WinAtScore", &["WinAtScore", "SpawnExit"]);
        let spawn_exit =
            Self::create_win_condition_callable(&that, "SpawnExit", &["WinAtScore", "SpawnExit"]);
        Self {
            bounds: Default::default(),
            current_score: Default::default(),
            level_stats: Default::default(),
            spawn_exit,
            win_at_score,
            base,
        }
    }

I have two strategies of creating callables for tool buttons - either in init (if init is "trivial") or in POSTINIT/Enter tree:

example - with on_notification
    ...
    #[export_group(name = "Triggers")]
    /// Creates new spawner.
    #[var(
        usage_flags = [EDITOR],
        hint = TOOL_BUTTON,
        hint_string = "Create new spawner"
    )]
    #[init(val = Callable::invalid())]
    create_spawner: Callable,
    ..

    fn on_notification(&mut self, what: godot::classes::notify::NodeNotification) {
        match what {
            ...
            NodeNotification::ENTER_TREE | NodeNotification::EXTENSION_RELOADED => {
                if !Engine::singleton().is_editor_hint() {
                    return;
                }
                let mut obj = self.to_gd();
                self.create_spawner = Callable::from_fn("Create the spawner", move |_args| {
                    let mut spawner = BallSpawner::new_alloc();
                    obj.add_child(&spawner);

                    let owner = obj.get_tree().unwrap().get_edited_scene_root().unwrap();
                    spawner.set_owner(&owner);
                });
            }
        }

ok, that's all for user story, time for actual meat:

Exposed API and why do we need THREE FOUR ways to create tool button?!?!?!

There are four ways to create export_tool_button. Hold on, I'll explain!

They don't bloat the API at all and every single of them has some specified purpose which can't be (easily) fulfilled by others.

First of all, user can explicitly opt-out from any autogeneration and provide Callable by themselves:

#[derive(GodotClass)]
#[class(init, tool, base = Node)]
struct MyClass {
    // Pretty much like a var mentioned earlier. Doesn't autogenerate any Callable, leaving it up to the user.
    #[export_tool_button(name = "My very cool tool button", icon = "Add")]
    manual_tool_button: Callable,
    base: Base<Node>,
}


#[godot_api]
impl INode for MyClass {
    fn on_notification(&mut self, what: NodeNotification) {
        match what {
            // Postinit is good too!
            NodeNotification::ENTER_TREE | NodeNotification::EXTENSION_RELOADED => {
                self.manual_tool_button = Callable::from_fn("manual fn", |_| {
                    godot_print!("hello from manual tool button!")
                });
            }
            _ => {}
        }
    }
}

it is for fine-grained control or stuff I haven't thought of earlier.

Theoretically it can be left out, since it is identical to:

    #[var(
        usage_flags = [EDITOR],
        hint = TOOL_BUTTON,
        hint_string = "tool button name"
    )]
    #[init(val = Callable::invalid())]
    my_tool_button: Callable,

Secondly user can auto-generate export tool button to some static fn - not much surprises here. Objectively it is the least useful export_tool_button, but it is still nice for creating pop-ups, emitting signals, interacting with editor plugins etc.

    ...
    // Runs some static method.
    #[export_tool_button(fn=some_fn, icon="3D", name="run static fn")]
    run_static_fn: Callable,
    ...

fn some_fn() {
    godot_print!("hello from some fn!");
}

Then we have Callable::from_object_method. It allows to call methods on base, but has one weakness – it can't really be shared reliably across various classes/structs.

    ...
    // Ol' reliable `func`..
    #[export_tool_button(method = "my_method", name = "run method fn")]
    fn_method: Callable,
    ...

#[godot_api]
impl MyClass {
    #[func(gd_self)]
    fn my_method(this: Gd<Self>) {
        godot_print!("Hello with {this}!");
    }
}

Finally there is fn_self which is prolly the most useful – we don't care about &mut self too much, the whole shmuck is about generic programming and declaring shared functionality.

    ..
    // Run fn with &mut dyn Trait receiver
    #[export_tool_button(fn_self=dyn_fn_one, icon="2DNodes", name="Run dyn fn")]
    dyn_fn1: Callable,

    // Run fn with &mut dyn Trait2 receiver
    #[export_tool_button(fn_self=dyn_fn_two, icon="2DNodes", name="Run dyn fn 2")]
    dyn_fn2: Callable,

    // Run Self::method
    #[export_tool_button(fn_self=Self::my_fn, icon="Blend", name="run Self::fn")]
    fn_myself: Callable,

    // Run fn<T:...>(me: &mut T) {...}
    #[export_tool_button(fn_self=generic_fn, icon="Blend", name="run generic fn")]
    fn_generic: Callable,
    ..

impl ExampleTrait for MyClass {}

impl ExampleTrait2 for MyClass {}

fn dyn_fn_one(_this: &mut dyn ExampleTrait) {
    godot_print!("Hello from ExampleTrait fn!");
}

fn dyn_fn_two(_this: &mut dyn ExampleTrait2) {
    godot_print!("Hello from ExampleTrait2 fn!");
}

fn generic_fn<T>(something: &mut T)
where
    T: GodotClass<Base = Node> + Inherits<Node> + WithBaseField
{
    let mut node = Node::new_alloc();
    something.base_mut().add_child(&node);
    let owner = something.base().get_tree().unwrap().get_edited_scene_root().unwrap();
    node.set_owner(&owner);
}

impl MyClass {
    fn my_fn(&mut self) {
        godot_print!("hello from my fn!");
    }
}
All of them together
#[derive(GodotClass)]
#[class(init, tool, base = Node)]
struct MyClass {
    // A var. Doesn't autogenerate any Callable, leaving it up to the user.
    #[export_tool_button(name = "My very cool tool button", icon = "Add")]
    manual_tool_button: Callable,

    // Runs some static method.
    #[export_tool_button(fn=some_fn, icon="3D", name="run static fn")]
    run_static_fn: Callable,

    // Run fn with &mut dyn Trait receiver
    #[export_tool_button(fn_self=dyn_fn_one, icon="2DNodes", name="Run dyn fn")]
    dyn_fn1: Callable,

    // Run fn with &mut dyn Trait2 receiver
    #[export_tool_button(fn_self=dyn_fn_two, icon="2DNodes", name="Run dyn fn 2")]
    dyn_fn2: Callable,

    // Run Self::method
    #[export_tool_button(fn_self=Self::my_fn, icon="Blend", name="run Self::fn")]
    fn_myself: Callable,

    // Run fn<T:...>(me: &mut T) {...}
    #[export_tool_button(fn_self=generic_fn, icon="Blend", name="run generic fn")]
    fn_generic: Callable,

    // Ol' reliable `func`.
    #[export_tool_button(method = "my_method", name = "run method fn")]
    fn_method: Callable,

    base: Base<Node>,
}

fn some_fn() {
    godot_print!("hello from some fn!");
}

fn dyn_fn_one(_this: &mut dyn ExampleTrait) {
    godot_print!("Hello from other fn!");
}

fn dyn_fn_two(_this: &mut dyn ExampleTrait2) {
    godot_print!("Hello from other fn2!");
}

pub trait ExampleTrait2 {}

pub trait ExampleTrait {}

impl ExampleTrait for MyClass {}

impl ExampleTrait2 for MyClass {}

fn generic_fn<T>(something: &mut T)
where
    T: GodotClass<Base = Node> + Inherits<Node> + WithBaseField
{
    let mut node = Node::new_alloc();
    something.base_mut().add_child(&node);
    let owner = something.base().get_tree().unwrap().get_edited_scene_root().unwrap();
    node.set_owner(&owner);
}


#[godot_api]
impl MyClass {
    fn my_fn(&mut self) {
        godot_print!("hello from my fn!");
    }

    #[func(gd_self)]
    fn my_method(this: Gd<Self>) {
        godot_print!("Hello with {this}!");
    }
}

#[godot_api]
impl INode for MyClass {
    fn on_notification(&mut self, what: NodeNotification) {
        match what {
            NodeNotification::ENTER_TREE | NodeNotification::EXTENSION_RELOADED => {
                self.manual_tool_button = Callable::from_fn("manual fn", |_| {
                    godot_print!("hello from manual tool button!")
                });
            }
            _ => {}
        }
    }
}
image

Implementation details or whatever

First of all – KISS. I started from a little too overcomplicated solution and trimmed it down to a little hacky albeit nice and reliable one.

Despite its name export_tool_button is a Var, not an Export.

I didn't want to mess with init due to postinit problems, and plugging-in to on_notification was hard, so I resorted to brute albeit nice solution – I check if Callable is valid in getter, and if it isn't I'm just generating a new one. IMO it is fairly clear and easy to maintain.

It makes getter &mut self (I might rename it to generate or fetch callable later 🤔) but it ain't big deal IMO.

Soundness - export tool button Callables are being recreated upon hot reload, so they will always be sound. One can somehow store reference to old Callable, clone it, share it with everything etc. but it isn't something I've seen, it is not a flow one would expect from this feature, and in such a case they can resort to #[export_tool_button(method="..", ..)].

I also wouldn't get too attached to export tool button in current form, since it is broken for GDScript: godotengine/godot#97834 BUT NOT FOR US 😁 and might be subject of change.

@Yarwin Yarwin added feature Adds functionality to the library c: register Register classes, functions and other symbols to GDScript labels Feb 8, 2026
@Yarwin
Copy link
Copy Markdown
Contributor Author

Yarwin commented Feb 8, 2026

Oh right, TOOL_BUTTON hint wasn't available before Godot 4.4. I'll fix it in a moment

@Bromeon
Copy link
Copy Markdown
Member

Bromeon commented Feb 8, 2026

Thanks a lot for adding this! 🚀

Regarding the API, I think it's important that it's consistent with other attributes that defer to functions. At the moment, this would mostly be getters and setters for properties:

#[var(set = my_setter)]
field: i64,

Thus some comments in that regard:

  1. Functions should not be in quotes.

  2. Why do we need to support global functions? Like for set/get, the button action is very likely closely tied to the class itself, and I'd expect most users to write a method. I'd vote on keeping the API minimal at first, and expand only when necessary.

  3. Similarly, I don't quite see the point to support both method and fn_self. This puts the user in front of a choice that may not be relevant.

    • For example, with signals, we added connect_self() which needs to connect to a Rust method. There's no direct way to go through Godot roundtrips, and no one ever complained about this missing. With a tiny bit of extra code it's also possible if really needed.
    • Did you consider moving some of this stuff to the type system, and proc-macro just generating a constructor call (see also next point)?
  4. At first glance, it's not quite obvious how the attribute and the Callable field interact:

    • Why postinit/enter_tree and not ready like OnReady, OnEditor etc?
    • Is there something "special" about #[export_tool_button]'s fn to initialize the field -- or is it something that #[init] could in theory also do? (Not saying we should)
    • Did you consider having a dedicated type ToolButton or so, similar to OnReady -- or maybe even combine the two?
    • Does the Callable need to occupy space as a field, or is it registered in fire&forget fashion, and the field could then become a ZST like PhantomVar?

Sorry if this means that some changes are required, but to avoid this, it would be necessary to discuss the design before implementation 🙂 (although I agree that certain things may only come up during implementation, and a PoC can be a good base)

@Yarwin Yarwin force-pushed the feature/export-tool-button branch 2 times, most recently from 6006669 to 33964b5 Compare February 8, 2026 14:34
- Second, more refined implementation which uses PhantomVar<Callable> instead of directly exposing&keeping the Callable.
@Yarwin Yarwin force-pushed the feature/export-tool-button branch from 33964b5 to 983e517 Compare February 8, 2026 14:35
@Yarwin
Copy link
Copy Markdown
Contributor Author

Yarwin commented Feb 8, 2026

Does the Callable need to occupy space as a field, or is it registered in fire&forget fashion, and the field could then become a ZST like PhantomVar?

Oh yeah, this whole Callable shenanigans turned out to be ultra silly - PhantomVar<Callable> gets a job done very nicely. Thanks!!
I retained only fn_self (now as fn = ...) which invalidates other questions, I think 🤔.

Rewriting everything took me only a while, so not that much time has been lost.

Copy link
Copy Markdown
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! I'll try to have another look at the API before v0.5 release, but for now I'd say it looks good to be merged, also to avoid divergence with other bigger changes 🙂

@Bromeon Bromeon added this pull request to the merge queue Feb 13, 2026
Merged via the queue into godot-rust:master with commit 3ca9b98 Feb 13, 2026
23 checks passed
@Bromeon Bromeon changed the title Add #[export_tool_button]. Add #[export_tool_button] Feb 14, 2026
@Bromeon Bromeon added this to the 0.5 milestone Feb 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c: register Register classes, functions and other symbols to GDScript feature Adds functionality to the library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants