Browse Source

Implement [ExportToolButton]

Paul Joannon 10 months ago
parent
commit
4f52c2bb1f
18 changed files with 406 additions and 3 deletions
  1. 27 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/ExportDiagnosticsTests.cs
  2. 9 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/ScriptPropertiesGeneratorTests.cs
  3. 48 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0108_ScriptProperties.generated.cs
  4. 48 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0109_ScriptProperties.generated.cs
  5. 48 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0110_ScriptProperties.generated.cs
  6. 48 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportedToolButtons_ScriptProperties.generated.cs
  7. 8 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0108.cs
  8. 9 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0109.cs
  9. 9 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0110.cs
  10. 12 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportedToolButtons.cs
  11. 3 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/AnalyzerReleases.Unshipped.md
  12. 30 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/Common.cs
  13. 6 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ExtensionMethods.cs
  14. 2 0
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/GodotClasses.cs
  15. 2 1
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/GodotEnums.cs
  16. 63 2
      modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPropertiesGenerator.cs
  17. 33 0
      modules/mono/glue/GodotSharp/GodotSharp/Core/Attributes/ExportToolButtonAttribute.cs
  18. 1 0
      modules/mono/glue/GodotSharp/GodotSharp/GodotSharp.csproj

+ 27 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/ExportDiagnosticsTests.cs

@@ -74,4 +74,31 @@ public class ExportDiagnosticsTests
             }
         );
     }
+
+    [Fact]
+    public async void ExportToolButtonInNonToolClass()
+    {
+        await CSharpSourceGeneratorVerifier<ScriptPropertiesGenerator>.Verify(
+            new string[] { "ExportDiagnostics_GD0108.cs" },
+            new string[] { "ExportDiagnostics_GD0108_ScriptProperties.generated.cs" }
+        );
+    }
+
+    [Fact]
+    public async void ExportAndExportToolButtonOnSameMember()
+    {
+        await CSharpSourceGeneratorVerifier<ScriptPropertiesGenerator>.Verify(
+            new string[] { "ExportDiagnostics_GD0109.cs" },
+            new string[] { "ExportDiagnostics_GD0109_ScriptProperties.generated.cs" }
+        );
+    }
+
+    [Fact]
+    public async void ExportToolButtonOnNonCallable()
+    {
+        await CSharpSourceGeneratorVerifier<ScriptPropertiesGenerator>.Verify(
+            new string[] { "ExportDiagnostics_GD0110.cs" },
+            new string[] { "ExportDiagnostics_GD0110_ScriptProperties.generated.cs" }
+        );
+    }
 }

+ 9 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/ScriptPropertiesGeneratorTests.cs

@@ -66,4 +66,13 @@ public class ScriptPropertiesGeneratorTests
             "AbstractGenericNode(Of T)_ScriptProperties.generated.cs"
         );
     }
+
+    [Fact]
+    public async void ExportedButtons()
+    {
+        await CSharpSourceGeneratorVerifier<ScriptPropertiesGenerator>.Verify(
+            "ExportedToolButtons.cs",
+            "ExportedToolButtons_ScriptProperties.generated.cs"
+        );
+    }
 }

+ 48 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0108_ScriptProperties.generated.cs

@@ -0,0 +1,48 @@
+using Godot;
+using Godot.NativeInterop;
+
+partial class ExportDiagnostics_GD0108
+{
+#pragma warning disable CS0109 // Disable warning about redundant 'new' keyword
+    /// <summary>
+    /// Cached StringNames for the properties and fields contained in this class, for fast lookup.
+    /// </summary>
+    public new class PropertyName : global::Godot.Node.PropertyName {
+        /// <summary>
+        /// Cached name for the 'MyButton' field.
+        /// </summary>
+        public new static readonly global::Godot.StringName @MyButton = "MyButton";
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool SetGodotClassPropertyValue(in godot_string_name name, in godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            this.@MyButton = global::Godot.NativeInterop.VariantUtils.ConvertTo<global::Godot.Callable>(value);
+            return true;
+        }
+        return base.SetGodotClassPropertyValue(name, value);
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool GetGodotClassPropertyValue(in godot_string_name name, out godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            value = global::Godot.NativeInterop.VariantUtils.CreateFrom<global::Godot.Callable>(this.@MyButton);
+            return true;
+        }
+        return base.GetGodotClassPropertyValue(name, out value);
+    }
+    /// <summary>
+    /// Get the property information for all the properties declared in this class.
+    /// This method is used by Godot to register the available properties in the editor.
+    /// Do not call this method.
+    /// </summary>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    internal new static global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo> GetGodotPropertyList()
+    {
+        var properties = new global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo>();
+        return properties;
+    }
+#pragma warning restore CS0109
+}

