diff --git a/godot-core/src/registry/godot_register_wrappers.rs b/godot-core/src/registry/godot_register_wrappers.rs index 93eb4342f..ac6b473a8 100644 --- a/godot-core/src/registry/godot_register_wrappers.rs +++ b/godot-core/src/registry/godot_register_wrappers.rs @@ -7,7 +7,7 @@ //! Internal registration machinery used by proc-macro APIs. -use crate::builtin::StringName; +use crate::builtin::{GString, StringName}; use crate::global::PropertyUsageFlags; use crate::meta::{ClassName, GodotConvert, GodotType, PropertyHintInfo, PropertyInfo}; use crate::obj::GodotClass; @@ -79,3 +79,33 @@ fn register_var_or_export_inner( ); } } + +pub fn register_group(group_name: &str) { + let group_name = GString::from(group_name); + let prefix = GString::default(); + let class_name = C::class_name(); + + unsafe { + sys::interface_fn!(classdb_register_extension_class_property_group)( + sys::get_library(), + class_name.string_sys(), + group_name.string_sys(), + prefix.string_sys(), + ); + } +} + +pub fn register_subgroup(subgroup_name: &str) { + let subgroup_name = GString::from(subgroup_name); + let prefix = GString::default(); + let class_name = C::class_name(); + + unsafe { + sys::interface_fn!(classdb_register_extension_class_property_subgroup)( + sys::get_library(), + class_name.string_sys(), + subgroup_name.string_sys(), + prefix.string_sys(), + ); + } +} diff --git a/godot-macros/src/class/data_models/field.rs b/godot-macros/src/class/data_models/field.rs index 61c8fbdba..0271912c4 100644 --- a/godot-macros/src/class/data_models/field.rs +++ b/godot-macros/src/class/data_models/field.rs @@ -5,6 +5,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +use crate::class::data_models::group_export::FieldGroup; use crate::class::{FieldExport, FieldVar}; use crate::util::{error, KvParser}; use proc_macro2::{Ident, Span, TokenStream}; @@ -16,6 +17,7 @@ pub struct Field { pub default_val: Option, pub var: Option, pub export: Option, + pub group: Option, pub is_onready: bool, pub is_oneditor: bool, #[cfg(feature = "register-docs")] @@ -31,6 +33,7 @@ impl Field { default_val: None, var: None, export: None, + group: None, is_onready: false, is_oneditor: false, #[cfg(feature = "register-docs")] @@ -110,20 +113,6 @@ pub enum FieldCond { IsOnEditor, } -pub struct Fields { - /// All fields except `base_field`. - pub all_fields: Vec, - - /// The field with type `Base`, if available. - pub base_field: Option, - - /// Deprecation warnings. - pub deprecations: Vec, - - /// Errors during macro evaluation that shouldn't abort the execution of the macro. - pub errors: Vec, -} - #[derive(Clone)] pub struct FieldDefault { pub default_val: TokenStream, diff --git a/godot-macros/src/class/data_models/fields.rs b/godot-macros/src/class/data_models/fields.rs new file mode 100644 index 000000000..b62c2142c --- /dev/null +++ b/godot-macros/src/class/data_models/fields.rs @@ -0,0 +1,54 @@ +/* + * 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 crate::class::Field; +use crate::util::bail; +use crate::ParseResult; +use proc_macro2::{Punct, TokenStream}; +use std::fmt::Display; + +pub struct Fields { + /// 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. + pub groups: Vec, + + /// All fields except `base_field`. + pub all_fields: Vec, + + /// The field with type `Base`, if available. + pub base_field: Option, + + /// Deprecation warnings. + pub deprecations: Vec, + + /// Errors during macro evaluation that shouldn't abort the execution of the macro. + pub errors: Vec, +} + +/// Fetches data for all named fields for a struct. +/// +/// Errors if `class` is a tuple struct. +pub fn named_fields( + class: &venial::Struct, + derive_macro_name: impl Display, +) -> ParseResult> { + // This is separate from parse_fields to improve compile errors. The errors from here demand larger and more non-local changes from the API + // user than those from parse_struct_attributes, so this must be run first. + match &class.fields { + // TODO disallow unit structs in the future + // It often happens that over time, a registered class starts to require a base field. + // Extending a {} struct requires breaking less code, so we should encourage it from the start. + venial::Fields::Unit => Ok(vec![]), + venial::Fields::Tuple(_) => bail!( + &class.fields, + "{derive_macro_name} is not supported for tuple structs", + )?, + venial::Fields::Named(fields) => Ok(fields.fields.inner.clone()), + } +} diff --git a/godot-macros/src/class/data_models/group_export.rs b/godot-macros/src/class/data_models/group_export.rs new file mode 100644 index 000000000..aea561e72 --- /dev/null +++ b/godot-macros/src/class/data_models/group_export.rs @@ -0,0 +1,219 @@ +/* + * 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 crate::class::data_models::fields::Fields; +use crate::util::{bail, KvParser}; +use crate::ParseResult; +use std::cmp::Ordering; + +/// Points to index of a given group name in [Fields.groups](field@Fields::groups). +/// +/// Two fields with the same GroupIdentifier belong to the same group. +pub type GroupIdentifier = usize; + +pub struct FieldGroup { + pub group_name_index: Option, + pub subgroup_name_index: Option, +} + +impl FieldGroup { + fn parse_group( + expr: &'static str, + parser: &mut KvParser, + groups: &mut Vec, + ) -> ParseResult> { + let Some(group) = parser.handle_string(expr)? else { + return Ok(None); + }; + + if let Some(group_index) = groups + .iter() + .position(|existing_group| existing_group == &group) + { + Ok(Some(group_index)) + } else { + groups.push(group); + Ok(Some(groups.len() - 1)) + } + } + + pub(crate) fn new_from_kv( + parser: &mut KvParser, + groups: &mut Vec, + ) -> ParseResult { + let (group_name_index, subgroup_name_index) = ( + Self::parse_group("group", parser, groups)?, + Self::parse_group("subgroup", parser, groups)?, + ); + + // Declaring only a subgroup for given property – with no group at all – is totally valid in Godot. + // Unfortunately it leads to a lot of very janky and not too ideal behaviours + // So it is better to treat it as a user error. + if subgroup_name_index.is_some() && group_name_index.is_none() { + return bail!(parser.span(), "Subgroups without groups are not supported."); + } + + Ok(Self { + group_name_index, + subgroup_name_index, + }) + } +} + +/// Remove surrounding quotes to display declared "group name" in editor as `group name` instead of `"group name"`. +/// Should be called after parsing all the fields to avoid unnecessary operations. +pub(crate) fn format_groups(groups: Vec) -> Vec { + groups + .into_iter() + .map(|g| g.trim_matches('"').to_string()) + .collect() +} + +// ---------------------------------------------------------------------------------------------------------------------------------------------- +// Ordering + +pub(crate) struct ExportGroupOrdering { + /// Allows to identify given export group. + /// `None` for root. + identifier: Option, + /// Contains subgroups of given ordering (subgroups for groups, subgroups&groups for root). + /// Ones parsed first have higher priority, i.e. are displayed as the first. + subgroups: Vec, +} + +impl ExportGroupOrdering { + /// Creates root which holds all the groups&subgroups. + /// Should be called only once in a given context. + fn root() -> Self { + Self { + identifier: None, + subgroups: Vec::new(), + } + } + + /// Represents individual group & its subgroups. + fn child(identifier: GroupIdentifier) -> Self { + Self { + identifier: Some(identifier), + subgroups: Vec::new(), + } + } + + /// Returns registered group index. Registers given group if not present. + fn group_index(&mut self, identifier: &GroupIdentifier) -> usize { + self.subgroups + .iter() + // Will never fail – non-root orderings must have an identifier. + .position(|sub| identifier == sub.identifier.as_ref().expect("Tried to parse an undefined export group. This is a bug, please report it.")) + .unwrap_or_else(|| { + // Register new subgroup. + self.subgroups.push(ExportGroupOrdering::child(*identifier)); + self.subgroups.len() - 1 + }) + } +} + +// Note: GDExtension doesn't support categories for some reason(s?). +// It probably expects us to use inheritance instead? +enum OrderingStage { + Group, + SubGroup, +} + +// It is recursive but max recursion depth is 2 (root -> group -> subgroup) so it's fine. +fn compare_by_group_and_declaration_order( + field_a: &FieldGroup, + field_b: &FieldGroup, + ordering: &mut ExportGroupOrdering, + stage: OrderingStage, +) -> Ordering { + let (lhs, rhs, next_stage) = match stage { + OrderingStage::Group => ( + &field_a.group_name_index, + &field_b.group_name_index, + Some(OrderingStage::SubGroup), + ), + OrderingStage::SubGroup => ( + &field_a.subgroup_name_index, + &field_b.subgroup_name_index, + None, + ), + }; + + match (lhs, rhs) { + // 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, + + // Same group/subgroup. + (Some(group_a), Some(group_b)) => { + if group_a == group_b { + let Some(next_stage) = next_stage else { + return Ordering::Equal; + }; + + let next_ordering_position = ordering.group_index(group_a); + + // Fields belong to the same group – check the subgroup. + compare_by_group_and_declaration_order( + field_a, + field_b, + &mut ordering.subgroups[next_ordering_position], + next_stage, + ) + } else { + // Parsed earlier => greater priority. + let (priority_a, priority_b) = ( + usize::MAX - ordering.group_index(group_a), + usize::MAX - ordering.group_index(group_b), + ); + priority_b.cmp(&priority_a) + } + } + + (None, None) => { + // Fields don't belong to any subgroup nor group. + let Some(next_stage) = next_stage else { + return Ordering::Equal; + }; + + compare_by_group_and_declaration_order(field_a, field_b, ordering, next_stage) + } + } +} + +/// Sorts fields by their group and subgroup association. +/// +/// Fields without group nor subgroup are first. +/// Fields with subgroup only come in next, in order of their declaration on the class struct. +/// Finally fields with groups are displayed – firstly ones without subgroups followed by +/// fields with given group & subgroup (in the same order as above). +/// +/// Group membership for properties in Godot is based on the order of their registration. +/// All the properties belong to group or subgroup registered beforehand – thus the need to sort them. +pub(crate) fn sort_fields_by_group(fields: &mut Fields) { + let mut initial_ordering = ExportGroupOrdering::root(); + + // `sort_by` instead of `sort_unstable_by` to preserve original order of declaration. + // Which is not guaranteed by the way albeit worked reliably so far. + fields.all_fields.sort_by(|a, b| { + let (group_a, group_b) = match (&a.group, &b.group) { + (Some(a), Some(b)) => (a, b), + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + // We don't care about ordering of fields without a `#[export]`. + _ => return Ordering::Equal, + }; + + compare_by_group_and_declaration_order( + group_a, + group_b, + &mut initial_ordering, + OrderingStage::Group, + ) + }); +} diff --git a/godot-macros/src/class/data_models/property.rs b/godot-macros/src/class/data_models/property.rs index 5c50efe00..247f77bc6 100644 --- a/godot-macros/src/class/data_models/property.rs +++ b/godot-macros/src/class/data_models/property.rs @@ -7,8 +7,10 @@ //! Parses the `#[var]` and `#[export]` attributes on fields. -use crate::class::{Field, FieldVar, Fields, GetSet, GetterSetterImpl, UsageFlags}; -use crate::util::{format_funcs_collection_constant, format_funcs_collection_struct}; +use crate::class::data_models::fields::Fields; +use crate::class::data_models::group_export::FieldGroup; +use crate::class::{Field, FieldVar, GetSet, GetterSetterImpl, UsageFlags}; +use crate::util::{format_funcs_collection_constant, format_funcs_collection_struct, ident}; use proc_macro2::{Ident, TokenStream}; use quote::quote; @@ -42,12 +44,15 @@ pub fn make_property_impl(class_name: &Ident, fields: &Fields) -> TokenStream { let mut func_name_consts = Vec::new(); let mut export_tokens = Vec::new(); + let (mut current_group, mut current_subgroup) = (None, None); + for field in &fields.all_fields { let Field { name: field_ident, ty: field_type, var, export, + group, .. } = field; @@ -59,18 +64,24 @@ pub fn make_property_impl(class_name: &Ident, fields: &Fields) -> TokenStream { } else { UsageFlags::InferredExport }; - Some(FieldVar { + FieldVar { usage_flags, ..Default::default() - }) + } } - (_, var) => var.clone(), + (_, Some(var)) => var.clone(), + _ => continue, }; - let Some(var) = var else { - continue; - }; + register_group_or_subgroup( + group, + &mut current_group, + &mut current_subgroup, + &fields.groups, + &mut export_tokens, + class_name, + ); let field_name = field_ident.to_string(); @@ -220,3 +231,62 @@ fn make_getter_setter( quote! { #funcs_collection::#constant } } + +/// Pushes registration of group or subgroup in case if they differ from previously registered ones. +/// +/// Group membership of properties is based on the order of their registration. +/// All the properties belong to group or subgroup registered beforehand. +fn register_group_or_subgroup( + field_group: &Option, + current_group: &mut Option, + current_subgroup: &mut Option, + groups: &[String], + export_tokens: &mut Vec, + class_name: &Ident, +) { + let Some(group) = field_group else { + return; + }; + + if let Some(group_registration) = make_group_registration( + current_group, + group.group_name_index, + groups, + ident("register_group"), + class_name, + ) { + export_tokens.push(group_registration); + } + + if let Some(subgroup_registration) = make_group_registration( + current_subgroup, + group.subgroup_name_index, + groups, + ident("register_subgroup"), + class_name, + ) { + export_tokens.push(subgroup_registration); + } +} + +fn make_group_registration( + current: &mut Option, + new: Option, + groups: &[String], + register_fn: Ident, + class_name: &Ident, +) -> Option { + let new_group = new?; + + if current.is_none_or(|cur| cur != new_group) { + *current = Some(new_group); + let group = groups.get(new_group)?; + Some(quote! { + ::godot::register::private::#register_fn::<#class_name>( + #group, + ); + }) + } else { + None + } +} diff --git a/godot-macros/src/class/derive_godot_class.rs b/godot-macros/src/class/derive_godot_class.rs index 416d0cba5..dfe0d9c3b 100644 --- a/godot-macros/src/class/derive_godot_class.rs +++ b/godot-macros/src/class/derive_godot_class.rs @@ -5,9 +5,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +use crate::class::data_models::fields::{named_fields, Fields}; +use crate::class::data_models::group_export::{format_groups, sort_fields_by_group, FieldGroup}; use crate::class::{ make_property_impl, make_virtual_callback, BeforeKind, Field, FieldCond, FieldDefault, - FieldExport, FieldVar, Fields, SignatureInfo, + FieldExport, FieldVar, SignatureInfo, }; use crate::util::{ bail, error, format_funcs_collection_struct, ident, path_ends_with_complex, @@ -33,9 +35,11 @@ pub fn derive_godot_class(item: venial::Item) -> ParseResult { } let mut modifiers = Vec::new(); - let named_fields = named_fields(class)?; + let named_fields = named_fields(class, "#[derive(GodotClass)]")?; let mut struct_cfg = parse_struct_attributes(class)?; let mut fields = parse_fields(named_fields, struct_cfg.init_strategy)?; + sort_fields_by_group(&mut fields); + if struct_cfg.is_editor_plugin() { modifiers.push(quote! { with_editor_plugin }) } @@ -559,25 +563,6 @@ fn parse_struct_attributes(class: &venial::Struct) -> ParseResult ParseResult> { - // This is separate from parse_fields to improve compile errors. The errors from here demand larger and more non-local changes from the API - // user than those from parse_struct_attributes, so this must be run first. - match &class.fields { - // TODO disallow unit structs in the future - // It often happens that over time, a registered class starts to require a base field. - // Extending a {} struct requires breaking less code, so we should encourage it from the start. - venial::Fields::Unit => Ok(vec![]), - venial::Fields::Tuple(_) => bail!( - &class.fields, - "#[derive(GodotClass)] is not supported for tuple structs", - )?, - venial::Fields::Named(fields) => Ok(fields.fields.inner.clone()), - } -} - /// Returns field names and 1 base field, if available. fn parse_fields( named_fields: Vec<(venial::NamedField, Punct)>, @@ -587,6 +572,7 @@ fn parse_fields( let mut base_field = Option::::None; let mut deprecations = vec![]; let mut errors = vec![]; + let mut groups = vec![]; // Attributes on struct fields for (named_field, _punct) in named_fields { @@ -627,10 +613,10 @@ fn parse_fields( } // Deprecated #[init(default = expr)] - if let Some(default) = parser.handle_expr("default")? { + if let Some((key, default)) = parser.handle_expr_with_key("default")? { if field.default_val.is_some() { return bail!( - parser.span(), + key, "Cannot use both `val` and `default` keys in #[init]; prefer using `val`" ); } @@ -680,6 +666,8 @@ fn parse_fields( if let Some(mut parser) = KvParser::parse(&named_field.attributes, "export")? { let export = FieldExport::new_from_kv(&mut parser)?; field.export = Some(export); + let group = FieldGroup::new_from_kv(&mut parser, &mut groups)?; + field.group = Some(group); parser.finish()?; } @@ -749,6 +737,7 @@ fn parse_fields( } Ok(Fields { + groups: format_groups(groups), all_fields, base_field, deprecations, diff --git a/godot-macros/src/class/mod.rs b/godot-macros/src/class/mod.rs index 4a99ff7cc..187301cd9 100644 --- a/godot-macros/src/class/mod.rs +++ b/godot-macros/src/class/mod.rs @@ -8,12 +8,15 @@ mod derive_godot_class; mod godot_api; mod godot_dyn; + mod data_models { pub mod constant; pub mod field; pub mod field_export; pub mod field_var; + pub mod fields; pub mod func; + pub mod group_export; pub mod inherent_impl; pub mod interface_trait_impl; pub mod property; diff --git a/godot-macros/src/lib.rs b/godot-macros/src/lib.rs index ed4497af6..33ed77520 100644 --- a/godot-macros/src/lib.rs +++ b/godot-macros/src/lib.rs @@ -300,6 +300,46 @@ use crate::util::{bail, ident, KvParser}; /// } /// ``` /// +/// 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 +/// +///``` +/// # use godot::prelude::*; +/// const MAX_HEALTH: f64 = 100.0; +/// +/// #[derive(GodotClass)] +/// # #[class(init)] +/// struct MyStruct { +/// #[export(group = "my_group", subgroup = "my_subgroup")] +/// will_be_displayed_as_4th: u32, +/// +/// #[export] +/// will_be_displayed_as_1st: i64, +/// +/// #[export(group = "my_other_group")] +/// will_be_displayed_as_the_3rd: i64, +/// +/// #[export(range = (0.0, MAX_HEALTH), group = "my_group")] +/// will_be_displayed_as_2nd: f64, +/// +/// #[export(group = "last_group")] +/// will_be_displayed_last: i64 +/// } +///``` +/// +/// Using subgroup with no group specified is not allowed and will result in compile error. +/// +/// ```compile_fail +/// # use godot::prelude::*; +/// +/// #[derive(GodotClass)] +/// # #[class(init)] +/// struct MyStruct { +/// #[export(subgroup = "my_subgroup")] +/// illegal: u32 +/// } +/// ``` +/// /// ## Low-level property hints and usage /// /// You can specify custom property hints, hint strings, and usage flags in a `#[var]` attribute using the `hint`, `hint_string` diff --git a/godot-macros/src/util/kv_parser.rs b/godot-macros/src/util/kv_parser.rs index a98db1989..89b694fad 100644 --- a/godot-macros/src/util/kv_parser.rs +++ b/godot-macros/src/util/kv_parser.rs @@ -5,13 +5,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +use super::{bail, error, ident, is_punct, path_is_single, ListParser}; use crate::ParseResult; use proc_macro2::{Delimiter, Ident, Literal, Spacing, Span, TokenStream, TokenTree}; use quote::ToTokens; use std::collections::HashMap; -use super::{bail, error, ident, is_punct, path_is_single, ListParser}; - pub(crate) type KvMap = HashMap>; /// Struct to parse attributes like `#[attr(key, key2="value", key3=123)]` in a very user-friendly way. @@ -156,19 +155,34 @@ impl KvParser { } /// Handles an optional key that can occur with arbitrary tokens as the value. - pub fn handle_expr(&mut self, key: &str) -> ParseResult> { + /// + /// Returns both the key (with the correct span pointing to the attribute) and the value. + /// [KvParser.span](field@KvParser::span) always points to the top of derive macro (`#[derive(GodotClass)]`). + pub fn handle_expr_with_key(&mut self, key: &str) -> ParseResult> { match self.map.remove_entry(&ident(key)) { None => Ok(None), // The `key` that was removed from the map has the correct span. Some((key, value)) => match value { None => bail!(key, "expected `{key}` to be followed by `= expression`"), - Some(value) => Ok(Some(value.expr()?)), + Some(value) => Ok(Some((key, value.expr()?))), }, } } - pub fn handle_usize(&mut self, key: &str) -> ParseResult> { - let Some(expr) = self.handle_expr(key)? else { + /// Shortcut for [KvParser::handle_expr_with_key] which returns only the value. + pub fn handle_expr(&mut self, key: &str) -> ParseResult> { + match self.handle_expr_with_key(key)? { + Some((_key, value)) => Ok(Some(value)), + None => Ok(None), + } + } + + pub fn handle_literal( + &mut self, + key: &str, + expected_type: &str, + ) -> ParseResult> { + let Some((key, expr)) = self.handle_expr_with_key(key)? else { return Ok(None); }; @@ -176,16 +190,29 @@ impl KvParser { let Some(TokenTree::Literal(lit)) = tokens.next() else { return bail!( key, - "missing value for '{key}' (must be unsigned integer literal)" + "missing value for '{key}' (must be {expected_type} literal)" ); }; if let Some(surplus) = tokens.next() { return bail!( key, - "value for '{key}' must be unsigned integer literal; found extra {surplus:?}" + "value for '{key}' must be {expected_type} literal; found extra {surplus:?}" ); } + Ok(Some(lit)) + } + + /// Handles a string literal (`att = "str"`). + pub fn handle_string(&mut self, key: &str) -> ParseResult> { + self.handle_literal(key, "String") + .map(|possible_literal| possible_literal.map(|lit| lit.to_string())) + } + + pub fn handle_usize(&mut self, key: &str) -> ParseResult> { + let Some(lit) = self.handle_literal(key, "unsigned integer")? else { + return Ok(None); + }; let Ok(int) = lit.to_string().parse() else { return bail!( @@ -199,7 +226,7 @@ impl KvParser { #[allow(dead_code)] pub fn handle_bool(&mut self, key: &str) -> ParseResult> { - let Some(expr) = self.handle_expr(key)? else { + let Some((key, expr)) = self.handle_expr_with_key(key)? else { return Ok(None); }; diff --git a/itest/rust/src/object_tests/property_test.rs b/itest/rust/src/object_tests/property_test.rs index cde1310f9..0df6e63ca 100644 --- a/itest/rust/src/object_tests/property_test.rs +++ b/itest/rust/src/object_tests/property_test.rs @@ -239,6 +239,15 @@ struct CheckAllExports { #[export] normal: GString, + #[export(group = "test_group")] + grouped: i64, + + #[export(group = "another group")] + subgrouped: i64, + + #[export(group = "test_group", subgroup = "test_subgroup")] + grouped_with_subgroup: i64, + // `suffix = "px"` should be in the third slot to test that key-value pairs in that position no longer error. #[export(range = (0.0, 10.0, suffix = "px", or_greater, or_less, exp, degrees, hide_slider))] range_exported: f64, diff --git a/itest/rust/src/register_tests/res/registered_docs.xml b/itest/rust/src/register_tests/res/registered_docs.xml index a0551a2ba..91a51d2c9 100644 --- a/itest/rust/src/register_tests/res/registered_docs.xml +++ b/itest/rust/src/register_tests/res/registered_docs.xml @@ -96,5 +96,5 @@ public class Player : Node2D -this is very documentednot to be confused with B!Some docs…is it documented?this docstring has < a special character +this is very documentedis it documented?this docstring has < a special characternot to be confused with B!Some docs… \ No newline at end of file