Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions godot-core/src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use std::sync::atomic::{AtomicBool, Ordering};

use godot_ffi as sys;
use sys::Global;
use sys::GodotFfi;

use crate::builtin::{GString, StringName};
Expand Down Expand Up @@ -79,6 +80,15 @@ pub unsafe fn __gdext_load_library<E: ExtensionLibrary>(
crate::private::set_gdext_hook(move || std::thread::current().id() == main_thread);
}

// Write the extension default icon path in a `Global`.
// Will be provided to Godot during class registration (if not empty) and no class icon is provided.
// Empty by default. Resets on deinitialization, see `gdext_on_level_deinit()`.
#[cfg(since_api = "4.4")]
{
let mut icon = DEFAULT_ICON.lock();
*icon = E::default_icon();
}
Comment on lines +83 to +90
Copy link
Member

Choose a reason for hiding this comment

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

Might be simpler to exclude the whole fallback logic from this PR and not try to achieve too many things at once. Icon fallbacks can easily be retrofitted later.


// Currently no way to express failure; could be exposed to E if necessary.
// No early exit, unclear if Godot still requires output parameters to be set.
let success = true;
Expand Down Expand Up @@ -117,6 +127,8 @@ pub unsafe fn __gdext_load_library<E: ExtensionLibrary>(
is_success.unwrap_or(0)
}

pub(crate) static DEFAULT_ICON: Global<&'static str> = Global::new(|| "");

static LEVEL_SERVERS_CORE_LOADED: AtomicBool = AtomicBool::new(false);

unsafe extern "C" fn ffi_initialize_layer<E: ExtensionLibrary>(
Expand Down Expand Up @@ -240,6 +252,9 @@ fn gdext_on_level_deinit(level: InitLevel) {
crate::meta::cleanup();
}

// Reset the extension default icon to be empty again.
*crate::init::DEFAULT_ICON.lock() = "";

// SAFETY: called after all other logic, so no concurrent access.
// TODO: multithreading must make sure other threads are joined/stopped here.
unsafe {
Expand Down Expand Up @@ -332,6 +347,14 @@ pub unsafe trait ExtensionLibrary {
InitLevel::Scene
}

/// Default icon resource path for classes that don't specify one.
///
/// This is used as a fallback when a class doesn't provide an icon, for example `#[class(icon = "...")]`.
#[cfg(since_api = "4.4")]
fn default_icon() -> &'static str {
"" // Default implementation: no icon.
}

/// Custom logic when a certain initialization stage is loaded.
///
/// This will be invoked for stages >= [`Self::min_level()`], in ascending order. Use `if` or `match` to hook to specific stages.
Expand Down
7 changes: 7 additions & 0 deletions godot-core/src/obj/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ where
Self::class_id()
}

/// Class icon resource path. Will use this icon if available.
///
/// You can also set an icon via `#[class(icon = "path/to/icon.svg")]`.
fn icon() -> &'static str {
""
}
Comment on lines +47 to +52
Copy link
Member

@Bromeon Bromeon Nov 19, 2025

Choose a reason for hiding this comment

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

Not 100% sure if this should be in the GodotClass trait... there are quite a few registration properties that aren't currently exposed like this (e.g. #[class(internal)]). For builder APIs in the future, it could otherwise be a separate step:

builder.class::<MyClass>()
    .icon("path/to/icon.svg")
    .internal()
    .build()

But if we decide GodotClass is the right place -- which needs further discussion -- then:

  1. This should document how the path has to look, i.e.

    • relative to where
    • whether it should start with res:// or not
    • maybe an example
  2. Could be named editor_icon. Since trait methods can be called unqualified, and user-defined classes having their own icon isn't that unlikely.