+ 48 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0109_ScriptProperties.generated.cs

@@ -0,0 +1,48 @@
+using Godot;
+using Godot.NativeInterop;
+
+partial class ExportDiagnostics_GD0109
+{
+#pragma warning disable CS0109 // Disable warning about redundant 'new' keyword
+    /// <summary>
+    /// Cached StringNames for the properties and fields contained in this class, for fast lookup.
+    /// </summary>
+    public new class PropertyName : global::Godot.Node.PropertyName {
+        /// <summary>
+        /// Cached name for the 'MyButton' field.
+        /// </summary>
+        public new static readonly global::Godot.StringName @MyButton = "MyButton";
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool SetGodotClassPropertyValue(in godot_string_name name, in godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            this.@MyButton = global::Godot.NativeInterop.VariantUtils.ConvertTo<global::Godot.Callable>(value);
+            return true;
+        }
+        return base.SetGodotClassPropertyValue(name, value);
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool GetGodotClassPropertyValue(in godot_string_name name, out godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            value = global::Godot.NativeInterop.VariantUtils.CreateFrom<global::Godot.Callable>(this.@MyButton);
+            return true;
+        }
+        return base.GetGodotClassPropertyValue(name, out value);
+    }
+    /// <summary>
+    /// Get the property information for all the properties declared in this class.
+    /// This method is used by Godot to register the available properties in the editor.
+    /// Do not call this method.
+    /// </summary>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    internal new static global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo> GetGodotPropertyList()
+    {
+        var properties = new global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo>();
+        return properties;
+    }
+#pragma warning restore CS0109
+}

+ 48 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportDiagnostics_GD0110_ScriptProperties.generated.cs

