Editor/Authoring/BlobNodeEditorWidget.cs

Editor UI widget for editing BlobNode components and a small helper button (GooTokenBindingButton) that shows a token-binding icon for properties with GooTokenAttribute, lets the user choose or clear bindings, and manages token candidates from nearby GooTokenScope components.

ReflectionFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Goo.Authoring;

[CustomEditor( typeof( BlobNode ) )]
public sealed class BlobNodeEditorWidget : ComponentEditorWidget
{
	public BlobNodeEditorWidget( SerializedObject obj ) : base( obj )
	{
		Layout = global::Editor.Layout.Column();

		var sheet = new ControlSheet { IncludePropertyNames = true };
		sheet.AddObject( obj, ShouldInclude );
		Layout.Add( sheet );
	}

	static bool ShouldInclude( SerializedProperty property )
	{
		if ( property.PropertyType is null ) return false;
		if ( property.PropertyType.IsAssignableTo( typeof( System.Delegate ) )
			&& property.Name.StartsWith( "OnComponent" ) ) return false;
		if ( property.IsMethod ) return true;
		return property.HasAttribute<PropertyAttribute>();
	}
}

sealed class GooTokenBindingButton : IconButton
{
	readonly ControlWidget _control;
	readonly BlobNode? _node;
	readonly float _trailingInset;

	SerializedProperty Property => _control.SerializedProperty;

	public static void Attach( ControlWidget control, float trailingInset = 2f )
	{
		if ( control.SerializedProperty?.HasAttribute<GooTokenAttribute>() != true ) return;
		_ = new GooTokenBindingButton( control, trailingInset );
	}

	GooTokenBindingButton( ControlWidget control, float trailingInset ) : base( "data_object", parent: control )
	{
		_control = control;
		_node = control.SerializedProperty?.Parent?.Targets?.OfType<BlobNode>().FirstOrDefault();
		_trailingInset = trailingInset;
		FixedWidth = 16;
		FixedHeight = 16;
		IconSize = 12;
		Background = Color.Transparent;
		OnClick = OpenMenu;
	}

	[EditorEvent.Frame]
	public void RefreshFrame() => Refresh();

	public void Refresh()
	{
		var binding = CurrentBinding();
		var candidates = Candidates();
		var isBound = !string.IsNullOrWhiteSpace( binding );
		var resolves = isBound && candidates.Any( candidate => candidate.Name.Trim() == binding );
		var isHovered = _control.IsUnderMouse
			|| _control.GetDescendants<Widget>().Any( child => child.IsUnderMouse );
		Visible = isBound || (isHovered && HasScope());
		if ( !Visible ) return;

		Position = new Vector2( Math.Max( 0f, _control.Width - Width - _trailingInset ), 2f );
		var accent = resolves ? Theme.Blue : Theme.Yellow;
		Foreground = isBound ? accent : Theme.TextControl.WithAlpha( 0.75f );
		Background = isBound ? accent.WithAlpha( 0.16f ) : Color.Transparent;
		ToolTip = !isBound
			? candidates.Count > 0
				? $"Bind {Property.DisplayName} to a Goo token"
				: $"No compatible Goo tokens are defined for {Property.DisplayName}"
			: resolves
				? $"{Property.DisplayName} uses @{binding}. The literal remains as its fallback."
				: $"@{binding} is missing or has the wrong type. {Property.DisplayName} is using its literal fallback.";
		Raise();
	}

	void OpenMenu()
	{
		var menu = new ContextMenu( this );
		var binding = CurrentBinding();
		var candidates = Candidates();
		if ( !string.IsNullOrWhiteSpace( binding ) )
		{
			menu.AddOption( "Use literal value", "link_off", ClearBinding );
			menu.AddSeparator();
		}

		foreach ( var token in candidates )
		{
			var candidate = token;
			var name = candidate.Name.Trim();
			var icon = name == binding ? "check" : "data_object";
			menu.AddOption( $"@{name}", icon, () => SetBinding( name ) );
		}

		if ( candidates.Count == 0 )
			menu.AddOption( "No compatible tokens in scope", "info", () => { } ).Enabled = false;

		menu.OpenAtCursor( false );
	}

	string? CurrentBinding()
	{
		var bindings = BindingProperty()?.GetValue<Dictionary<string, string>>();
		return bindings is not null && bindings.TryGetValue( Property.Name, out var name ) ? name : null;
	}

	void SetBinding( string tokenName ) => ChangeBindings( bindings => bindings[Property.Name] = tokenName );

	void ClearBinding() => ChangeBindings( bindings => bindings.Remove( Property.Name ) );

	void ChangeBindings( Action<Dictionary<string, string>> change )
	{
		var property = BindingProperty();
		if ( property is null ) return;

		var bindings = property.GetValue<Dictionary<string, string>>() is { } current
			? new Dictionary<string, string>( current )
			: new Dictionary<string, string>();
		change( bindings );

		property.Parent.NoteStartEdit( property );
		property.SetValue( bindings );
		property.Parent.NoteFinishEdit( property );
		Refresh();
	}

	SerializedProperty? BindingProperty() => Property.Parent?.GetProperty( nameof( BlobNode.TokenBindings ) );

	List<GooTokenEntry> Candidates()
	{
		var result = new List<GooTokenEntry>();
		var seen = new HashSet<string>( StringComparer.Ordinal );
		foreach ( var scope in Scopes() )
		{
			foreach ( var token in scope.Tokens )
			{
				if ( token is null || string.IsNullOrWhiteSpace( token.Name ) ) continue;
				var name = token.Name.Trim();
				if ( !seen.Add( name ) ) continue;
				if ( Supports( token.Kind, Property.PropertyType ) ) result.Add( token );
			}
		}
		return result;
	}

	bool HasScope() => Scopes().Any();

	IEnumerable<GooTokenScope> Scopes()
	{
		for ( var gameObject = _node?.GameObject; gameObject is not null; gameObject = gameObject.Parent )
		{
			if ( gameObject.Components.Get<GooTokenScope>() is { } scope )
				yield return scope;
		}
	}

	static bool Supports( GooTokenKind kind, Type propertyType )
	{
		propertyType = Nullable.GetUnderlyingType( propertyType ) ?? propertyType;
		return kind switch
		{
			GooTokenKind.Color => propertyType == typeof( Color ),
			GooTokenKind.Length => propertyType == typeof( GooLength ) || propertyType == typeof( Sandbox.UI.Length ),
			GooTokenKind.Float => propertyType == typeof( float ),
			GooTokenKind.Integer => propertyType == typeof( int ),
			GooTokenKind.Boolean => propertyType == typeof( bool ),
			GooTokenKind.String => propertyType == typeof( string ),
			GooTokenKind.Texture => propertyType == typeof( Texture ),
			_ => false,
		};
	}
}