Skip to content

Add groups export. #1214

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

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft

Add groups export. #1214

wants to merge 6 commits into from

Conversation

Yarwin
Copy link
Contributor

@Yarwin Yarwin commented Jun 23, 2025

What does this PR solve?

Adds groups and subgroup.

How does it work?

I checked few options and finally went with a clap API (suggested by @ttencate) which requires specifying the group directly inside a #[export] attribute – everything else turned out to be too limiting, too messy or too janky 😒.

Example usage
#[derive(GodotClass)]
#[class(init, base=Node)]
struct Initializer {
    #[export(group = "g1765")]
    #[init( val = 124 )]
    pub rust_field_with_group_1: i32,

    pub rust_field_without_group_1: i32,

    #[export]
    pub export_without_group: i32,

    #[var]
    pub random_var: i64,

    #[export(subgroup = "some subgroup 2")]
    #[init( val = 444 )]
    pub export_with_subgroup3: i32,

    #[export(subgroup = "some subgroup")]
    pub export_with_subgroup: i32,
    #[export(subgroup = "some subgroup")]
    pub export_with_subgroup2: i32,

    #[export(range = (0.0, 10.0, or_greater), group = "g1765")]
    pub rust_field_with_group_7: f32,

    #[export(group = "g1", subgroup = "some subgroup")]
    pub export_with_group_and_subgroup: i32,

    #[export(group = "g2", subgroup = "some subgroup 2")]
    pub export_with_other_group_and_subgroup: i32,

    #[export(group = "g1", subgroup = "some subgroup 2")]
    pub export_with_yet_another_group_and_subgroup: i32,
    
    #[export(group = "g1765", subgroup = "some subgroup 3")]
    pub first_group_but_subgroup: i32,

    #[export(subgroup = "some subgroup 2")]
    #[init( val = 444 )]
    pub export_with_subgroup31: i32,

    base: Base<Node>,
}

image

Problems etc

