Troubles with EditorProperty for InspectorPlugin that handles an Array of Variant arguments

Godot Version

v4.6.3.stable.mono.official [7d41c59c4]

tl;dr

Attempting to create an inspector plugin that handles an array of “arguments”. Having trouble creating said plugin, both in the visual layer and serialization layer.

Background

I am creating a resource that uses Reflection to create pure C# objects. My intention is to have code that is completely engine agnostic for a few reasons. Having a generic resource in which I can select assembly, type, constructor, and arguments to instantiate objects would be incredibly useful. I’ve already got the first three working, having trouble with arguments.

My current solution has been to create a new plugin, using _ParseProperty to create my own EditorProperty for each element:

    GodotObject @object, 
    Variant.Type type,
    string name, 
    PropertyHint hintType, 
    string hintString,
    PropertyUsageFlags usageFlags, 
    bool wide)
{
    ReflectionResource resource = @object as ReflectionResource;

    const bool addToEnd = false;
    if (name == nameof(ReflectionResource.AssemblyName))
    {
        AddPropertyEditor(
            name, 
            new AssemblyEditorProperty(resource), 
            addToEnd, 
            "Assembly");
        return true;
    }

    if (name == nameof(ReflectionResource.TypeName))
    {
        AddPropertyEditor(
            name, 
            new TypeEditorProperty(resource),
            addToEnd,
            "Type");
        return true;
    }

    if (name == nameof(ReflectionResource.ConstructorSignature))
    {
        AddPropertyEditor(
            name, 
            new ConstructorEditorProperty(resource),
            addToEnd,
            "Constructor");
        return true;
    }

    if (name == nameof(ReflectionResource.FirstArgument))
    {
        const int argumentIndex = 0;
        if (argumentIndex >= resource.ConstructorSignature.Count)
        {
            return true;
        }

// Repeat for parameters 2-4.

    if (name == nameof(ReflectionResource.FifthArgument))
    {
        const int argumentIndex = 4;
        if (argumentIndex >= resource.ConstructorSignature.Count)
        {
            return true;
        }

        ParameterData parameter = new(resource.ConstructorSignature[argumentIndex]);
        PropertyHint propertyHint = ReflectionUtility.GetPropertyHint(parameter.VariantType);
        return base._ParseProperty(@object, parameter.VariantType, name, propertyHint, hintString, usageFlags, wide);
    }

    return false;
}

Problem

This works technically, but there’s some things I dislike:

  • Cannot enforce variant type. The user needs to select the right variant for the argument, in the picture above, that’d be a boolean.
  • I have no control over label name.
  • I have to set up arguments concretely. Right now 5 arguments is the maximum.

Explrored Solutions

Below are some things I’ve tried to mitigate the above problems:

Argument Array - Per Element Custom PropertyEditor

My first pass at this was to have a Godot.Array property on ReflectionResource instead of typing out each argument property. For each argument, I’d create a new EditorProperty:

#if TOOLS
using Godot;

namespace Flantastico.Godot.ACE.Presenter.Core.Reflection;

internal partial class ArgumentEditorProperty : EditorProperty
{
    private readonly ReflectionResource _resource;
    private readonly int _parameterIndex;

    private bool _isUpdating;
    private EditorProperty _editorProperty;
    private Variant _currentValue;

    public ArgumentEditorProperty(ReflectionResource resource, int parameterIndex) 
    {
        _resource = resource;
        _parameterIndex = parameterIndex;

        ArgumentData parameter = new(resource.ConstructorSignature[_parameterIndex]);
        Variant variant = resource.GetArgumentByIndex(_parameterIndex);

        _editorProperty = EditorInspector.InstantiatePropertyEditor(
            resource,
            parameter.VariantType,
            resource.ResourceName, 
            PropertyHint.None, 
            string.Empty, 
            (uint)PropertyUsageFlags.None);
       
        AddChild(_editorProperty);
        AddFocusable(_editorProperty);

        _editorProperty.PropertyChanged += OnPropertyChanged;
        RefreshArgument();
    }

    public override void _UpdateProperty()
    {
        var editedProperty = GetEditedProperty();
        var editedValue = GetEditedObject().Get(editedProperty);
        if (IsMatchingVariant(editedValue, _currentValue))
        {
            return;
        }

        _isUpdating = true;
        _currentValue = editedValue;
        RefreshArgument();
        _isUpdating = false;
    }

    private void RefreshArgument()
    {
        _editorProperty.UpdateProperty();
    }

    private void OnPropertyChanged(StringName property, Variant value, StringName field, bool changing)
    {
        if (_isUpdating)
        {
            return;
        }

        _currentValue = value;
        EmitChanged(nameof(ReflectionResource.FirstArgument), _currentValue);
    }

    private static bool IsMatchingVariant(Variant variantA,  Variant variantB)
    {
        if (variantA.Obj == null || variantB.Obj == null)
        {
            return false;
        }

        if (variantA.VariantType != variantB.VariantType)
        {
            return false;
        }

        if (!variantA.Obj.Equals(variantB.Obj))
        {
            return false;
        }

        return true;
    }
}
#endif

This EditorProperty would be added as a child of a VBoxContainer, which was added as a control within the plugin. Problem with this solution was that it was having trouble serializing; whenever I’d make a change to an argument in the inspector, it’d revert to the default value. Otherwise, it worked great.

Argument Array - Per Element Default PropertyEditor

When the above didn’t work, I decided to move the code that was in the constructor out to the plugin, using AddPropertyEditor instead of AddCustomControl. This also didn’t work. Unfortunately, don’t have code left over to show but I can try to recreate if folks feel like this is a viable solution.

Argument Per Property

Essentially what I have now; each argument is it’s own Variant property.

Question

I’ve given a few solutions, so I guess my question is which is the best one that has the least amount of downsides. Here’s a general list of inquiries:

  • How does one use AddCustomControl while still serializing properties? I never got that to work; I always had to use AddEditorProperty.
  • Is it possible for an EditorProperty to handle a single element of an Array or is it an all or nothing type of deal?
  • How do I create the default EditorProperty for a Variant? I only found out how to use EditorInspector.InstantiatePropertyEditor, but this one doesn’t seem to allow me enforce a Variant.Type unless I’m wrong.
  • If I am force to have each argument be a separate property, how do I enforce Variant.Type and override the shown text.
  • Tangential, but is there a way to call Godot Inspector’s default property label formatter? I.E. I have argument isDeactivatedImmediately and want to change it to Is Deactivated Immediately as the inspector does with variables.

Thanks in advance for your help! Let me know if you need more clarifications. Also, feel free to give solutions in gdScript. I’ll try to then figure out how to translate to C#.

This is a fascinating exercise. I’m a huge fan of Reflection. It’s one of the things I love most about Ruby as a language, along with Duck Typing. I’ve implemented reflection in Java, but never C#.

I don’t have any code for you, but maybe I can help you think through the problem a bit.

Have you tried making this Resource in GDScript? The C# implementation of Variant is a struct at its heart and it creates an interface, but isn’t really a Variant the way it is in GDScript, because C#'s duck typing support is different. If the Resource has to stay in C#, this is likely a limitation you’ll have to accept - at least for now. At which point I’d circle back around when C# support through GDExtension is finished.

Do you mean “Assembly”, “Type”, “Constructor”, etc? Because I see you defining them in your code. What labels are you referring to?

Why? Why does each index have to be a const? Why can’t you use a for loop the size of the array of arguments? What happens?

Are you saying that you cannot use AddCustomControl while in the middle of a constructor? You never got it to work inside EditorInspectorPlugin ?

Are you trying to change things after enabling the plugin? Because you can’t do that. You need to disable and re-enable the plugin to make changes, then most likely you will need to reload the project.

EditorProperty is a Container.
image
So it can handle one or more things, depending on how you pass them. If you want an EditorProperrty to only hold one Array item, only pass it one Array item.

No idea, but we go back to the fact that you’re trying to shoehorn something in here that C# doesn’t really know how to handle. I recommend trying to use GDScript to do this or switch to GDExtension and use C++ instead of C#.

Need more info.

In GDScript, if you pass a variable as snake_case, capitalize() is automatically run on it. In your example, you are using camelCase, which in Godot C# indicates a private or local variable. Changing it to PascalCase, i.e. IsDeactivatedImmediately would make it a public variable and therefore probably cause it to be converted for you. I’m not sure though. Naming is very important in Godot however.

You don’t really need to use a plugin for what you want to do. You can use Object._get(), Object._set(), and Object._get_property_list() instead.

Example
@tool
extends Node


# The data that will be serialized to disk. This will contain the parameter values the user adds.
@export_storage var _data: Dictionary
# The selected method
var _selected_method: String = METHODS.keys()[0]


func _get(property: StringName) -> Variant:
	if property == "method":
		# Property is "method" so we return the _selected_method
		return _selected_method

	if property.begins_with("params/"):
		# Property begins with "params/" so we need to find out the parameter value
		var param = property.split("/")[1]
		if _data.has(param):
			# If the data has it already we return it
			return _data.get(param)
		else:
			# If not, we will get the default value from our METHODS dictionary
			var params = METHODS.get(_selected_method).get("params")
			var def = null
			for p in params:
				if param == p.name:
					def = p.value
					break
			return _data.get_or_add(param, def)

	return null


func _set(property: StringName, value: Variant) -> bool:
	if property == "method":
		# If we are setting the "method" then:
		_selected_method = value
		# we will clear the _data dictionary
		_data.clear()
		# And notify that the property list has changed so the new parameters are shown in the inspector
		notify_property_list_changed()
		return true

	if property.begins_with("params/"):
		# If it's a parameter then we just set the new value in our _data dictionary
		var param = property.split("/")[1]
		_data.set(param, value)
		return true

	return false


func _get_property_list() -> Array[Dictionary]:
	var props: Array[Dictionary]

	# We append a new inspector entry with the name "method" that will be shown as an enum with our method names
	props.append({
		"name": "method",
		"type": TYPE_STRING,
		"hint": PROPERTY_HINT_ENUM,
		"hint_string": ",".join(METHODS.keys()),
		"usage": PROPERTY_USAGE_DEFAULT # The property will be serialized to disk as "method"
	})


	# We get the parameters for the selected method and:
	var params = METHODS.get(_selected_method).get("params", [])

	for param in params:
		# for each one we will get the type using the value
		var type = typeof(param.get("value", null))
		# we will get the name of the param
		var param_name = param.get("name", "unknown")
		# and set its usage as only editor (won't be serialized to disk)
		# because we are already serializing the _data property
		var usage = PROPERTY_USAGE_EDITOR
		if type == TYPE_NIL:
			# if the type is Nil then it will be shown as a variant editor
			usage |= PROPERTY_USAGE_NIL_IS_VARIANT

		props.append({
			"name": "params/"+param_name, # params/ is the group
			"type": type,
			"usage": usage
		})

	return props


# mock Dictionary with some methods
const METHODS = {
	"play_animation": {
		"params": [
			{
				"name": "animation",
				"value": "",
			},
			{
				"name": "backwards",
				"value": false,
			}
		]
	},
	"add_animation_frame": {
		"params": [
			{
				"name": "animation",
				"value": "",
			},
			{
				"name": "time",
				"value": 0.0,
			},
			{
				"name": "path",
				"value": NodePath(),
			},
			{
				"name": "value",
				"value": null,
			},
		]
	}
}