@@ -0,0 +1,48 @@
+using Godot;
+using Godot.NativeInterop;
+
+partial class ExportDiagnostics_GD0110
+{
+#pragma warning disable CS0109 // Disable warning about redundant 'new' keyword
+    /// <summary>
+    /// Cached StringNames for the properties and fields contained in this class, for fast lookup.
+    /// </summary>
+    public new class PropertyName : global::Godot.Node.PropertyName {
+        /// <summary>
+        /// Cached name for the 'MyButton' field.
+        /// </summary>
+        public new static readonly global::Godot.StringName @MyButton = "MyButton";
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool SetGodotClassPropertyValue(in godot_string_name name, in godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            this.@MyButton = global::Godot.NativeInterop.VariantUtils.ConvertTo<string>(value);
+            return true;
+        }
+        return base.SetGodotClassPropertyValue(name, value);
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool GetGodotClassPropertyValue(in godot_string_name name, out godot_variant value)
+    {
+        if (name == PropertyName.@MyButton) {
+            value = global::Godot.NativeInterop.VariantUtils.CreateFrom<string>(this.@MyButton);
+            return true;
+        }
+        return base.GetGodotClassPropertyValue(name, out value);
+    }
+    /// <summary>
+    /// Get the property information for all the properties declared in this class.
+    /// This method is used by Godot to register the available properties in the editor.
+    /// Do not call this method.
+    /// </summary>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    internal new static global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo> GetGodotPropertyList()
+    {
+        var properties = new global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo>();
+        return properties;
+    }
+#pragma warning restore CS0109
+}

+ 48 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/GeneratedSources/ExportedToolButtons_ScriptProperties.generated.cs

@@ -0,0 +1,48 @@
+using Godot;
+using Godot.NativeInterop;
+
+partial class ExportedToolButtons
+{
+#pragma warning disable CS0109 // Disable warning about redundant 'new' keyword
+    /// <summary>
+    /// Cached StringNames for the properties and fields contained in this class, for fast lookup.
+    /// </summary>
+    public new class PropertyName : global::Godot.GodotObject.PropertyName {
+        /// <summary>
+        /// Cached name for the 'MyButton1' property.
+        /// </summary>
+        public new static readonly global::Godot.StringName @MyButton1 = "MyButton1";
+        /// <summary>
+        /// Cached name for the 'MyButton2' property.
+        /// </summary>
+        public new static readonly global::Godot.StringName @MyButton2 = "MyButton2";
+    }
+    /// <inheritdoc/>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    protected override bool GetGodotClassPropertyValue(in godot_string_name name, out godot_variant value)
+    {
+        if (name == PropertyName.@MyButton1) {
+            value = global::Godot.NativeInterop.VariantUtils.CreateFrom<global::Godot.Callable>(this.@MyButton1);
+            return true;
+        }
+        if (name == PropertyName.@MyButton2) {
+            value = global::Godot.NativeInterop.VariantUtils.CreateFrom<global::Godot.Callable>(this.@MyButton2);
+            return true;
+        }
+        return base.GetGodotClassPropertyValue(name, out value);
+    }
+    /// <summary>
+    /// Get the property information for all the properties declared in this class.
+    /// This method is used by Godot to register the available properties in the editor.
+    /// Do not call this method.
+    /// </summary>
+    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
+    internal new static global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo> GetGodotPropertyList()
+    {
+        var properties = new global::System.Collections.Generic.List<global::Godot.Bridge.PropertyInfo>();
+        properties.Add(new(type: (global::Godot.Variant.Type)25, name: PropertyName.@MyButton1, hint: (global::Godot.PropertyHint)39, hintString: "Click me!", usage: (global::Godot.PropertyUsageFlags)4, exported: true));
+        properties.Add(new(type: (global::Godot.Variant.Type)25, name: PropertyName.@MyButton2, hint: (global::Godot.PropertyHint)39, hintString: "Click me!,ColorRect", usage: (global::Godot.PropertyUsageFlags)4, exported: true));
+        return properties;
+    }
+#pragma warning restore CS0109
+}

+ 8 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0108.cs

@@ -0,0 +1,8 @@
+using Godot;
+using Godot.Collections;
+
+public partial class ExportDiagnostics_GD0108 : Node
+{
+    [ExportToolButton("")]
+    public Callable {|GD0108:MyButton|};
+}

+ 9 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0109.cs

@@ -0,0 +1,9 @@
+using Godot;
+using Godot.Collections;
+
+[Tool]
+public partial class ExportDiagnostics_GD0109 : Node
+{
+    [Export, ExportToolButton("")]
+    public Callable {|GD0109:MyButton|};
+}

+ 9 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportDiagnostics_GD0110.cs

@@ -0,0 +1,9 @@
+using Godot;
+using Godot.Collections;
+
+[Tool]
+public partial class ExportDiagnostics_GD0110 : Node
+{
+    [ExportToolButton("")]
+    public string {|GD0110:MyButton|};
+}

+ 12 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators.Tests/TestData/Sources/ExportedToolButtons.cs

@@ -0,0 +1,12 @@
+using Godot;
+using System;
+
+[Tool]
+public partial class ExportedToolButtons : GodotObject
+{
+    [ExportToolButton("Click me!")]
+    public Callable MyButton1 => Callable.From(() => { GD.Print("Clicked MyButton1!"); });
+
+    [ExportToolButton("Click me!", Icon = "ColorRect")]
+    public Callable MyButton2 => Callable.From(() => { GD.Print("Clicked MyButton2!"); });
+}

+ 3 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/AnalyzerReleases.Unshipped.md

@@ -3,3 +3,6 @@
 Rule ID | Category | Severity | Notes
 --------|----------|----------|--------------------
 GD0003  |  Usage   |  Error   | ScriptPathAttributeGenerator, [Documentation](https://docs.godotengine.org/en/latest/tutorials/scripting/c_sharp/diagnostics/GD0003.html)
+GD0108  |  Usage   |  Error   | ScriptPropertiesGenerator, [Documentation](https://docs.godotengine.org/en/latest/tutorials/scripting/c_sharp/diagnostics/GD0108.html)
+GD0109  |  Usage   |  Error   | ScriptPropertiesGenerator, [Documentation](https://docs.godotengine.org/en/latest/tutorials/scripting/c_sharp/diagnostics/GD0109.html)
+GD0110  |  Usage   |  Error   | ScriptPropertiesGenerator, [Documentation](https://docs.godotengine.org/en/latest/tutorials/scripting/c_sharp/diagnostics/GD0110.html)

+ 30 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/Common.cs

@@ -107,6 +107,36 @@ namespace Godot.SourceGenerators
                 "Types not derived from Node should not export Node members. Node export is only supported in Node-derived classes.",
                 helpLinkUri: string.Format(_helpLinkFormat, "GD0107"));
 
+        public static readonly DiagnosticDescriptor OnlyToolClassesShouldUseExportToolButtonRule =
+            new DiagnosticDescriptor(id: "GD0108",
+                title: "The exported tool button is not in a tool class",
+                messageFormat: "The exported tool button '{0}' is not in a tool class",
+                category: "Usage",
+                DiagnosticSeverity.Error,
+                isEnabledByDefault: true,
+                "The exported tool button is not in a tool class. Annotate the class with the '[Tool]' attribute, or remove the '[ExportToolButton]' attribute.",
+                helpLinkUri: string.Format(_helpLinkFormat, "GD0108"));
+
+        public static readonly DiagnosticDescriptor ExportToolButtonShouldNotBeUsedWithExportRule =
+            new DiagnosticDescriptor(id: "GD0109",
+                title: "The '[ExportToolButton]' attribute cannot be used with another '[Export]' attribute",
+                messageFormat: "The '[ExportToolButton]' attribute cannot be used with another '[Export]' attribute on '{0}'",
+                category: "Usage",
+                DiagnosticSeverity.Error,
+                isEnabledByDefault: true,
+                "The '[ExportToolButton]' attribute cannot be used with the '[Export]' attribute. Remove one of the attributes.",
+                helpLinkUri: string.Format(_helpLinkFormat, "GD0109"));
+
+        public static readonly DiagnosticDescriptor ExportToolButtonIsNotCallableRule =
+            new DiagnosticDescriptor(id: "GD0110",
+                title: "The exported tool button is not a Callable",
+                messageFormat: "The exported tool button '{0}' is not a Callable",
+                category: "Usage",
+                DiagnosticSeverity.Error,
+                isEnabledByDefault: true,
+                "The exported tool button is not a Callable. The '[ExportToolButton]' attribute is only supported on members of type Callable.",
+                helpLinkUri: string.Format(_helpLinkFormat, "GD0110"));
+
         public static readonly DiagnosticDescriptor SignalDelegateMissingSuffixRule =
             new DiagnosticDescriptor(id: "GD0201",
                 title: "The name of the delegate must end with 'EventHandler'",

+ 6 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ExtensionMethods.cs

@@ -287,6 +287,12 @@ namespace Godot.SourceGenerators
         public static bool IsGodotGlobalClassAttribute(this INamedTypeSymbol symbol)
             => symbol.FullQualifiedNameOmitGlobal() == GodotClasses.GlobalClassAttr;
 
+        public static bool IsGodotExportToolButtonAttribute(this INamedTypeSymbol symbol)
+            => symbol.FullQualifiedNameOmitGlobal() == GodotClasses.ExportToolButtonAttr;
+
+        public static bool IsGodotToolAttribute(this INamedTypeSymbol symbol)
+            => symbol.FullQualifiedNameOmitGlobal() == GodotClasses.ToolAttr;
+
         public static bool IsSystemFlagsAttribute(this INamedTypeSymbol symbol)
             => symbol.FullQualifiedNameOmitGlobal() == GodotClasses.SystemFlagsAttr;
 

+ 2 - 0
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/GodotClasses.cs

@@ -9,10 +9,12 @@ namespace Godot.SourceGenerators
         public const string ExportCategoryAttr = "Godot.ExportCategoryAttribute";
         public const string ExportGroupAttr = "Godot.ExportGroupAttribute";
         public const string ExportSubgroupAttr = "Godot.ExportSubgroupAttribute";
+        public const string ExportToolButtonAttr = "Godot.ExportToolButtonAttribute";
         public const string SignalAttr = "Godot.SignalAttribute";
         public const string MustBeVariantAttr = "Godot.MustBeVariantAttribute";
         public const string GodotClassNameAttr = "Godot.GodotClassNameAttribute";
         public const string GlobalClassAttr = "Godot.GlobalClassAttribute";
+        public const string ToolAttr = "Godot.ToolAttribute";
         public const string SystemFlagsAttr = "System.FlagsAttribute";
     }
 }

+ 2 - 1
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/GodotEnums.cs

@@ -89,7 +89,8 @@ namespace Godot.SourceGenerators
         Password = 36,
         LayersAvoidance = 37,
         DictionaryType = 38,
-        Max = 39
+        ToolButton = 39,
+        Max = 40
     }
 
     [Flags]

+ 63 - 2
modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPropertiesGenerator.cs

@@ -69,6 +69,7 @@ namespace Godot.SourceGenerators
             bool hasNamespace = classNs.Length != 0;
 
             bool isInnerClass = symbol.ContainingType != null;
+            bool isToolClass = symbol.GetAttributes().Any(a => a.AttributeClass?.IsGodotToolAttribute() ?? false);
 
             string uniqueHint = symbol.FullQualifiedNameOmitGlobal().SanitizeQualifiedNameForUniqueHint()
                                 + "_ScriptProperties.generated";
@@ -277,6 +278,16 @@ namespace Godot.SourceGenerators
                     if (propertyInfo == null)
                         continue;
 
+                    if (propertyInfo.Value.Hint == PropertyHint.ToolButton && !isToolClass)
+                    {
+                        context.ReportDiagnostic(Diagnostic.Create(
+                            Common.OnlyToolClassesShouldUseExportToolButtonRule,
+                            member.Symbol.Locations.FirstLocationWithSourceTreeOrDefault(),
+                            member.Symbol.ToDisplayString()
+                        ));
+                        continue;
+                    }
+
                     AppendPropertyInfo(source, propertyInfo.Value);
                 }
 
@@ -418,6 +429,19 @@ namespace Godot.SourceGenerators
             var exportAttr = memberSymbol.GetAttributes()
                 .FirstOrDefault(a => a.AttributeClass?.IsGodotExportAttribute() ?? false);
 
+            var exportToolButtonAttr = memberSymbol.GetAttributes()
+                .FirstOrDefault(a => a.AttributeClass?.IsGodotExportToolButtonAttribute() ?? false);
+
+            if (exportAttr != null && exportToolButtonAttr != null)
+            {
+                context.ReportDiagnostic(Diagnostic.Create(
+                    Common.ExportToolButtonShouldNotBeUsedWithExportRule,
+                    memberSymbol.Locations.FirstLocationWithSourceTreeOrDefault(),
+                    memberSymbol.ToDisplayString()
+                ));
+                return null;
+            }
+
             var propertySymbol = memberSymbol as IPropertySymbol;
             var fieldSymbol = memberSymbol as IFieldSymbol;
 
@@ -446,19 +470,56 @@ namespace Godot.SourceGenerators
                 }
             }
 