Initially I wanted to ship it with struct extensions but they can be added in separate PR (I'll create the issue for that later). The leftovers of that is Fields struct being moved to separate file – I don't see anything wrong with that though, so I left it like that (not sure about named_fields function though).

I experimented with organizing fields into something along the lines of

struct Field {
    Parsed(Field),
    Group(GroupName),
    ClassExtension(ClassExtensionData)
}

but it turned out to be silly, unnecessary and unnecessary overcomplicated.

I am not really happy with GroupIdentifier (usize which simply points to some index holding group names) but it is kinda the sanest solution – idents make no sense (and use "to_string()" underneath anyway), strings require too many unnecessary allocations, *const String is… uh… let's not do that.

@Yarwin Yarwin added feature Adds functionality to the library c: register Register classes, functions and other symbols to GDScript labels Jun 23, 2025
@GodotRust
Copy link

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

- Edit registered_docs.xml – from now on `export`ed fields are being documented after `#[var]` fields.
@Yarwin
Copy link
Contributor Author

Yarwin commented Jun 23, 2025

After change #[export] fields in docs are being included/documented after #[var], export-less fields 🤔. Godot docs by itself are inconsistent in this regard and don't specify any order, so I decided to keep it (feel free to complain and whatnot).

Example:

image

@ttencate
Copy link
Contributor

Looks like the docs are just using alphabetical order?

@Yarwin
Copy link
Contributor Author

Yarwin commented Jun 23, 2025

Coincidence – "a" is after "b" while all the items are not a #[export]s.

it went from:

<members>
// #[var]
<member name="item" type="f32" default="">this is very documented</member>
// export A & B
<member name="a" type="i32" default="" deprecated="use on your own risk!!">not to be confused with B!</member>
<member name="b" type="i64" default="" experimental="idk.">Some docs…</member>
// Two other #[var]s
<member name="item_2" type="i64" default="">is it documented?</member>
<member name="item_xml" type="GString" default="">this docstring has &lt; a special character</member>
</members>

for

pub struct FairlyDocumented {
#[doc = r#"this is very documented"#]
#[var]
item: f32,
#[doc = "@deprecated use on your own risk!!"]
#[doc = ""]
#[doc = "not to be confused with B!"]
#[export]
a: i32,
/// Some docs…
/// @experimental idk.
#[export]
b: i64,
/// is it documented?
#[var]
item_2: i64,
#[var]
/// this docstring has < a special character
item_xml: GString,
/// this isnt documented
_other_item: (),
/// nor this
base: Base<Node>,
}

to

<members>
// #[var]s
<member name="item" type="f32" default="">this is very documented</member>
<member name="item_2" type="i64" default="">is it documented?</member>
<member name="item_xml" type="GString" default="">this docstring has &lt; a special character</member>
// export A & B 
<member name="a" type="i32" default="" deprecated="use on your own risk!!">not to be confused with B!</member>
<member name="b" type="i64" default="" experimental="idk.">Some docs…</member>
</members>

I can change this order and document this quirk 🤔

EDIT: ah, now I see – you meant GODOT docs, not the test ones 😅 – it seems you are right (I checked it with other examples and yeah, they are sorted alphabetically).

- Formatting fixes, use `sort_by` instead of `sort_unstable_by` (as the already created comment states).
@TitanNano
Copy link
Contributor

Properties are implicitly added to the most recently defined group, like in GDScript, right? I'm not a fan of attributes having implicit effects on other properties.

@@ -749,6 +737,7 @@ fn parse_fields(
}

Ok(Fields {
groups,
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you call format_groups here and pass the Vec by value? fn format_groups(groups: Vec<String>) -> Vec<String>

Comment on lines 41 to 47
pub(crate) fn new_from_kv(parser: &mut KvParser, groups: &mut Vec<String>) -> Self {
Self {
group_name_index: Self::parse_group("group", parser, groups),
subgroup_name_index: Self::parse_group("subgroup", parser, groups),
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This currently accepts any token-tree and just prays that it's a string litteral. This is also accepted:

    #[export(group = f64::MAX)]

but is turned into a group "f64 :: MAX" which is not something I'd expect.

Copy link
Contributor Author

@Yarwin Yarwin Jun 25, 2025

Choose a reason for hiding this comment

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

I would argue that it might be desirable, bigger issue might be typo such as #[export(group = g1765 range = (0.0, 10.0, or_greater))] (lack of comma) 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix'd, I also included key with a proper span pointing at the attribute, for example:

error: missing value for 'subgroup' (must be String literal)
  --> src/lib.rs:27:14
   |
27 |     #[export(subgroup = some subgroup 2)]
   |              ^^^^^^^^

error: value for 'group' must be String literal; found extra Ident { sym: range, span: #0 bytes(803..808) }
  --> src/lib.rs:36:14
   |
36 |     #[export(group = "g1765" range = (0.0, 10.0, or_greater))]
   |              ^^^^^

It works nicely with IDEs, albeit it handles only one error at once 🤔

image

- Check for string literal, instead of any expression; Return the key with proper span in the errors.
@Yarwin
Copy link
Contributor Author

Yarwin commented Jun 30, 2025

Properties are implicitly added to the most recently defined group, like in GDScript, right? I'm not a fan of attributes having implicit effects on other properties.

Yeah, I'm not a fan either. I tested gdscript-like syntax and it results in jank – this is why I'm doing all these shenanigans with sorting&checking groups.

@TitanNano
Copy link
Contributor

Properties are implicitly added to the most recently defined group, like in GDScript, right? I'm not a fan of attributes having implicit effects on other properties.

Yeah, I'm not a fan either. I tested gdscript-like syntax and it results in jank – this is why I'm doing all these shenanigans with sorting&checking groups.

Personally, I would prefer explicitly setting the desired group on every property. That would also make things simpler for you.

@Yarwin
Copy link
Contributor Author

Yarwin commented Jun 30, 2025

Personally, I would prefer explicitly setting the desired group on every property. That would also make things simpler for you.

This is extacly what I went for – every property has explicitly set group and subgroup. The problem is with the Godot itself – it assumes that every property registered after given group/subgroup is a part of said group/subgroup – therefore forcing us to sort all the properties by their group/subgroup association. I'm not sure if it can be changed upstream (due to backwards compatibility and whatnot – it has been like that for years).

Example, to make it 100% clear:

#[derive(GodotClass)]
#[class(base=Node, init)]
pub struct NodeWithGroups {
    #[export(group = "my group", subgroup = "my group subgroup")]
    property_6: i32,
    #[export(group = "my group")]
    property_5: GString,
    #[export(subgroup = "my subgroup")]
    property_3: f32,
    #[export]
    property_1: i32,
    #[export(group = "my group")]
    property_4: f32,
    #[export(group = "my group 2")]
    property_8: i32,
    #[export(group = "my group", subgroup = "my group subgroup")]
    property_7: i32,
    #[export]
    property_2: i32,
}

and the result:
image

@ttencate
Copy link
Contributor

Excellent work! Small issue: I think subgroup without group makes no sense and should result in a compile error, since it's likely a user error, even if Godot allows it and implicitly converts the subgroup to a non-sub group.

@TitanNano
Copy link
Contributor

@Yarwin thanks for the clarification. I misunderstood this part when reading the PR the first time.

@Bromeon
Copy link
Member

Bromeon commented Jun 30, 2025

This is extacly what I went for – every property has explicitly set group and subgroup. The problem is with the Godot itself – it assumes that every property registered after given group/subgroup is a part of said group/subgroup – therefore forcing us to sort all the properties by their group/subgroup association.

Is there any reason for us to support "non-canonical" order, i.e. property_2 being declared last? This also seems like an unintended user error.

The sorting logic is definitely nice to have and I wouldn't remove it, but maybe we should for now expect that fields are already declared in the correct order. Meaning: apply sort, check if order changed, if yes emit compile error.

Also, will the relative order of properties without group change? Because that wouldn't be good.

- Prevent declaring subgroup without group.
@Yarwin
Copy link
Contributor Author

Yarwin commented Jun 30, 2025

Small issue: I think subgroup without group makes no sense and should result in a compile error, since it's likely a user error, even if Godot allows it and implicitly converts the subgroup to a non-sub group.

Note: Firstly I wasn't sold on it, but having subgroup & group with the same name results in jank.

Is there any reason for us to support "non-canonical" order, i.e. property_2 being declared last? This also seems like an unintended user error.

Yep, this way user is free to sort their properties on their rust struct as they wish. It is easy to imagine order such as:

// MOVEMENT PROPS
#[export (group="movement") ]
speed: f32,

// NODES
#[init(...)]
animation_tree: OnReady<Gd<AnimationTree>>,
#[export]
camera: OnEditor<Gd<Camera3d>>,
#[export(group="movement")]
character_body: OnEditor<Gd<CustomCharacterController>>,

// NAVREGIONS
#[export(group="navigation_stuff)]
(...)

// CAMERA STUFF
(...)

Additionally groups are declared explicitly with the #[export]. Otherwise we are going back to Godot's decorator (and property_2 belonging to my_group_2).

Also, will the relative order of properties without group change? Because that wouldn't be good.

While we never guaranteed the proper order (albeit it always worked well 😄) relative ordering is being preserved (firstly declared go first etc).

@Bromeon
Copy link
Member

Bromeon commented Jun 30, 2025

Yep, this way user is free to sort their properties on their rust struct as they wish. It is easy to imagine order such as:

I get that, but why would anyone do this?

The inspector is the user-facing representation of fields, which people likely interact with most (otherwise they wouldn't use #[export]). The fact that someone divides into groups/subgroups means there's some logical connection between certain properties.

Why not maintain that same connection in the Rust source? What's the benefit?

Was this feature (splitting declarations belonging to the same group) ever requested in GDScript?


While we never guaranteed the proper order (albeit it always worked well 😄) relative ordering is being preserved (firstly declared go first etc).

Thanks! Just to be clear, this also applies to any fields within the same group/subgroup, right? Not just on top level.

- Fix docs.
@Yarwin Yarwin force-pushed the add-group-export branch from 9ef11e8 to e0342e3 Compare June 30, 2025 17:02
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, overall great work 👍

Not fully convinced about per-field attributes + reordering. While per-field is more rusty, it comes with some disadvantages: it needs repetition of both group and subgroup name. This would be less of a problem if the proc-macro didn't require a literal, but with this design it's not possible to store the name in a constant and change in only one place. Accidentally making a typo creates a new group.

GDScript doesn't have these problems.

@export_group("My Properties")
@export var number = 3

@export_subgroup("Extra Properties")
@export var string = ""
@export var flag = false

It's also considerably less verbose (granted, the init we have to do):

#[export(group = "My Properties"))
#[init(val = 3)]
number: i32

#[export(group = "My Properties", subgroup = "Extra Properties"))
string: GString,

#[export(group = "My Properties", subgroup = "Extra Properties"))
#[init(val = false)]
flag: bool,

Not saying we have to change this, but we should at least foresee how we can expand from here, to not design ourselves into a corner. Which also gets us back to my last comment: if we required the correct order in the first place, we could still go both ways in the future:

  • Allow reordering of properties without changing grouping (current impl)
  • Omit repetition of group/subgroup to infer it from previous field (GDScript impl)

Both would be backwards-compatible. Committing to field reordering today however closes that door for relatively little benefit. We don't have user feedback yet -- if anything, we know from thousands of users that the GDScript way works, but there's zero indication that reordering is a necessity.

/// Names of all the declared groups and subgroups for this struct.
// In the future might be split in two (for groups and subgroups) & used to define the priority (order) of said groups.
// Currently order of declaration declares the group priority (i.e. – groups declared first are shown as the first in the editor).
// This order is not guaranteed but so far proved to work reliably.
Copy link
Member

Choose a reason for hiding this comment

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

We should probably guarantee it.

People use #[export] because they want to have a UI representation, and with that come certain expectations. Changing order would break user workflows for no good reason. At the very least, we should never change this in patch versions.

Maybe we can even test this, if certain APIs return the same order as in inspector.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fortunately get_property_list will be enough and will return the same order as one in the inspector.

Comment on lines +148 to +150
// Ungrouped fields or fields with subgroup only always have higher priority (i.e. are displayed on top).
(Some(_), None) => Ordering::Greater,
(None, Some(_)) => Ordering::Less,
Copy link
Member

Choose a reason for hiding this comment

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

or fields with subgroup only

This is outlawed now, no?

Comment on lines +284 to +288
Some(quote! {
::godot::register::private::#register_fn::<#class_name>(
#group,
);
})
Copy link
Member

Choose a reason for hiding this comment

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

Formatting

Comment on lines +303 to +304
/// To declare groups and subgroups append `group` key to a `#[export]` attribute. Fields without group will be exported first,
/// followed by properties with group only, tailed by ones with group & subgroup declarations. Relative order of fields is being preserved
Copy link
Member

Choose a reason for hiding this comment

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

Relative order of fields is being preserved

Maybe mention that this applies within the same group/subgroup, as well as on top level.

Comment on lines +206 to +210
/// Handles a string literal (`att = "str"`).
pub fn handle_string(&mut self, key: &str) -> ParseResult<Option<String>> {
self.handle_literal(key, "String")
.map(|possible_literal| possible_literal.map(|lit| lit.to_string()))
}
Copy link
Member

Choose a reason for hiding this comment

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

We didn't add this because string properties can generally be expressions.

It might be OK to support only literals in the first spec, but maybe add a comment like

If we support expressions in the future (e.g. names of consts), this is no longer needed.

@Bromeon
Copy link
Member

Bromeon commented Jun 30, 2025

Maybe instead of supporting non-literals, we could in the future allow a "config" struct for groups?

Prior art in #[rpc]:

#[godot_api]
impl MyStruct {
    #[rpc(authority, unreliable_ordered, call_remote, channel = 2)]
    fn explicit(&mut self) {}

    #[rpc(config = MY_RPC_CONFIG)]
    fn external_config_const(&mut self) {}
}

const MY_RPC_CONFIG: RpcConfig = RpcConfig {
    rpc_mode: RpcMode::AUTHORITY,
    transfer_mode: TransferMode::UNRELIABLE_ORDERED,
    call_local: false,
    channel: 2,
};

Here, this could mean:

#[derive(GodotClass)]
struct MyStruct {
    #[export(group = "g", subgroup = "sub-g")]
    explicit: i32,

    #[export(group_config = GROUP)]
    external_config_const: i32,
}

const GROUP: GroupConfig = GroupConfig::new("g", "sub-g");

Either way, it would need moving the sorting/validation to runtime, but we already do similar things in other places (e.g. calculating values of enum variants).

@Yarwin
Copy link
Contributor Author

Yarwin commented Jul 1, 2025

In general groups are pick-your-poison – gdscript groups are fairly janky (and some people dislike them), while going fully with clap-inspired API is overly verbose 🤔.

After re-thinking all of it again, I think I'll go with gdscript implementation 🤔 (and expose the prefix as well). I'll leave the ordering stuff in separate commit, so re-using it would be fairly trivial in the future.

@Yarwin Yarwin marked this pull request as draft July 1, 2025 21:59
@Bromeon
Copy link
Member

Bromeon commented Jul 2, 2025

Another idea would be to avoid inline definitions entirely, and reserve only group as the key for a config.

// Special methods for ease of use.
use godot::register::{group, subgroup};

const MAP: PropertyGroup = group("Map");
const MAP_TILES: PropertyGroup = subgroup("Map", "Tiles");
const MAP_ENEMIES: PropertyGroup = subgroup("Map", "Enemies");
const PLAYER: PropertyGroup = group("Player");

#[derive(GodotClass)]
struct LevelConfig {
    #[export] // top-level
    name: GString,

    #[export(group = MAP)]
    terrain_type: Terrain,

    #[export(group = MAP_TILES)]
    width: u8,
    #[export(group = MAP_TILES)]
    height: u8,

    #[export(group = MAP_ENEMIES)]
    enemy_positions: PackedVector2Array,

    #[export(group = PLAYER)]
    player_hp: u16,
}

Still not sure if we should go rather the "rusty" or the "godoty" way... 🤔

@Bromeon
Copy link
Member

Bromeon commented Jul 2, 2025

If we go for the Godot "attribute affects all following fields" way, we might want to make this clearer with an attribute other than #[export].

Maybe even something that underlines the "multi-field" semantics.
#[export_onward(group = ...)] or so 🧐

Being separate from #[export] also has the advantage that you can easily regroup properties by moving 1 line, and don't need to tear apart other export configurations.

@Bromeon Bromeon linked an issue Jul 2, 2025 that may be closed by this pull request
@Bromeon Bromeon force-pushed the master branch 2 times, most recently from ea8033b to db2bdce Compare July 3, 2025 20:55
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.

Grouping Exported Properties in the Editor
5 participants