/// Initialization level, during which this class should be initialized with Godot.
///
/// The default is a good choice in most cases; override only if you have very specific initialization requirements.
Expand Down
32 changes: 32 additions & 0 deletions godot-core/src/registry/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ fn global_dyn_traits_by_typeid() -> GlobalGuard<'static, HashMap<any::TypeId, Ve
/// Besides the name, this type holds information relevant for the deregistration of the class.
pub struct LoadedClass {
name: ClassId,

// Class icon needs to be retained for registered for class lifetime, this is not accessed directly.
Copy link
Member

Choose a reason for hiding this comment

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

Grammar? Not clear what you mean.

#[cfg(since_api = "4.4")]
#[allow(unused)]
icon: crate::builtin::GString,

is_editor_plugin: bool,
}

Expand Down Expand Up @@ -97,6 +103,8 @@ struct ClassRegistrationInfo {
/// Godot low-level class creation parameters.
godot_params: GodotCreationInfo,

icon_path: crate::builtin::GString,

#[allow(dead_code)] // Currently unused; may be useful for diagnostics in the future.
init_level: InitLevel,
is_editor_plugin: bool,
Expand Down Expand Up @@ -149,6 +157,7 @@ pub(crate) fn register_class<
out!("Manually register class {}", std::any::type_name::<T>());

let godot_params = GodotCreationInfo {
icon_path: ptr::null(),
to_string_func: Some(callbacks::to_string::<T>),
notification_func: Some(callbacks::on_notification::<T>),
reference_func: Some(callbacks::reference::<T>),
Expand All @@ -167,6 +176,7 @@ pub(crate) fn register_class<

register_class_raw(ClassRegistrationInfo {
class_name: T::class_id(),
icon_path: crate::builtin::GString::from(T::icon()),
parent_class_name: Some(T::Base::class_id()),
register_methods_constants_fn: None,
register_properties_fn: None,
Expand Down Expand Up @@ -258,8 +268,10 @@ fn register_classes_and_dyn_traits(

let loaded_class = LoadedClass {
name: class_name,
icon: info.icon_path.clone(),
is_editor_plugin: info.is_editor_plugin,
};

let metadata = ClassMetadata {};

// Transpose Class->Trait relations to Trait->Class relations.
Expand Down Expand Up @@ -426,6 +438,7 @@ fn fill_class_info(item: PluginItem, c: &mut ClassRegistrationInfo) {
is_instantiable,
reference_fn,
unreference_fn,
icon,
}) => {
c.parent_class_name = Some(base_class_name);
c.default_virtual_fn = default_get_virtual_fn;
Expand Down Expand Up @@ -466,6 +479,24 @@ fn fill_class_info(item: PluginItem, c: &mut ClassRegistrationInfo) {
c.godot_params.is_runtime =
sys::conv::bool_to_sys(crate::private::is_class_runtime(is_tool));
}

#[cfg(before_api = "4.4")]
let _ = icon; // mark used
#[cfg(since_api = "4.4")]
{
let chosen = if !icon.is_empty() {
icon
} else {
// Default icon from `ExtensionLibrary::default_class_icon()`.
*crate::init::DEFAULT_ICON.lock()
};
Comment on lines +487 to +492
Copy link
Member

Choose a reason for hiding this comment

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

When you have if/else with the condition being a ! negation, it's usually easier to understand if you swap the branches and invert the condition.


// It's possible that there's no icon.
if !chosen.is_empty() {
c.icon_path = crate::builtin::GString::from(chosen);
c.godot_params.icon_path = c.icon_path.string_sys();
Comment on lines +496 to +497
Copy link
Member

Choose a reason for hiding this comment

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

string_sys() creates a raw pointer to that field. If you move c, you will have use-after-free UB.

It's admittedly hard to see if you are not familiar with this; maybe we should rethink this API (just requiring unsafe doesn't solve the problem).

}
}
}

PluginItem::InherentImpl(InherentImpl {
Expand Down Expand Up @@ -664,6 +695,7 @@ fn lock_or_panic<T>(global: &'static Global<T>, ctx: &str) -> GlobalGuard<'stati
fn default_registration_info(class_name: ClassId) -> ClassRegistrationInfo {
ClassRegistrationInfo {
class_name,
icon_path: crate::builtin::GString::new(),
parent_class_name: None,
register_methods_constants_fn: None,
register_properties_fn: None,
Expand Down
10 changes: 10 additions & 0 deletions godot-core/src/registry/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ pub struct Struct {

/// Whether the class has a default constructor.
pub(crate) is_instantiable: bool,

/// Icon path from `#[class(icon = EXPR)]`.
pub(crate) icon: &'static str,
}

impl Struct {
Expand All @@ -214,6 +217,7 @@ impl Struct {
is_editor_plugin: false,
is_internal: false,
is_instantiable: false,
icon: T::icon(),
// While Godot doesn't do anything with these callbacks for non-RefCounted classes, we can avoid instantiating them in Rust.
reference_fn: refcounted.then_some(callbacks::reference::<T>),
unreference_fn: refcounted.then_some(callbacks::unreference::<T>),
Expand Down Expand Up @@ -252,6 +256,12 @@ impl Struct {
self
}

#[cfg(since_api = "4.4")]
pub fn with_icon(mut self, icon: &'static str) -> Self {
self.icon = icon;
self
}

pub fn with_editor_plugin(mut self) -> Self {
self.is_editor_plugin = true;
self
Expand Down
30 changes: 22 additions & 8 deletions godot-macros/src/class/derive_godot_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult<TokenStream> {
let mut fields = parse_fields(named_fields, struct_cfg.init_strategy)?;

if struct_cfg.is_editor_plugin() {
modifiers.push(quote! { with_editor_plugin })
modifiers.push(quote! { with_editor_plugin() })
}

let mut deprecations = std::mem::take(&mut struct_cfg.deprecations);
Expand All @@ -58,7 +58,7 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult<TokenStream> {
let class_name_allocation = quote! { ClassId::__alloc_next_unicode(#class_name_str) };

if struct_cfg.is_internal {
modifiers.push(quote! { with_internal })
modifiers.push(quote! { with_internal() })
}
let base_ty = &struct_cfg.base_ty;
let prv = quote! { ::godot::private };
Expand Down Expand Up @@ -113,7 +113,7 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult<TokenStream> {
match struct_cfg.init_strategy {
InitStrategy::Generated => {
godot_init_impl = make_godot_init_impl(class_name, &fields);
modifiers.push(quote! { with_generated::<#class_name> });
modifiers.push(quote! { with_generated::<#class_name>() });
}
InitStrategy::UserDefined => {
let fn_name = format_ident!("class_{}_must_have_an_init_method", class_name);
Expand All @@ -131,19 +131,24 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult<TokenStream> {

// Workaround for https://github.com/godot-rust/gdext/issues/874 before Godot 4.5.
#[cfg(before_api = "4.5")]
modifiers.push(quote! { with_generated_no_default::<#class_name> });
modifiers.push(quote! { with_generated_no_default::<#class_name>() });
}
};
if is_instantiable {
modifiers.push(quote! { with_instantiable });
modifiers.push(quote! { with_instantiable() });
}

if has_default_virtual {
modifiers.push(quote! { with_default_get_virtual_fn::<#class_name> });
modifiers.push(quote! { with_default_get_virtual_fn::<#class_name>() });
}

if struct_cfg.is_tool {
modifiers.push(quote! { with_tool })
modifiers.push(quote! { with_tool() })
}

#[cfg(since_api = "4.4")]
if let Some(icon) = &struct_cfg.icon {
modifiers.push(quote! { with_icon(#icon) })
}

// Declares a "funcs collection" struct that, for holds a constant for each #[func].
Expand Down Expand Up @@ -198,8 +203,9 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult<TokenStream> {
#struct_docs_registration
::godot::sys::plugin_add!(#prv::__GODOT_PLUGIN_REGISTRY; #prv::ClassPlugin::new::<#class_name>(
#prv::PluginItem::Struct(
#prv::Struct::new::<#class_name>()#(.#modifiers())*
#prv::Struct::new::<#class_name>()#(.#modifiers)*
)

));

#prv::class_macros::#inherits_macro_ident!(#class_name);
Expand Down Expand Up @@ -303,6 +309,7 @@ struct ClassAttributes {
is_tool: bool,
is_internal: bool,
rename: Option<Ident>,
icon: Option<TokenStream>,
deprecations: Vec<TokenStream>,
}

Expand Down Expand Up @@ -510,6 +517,7 @@ fn parse_struct_attributes(class: &venial::Struct) -> ParseResult<ClassAttribute
let mut is_tool = false;
let mut is_internal = false;
let mut rename: Option<Ident> = None;
let mut icon: Option<TokenStream> = None;
let mut deprecations = vec![];

// #[class] attribute on struct
Expand Down Expand Up @@ -542,6 +550,11 @@ fn parse_struct_attributes(class: &venial::Struct) -> ParseResult<ClassAttribute
// #[class(rename = NewName)]
rename = parser.handle_ident("rename")?;

// #[class(icon = "PATH")]
if let Some(expr) = parser.handle_expr("icon")? {
icon = Some(expr);
}

// #[class(internal)]
// Named "internal" following Godot terminology: https://github.com/godotengine/godot-cpp/blob/master/include/godot_cpp/core/class_db.hpp#L327
if parser.handle_alone("internal")? {
Expand Down Expand Up @@ -583,6 +596,7 @@ fn parse_struct_attributes(class: &venial::Struct) -> ParseResult<ClassAttribute
is_tool,
is_internal,
rename,
icon,
deprecations,
})
}
Expand Down
11 changes: 11 additions & 0 deletions godot-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,17 @@ use crate::util::{bail, ident, KvParser};
/// Even though this class is a `Node` and it has an init function, it still won't show up in the editor as a node you can add to a scene
/// because we have added a `hidden` key to the class. This will also prevent it from showing up in documentation.
///
/// ## Class Icon
///
/// You can set a class icon by providing a valid resource path using `#[class(icon = EXPR)]`.
///
/// ```
/// # use godot::prelude::*;
/// #[derive(GodotClass)]
/// #[class(base=Node, init, icon = "res://icon.svg")]
/// pub struct Foo {}
/// ```
///
/// # Further field customization
///
/// ## Fine-grained inference hints
Expand Down
34 changes: 34 additions & 0 deletions itest/rust/src/register_tests/icon_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (c) godot-rust; Bromeon and contributors.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

use godot::prelude::*;

use crate::framework::itest;

const ICON: &str = "res://icons/icon.svg";
#[derive(GodotClass)]
#[class(init, base=RefCounted, icon = ICON)]
struct ClassWithIconRefCounted {
base: Base<RefCounted>,
}

#[derive(GodotClass)]
#[class(init, base=Node, tool, icon = ICON)]
struct ClassWithIconNode {
base: Base<Node>,
}
Comment on lines +13 to +23
Copy link
Member

Choose a reason for hiding this comment

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

Is there any notable difference in the icon logic for Node and RefCounted?

If not, it should be enough to just test with RefCounted (to ensure it works also for non-nodes).


#[itest]
fn class_icon_registers() {
let instance1 = ClassWithIconRefCounted::new_gd();
let instance2 = ClassWithIconNode::new_alloc();

assert!(instance1.is_instance_valid());
assert!(instance2.is_instance_valid());

instance2.free();
}
4 changes: 4 additions & 0 deletions itest/rust/src/register_tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ mod multiple_impl_blocks_test;
mod naming_tests;
mod option_ffi_test;
mod register_docs_test;

#[cfg(since_api = "4.4")]
mod icon_test;

#[cfg(feature = "codegen-full")]
mod rpc_test;
mod var_test;
Expand Down
Loading