+            if (exportToolButtonAttr != null && propertySymbol != null && propertySymbol.GetMethod == null)
+            {
+                context.ReportDiagnostic(Diagnostic.Create(
+                    Common.ExportedPropertyIsWriteOnlyRule,
+                    propertySymbol.Locations.FirstLocationWithSourceTreeOrDefault(),
+                    propertySymbol.ToDisplayString()
+                ));
+                return null;
+            }
+
             var memberType = propertySymbol?.Type ?? fieldSymbol!.Type;
 
             var memberVariantType = MarshalUtils.ConvertMarshalTypeToVariantType(marshalType)!.Value;
             string memberName = memberSymbol.Name;
 
+            string? hintString = null;
+
+            if (exportToolButtonAttr != null)
+            {
+                if (memberVariantType != VariantType.Callable)
+                {
+                    context.ReportDiagnostic(Diagnostic.Create(
+                        Common.ExportToolButtonIsNotCallableRule,
+                        memberSymbol.Locations.FirstLocationWithSourceTreeOrDefault(),
+                        memberSymbol.ToDisplayString()
+                    ));
+                    return null;
+                }
+
+                hintString = exportToolButtonAttr.ConstructorArguments[0].Value?.ToString() ?? "";
+                foreach (var namedArgument in exportToolButtonAttr.NamedArguments)
+                {
+                    if (namedArgument is { Key: "Icon", Value.Value: string { Length: > 0 } })
+                    {
+                        hintString += $",{namedArgument.Value.Value}";
+                    }
+                }
+
+                return new PropertyInfo(memberVariantType, memberName, PropertyHint.ToolButton,
+                    hintString: hintString, PropertyUsageFlags.Editor, exported: true);
+            }
+
             if (exportAttr == null)
             {
                 return new PropertyInfo(memberVariantType, memberName, PropertyHint.None,
-                    hintString: null, PropertyUsageFlags.ScriptVariable, exported: false);
+                    hintString: hintString, PropertyUsageFlags.ScriptVariable, exported: false);
             }
 
             if (!TryGetMemberExportHint(typeCache, memberType, exportAttr, memberVariantType,
-                    isTypeArgument: false, out var hint, out var hintString))
+                    isTypeArgument: false, out var hint, out hintString))
             {
                 var constructorArguments = exportAttr.ConstructorArguments;
 

+ 33 - 0
modules/mono/glue/GodotSharp/GodotSharp/Core/Attributes/ExportToolButtonAttribute.cs

@@ -0,0 +1,33 @@
+using System;
+
+#nullable enable
+
+namespace Godot
+{
+    /// <summary>
+    /// Exports the annotated <see cref="Callable"/> as a clickable button.
+    /// </summary>
+    [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
+    public sealed class ExportToolButtonAttribute : Attribute
+    {
+        /// <summary>
+        /// The label of the button.
+        /// </summary>
+        public string Text { get; }
+
+        /// <summary>
+        /// If defined, used to fetch an icon for the button via <see cref="Control.GetThemeIcon"/>,
+        /// from the <code>EditorIcons</code> theme type.
+        /// </summary>
+        public string? Icon { get; init; }
+
+        /// <summary>
+        /// Exports the annotated <see cref="Callable"/> as a clickable button.
+        /// </summary>
+        /// <param name="text">The label of the button.</param>
+        public ExportToolButtonAttribute(string text)
+        {
+            Text = text;
+        }
+    }
+}

+ 1 - 0
modules/mono/glue/GodotSharp/GodotSharp/GodotSharp.csproj

@@ -48,6 +48,7 @@
   <!-- Sources -->
   <ItemGroup>
     <Compile Include="Core\Aabb.cs" />
+    <Compile Include="Core\Attributes\ExportToolButtonAttribute.cs" />
     <Compile Include="Core\Bridge\GodotSerializationInfo.cs" />
     <Compile Include="Core\Bridge\MethodInfo.cs" />
     <Compile Include="Core\Callable.generics.cs" />