Editor/ShaderGraphPlus/Blackboard/BlackboardView.cs

Editor UI for the ShaderGraphPlus blackboard. It builds and manages the parameter list UI: filter, grouping, create/rename/move/delete parameters and groups, drag-and-drop reordering, undo/redo hooks, and painting of rows and group headers.

File AccessNetworking
using Editor;
using static ShaderGraphPlus.ShaderGraphPlusGlobals;

namespace ShaderGraphPlus;

public class BlackboardView : Widget
{
	private readonly UndoStack _undoStack;
	private readonly MainWindow _window;
	private readonly LineEdit _filter;
	private readonly Widget _rowsCanvas;
	private readonly Layout _rows;

	private readonly List<IParameterRow> _rowWidgets = new();

	private readonly Dictionary<string, IBlackboardParameterType> _availableParameters = new( StringComparer.OrdinalIgnoreCase );

	//private readonly ToolButton _deleteUnusedButton;
	private readonly HashSet<string> _collapsedGroups;
	private bool _hasVisibleParameters;

	private string EmptyHint => (Graph?.Parameters.Count() ?? 0) > 0
	? "No matching parameters"
	: "No parameters\nClick + to add one";

	protected virtual string CollapsedGroupsCookie => EditorCookieNames.ShaderGraphPlusBlackboardCollapsedGroupsCookie;

	public ShaderGraphPlus Graph
	{
		get => field;
		set
		{
			if ( field == value ) return;

			field = value;

			RebuildFromGraph();
		}
	}

	public Action<bool> OnDirty { get; set; }

	public Action OnParameterNodesDeleted { get; set; }

	public BlackboardView( MainWindow window ) : base( null )
	{
		_window = window;
		_undoStack = window.UndoStack;
		_collapsedGroups = EditorCookie.Get<List<string>>( CollapsedGroupsCookie, [] ).ToHashSet( StringComparer.OrdinalIgnoreCase );

		Name = "Blackboard";
		WindowTitle = "Blackboard";
		SetWindowIcon( "tune" );

		Layout = Layout.Column();

		var bar = new Widget( this );
		bar.FixedHeight = Theme.RowHeight + 2;
		bar.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.SurfaceBackground );
			Paint.DrawRect( bar.LocalRect );
			return true;
		};

		var toolbar = bar.Layout = Layout.Row();
		toolbar.Margin = new Sandbox.UI.Margin( 0, 0, 0, 2 );
		toolbar.Spacing = 2;
		Layout.Add( bar );

		_filter = toolbar.Add( new LineEdit() { PlaceholderText = "⌕  Filter..", ClearButtonEnabled = true, FixedHeight = Theme.RowHeight }, 1 );
		_filter.TextEdited += _ => BuildFromParameters( Graph.Parameters );

		ToolButton addButton = null;
		addButton = toolbar.Add( HeaderButton( "add",
			"<b>Add Parameter</b>",
			() => OpenAddMenu( addButton ) ) );

		var panel = Layout.Add( new Widget( this ) { Layout = Layout.Column() }, 1 );
		panel.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.WidgetBackground.Darken( 0.1f ) );
			Paint.DrawRect( panel.LocalRect, Theme.ControlRadius );

			if ( !_hasVisibleParameters )
			{
				Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
				Paint.SetDefaultFont();
				Paint.DrawText( panel.LocalRect, EmptyHint, TextFlag.Center );
			}

			return true;
		};

		var scroll = panel.Layout.Add( new ScrollArea( panel ), 1 );
		scroll.VerticalScrollbarMode = ScrollbarMode.Auto;
		scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
		scroll.TranslucentBackground = true;
		scroll.NoSystemBackground = true;

		// Scope to the scroll area itself, otherwise the rules cascade into every
		// descendant - including the QToolTip popups shown for the rows inside it.
		scroll.Name = "ShaderGraphPlusParameterScroll";
		scroll.SetStyles( "#AnimGraphParameterScroll { border: none; background: transparent; }" );
		scroll.Canvas = new Widget( scroll )
		{
			Layout = Layout.Column(),
			HorizontalSizeMode = SizeMode.Flexible,
			VerticalSizeMode = SizeMode.CanGrow,
			TranslucentBackground = true,
			NoSystemBackground = true,
		};
		scroll.Canvas.Layout.Spacing = 0;

		_rowsCanvas = scroll.Canvas;
		_rows = scroll.Canvas.Layout;
	}

	public void AddParameterType<T>() where T : BlackboardParameter
	{
		AddParameterType( EditorTypeLibrary.GetType<T>() );
	}

	public void AddParameterType( TypeDescription type )
	{
		var parameterType = ClassBlackboardParameterType.HookupParameterType( type );

		_availableParameters.TryAdd( parameterType.Identifier, parameterType );
	}

	internal IDisposable UndoScope( string name )
	{
		PushUndo( name );
		return new Sandbox.Utility.DisposeAction( () => PushRedo() );
	}

	public void PushUndo( string name )
	{
		SGPLogger.Info( $"Push Undo ({name})" );
		_undoStack.PushUndo( name, Graph.UndoStackSerialize() );
		_window.OnUndoPushed();
	}

	public void PushRedo()
	{
		SGPLogger.Info( "Push Redo" );
		_undoStack.PushRedo( Graph.UndoStackSerialize() );
		_window.SetDirty();
	}

	private void BuildFromParameters( IEnumerable<IBlackboardParameter> parameters, bool preserveSelection = false )
	{
		// Only the rows, so a filter being typed into isn't hidden along with them and loses focus
		using var _ = SuspendUpdates.For( _rowsCanvas );

		_rows.Clear( true );
		_rowWidgets.Clear();
		var groups = FilteredGroups( parameters ).ToList();
		_hasVisibleParameters = groups.Any();
		var filtering = !string.IsNullOrWhiteSpace( _filter.Text );

		foreach ( var groupedParameter in groups.OrderBy( x => Graph.GetGroupDataIndex( string.IsNullOrWhiteSpace( x.Key ) ? BlackboardGlobals.EmptyGroupName : x.Key ) ) )
		{
			var collapsed = !filtering && _collapsedGroups.Contains( groupedParameter.Key );

			var groupName = string.IsNullOrWhiteSpace( groupedParameter.Key ) ? BlackboardGlobals.EmptyGroupName : groupedParameter.Key;

			//SGPLogger.Info( $"Setting up group \"{groupName}\"" );

			_rows.Add( new ParameterGroupHeader( this, Graph.FindGroupData( groupName ), groupedParameter.Count(), collapsed, !filtering ) );

			if ( collapsed )
				continue;

			var body = new Widget
			{
				Layout = Layout.Column(),
				HorizontalSizeMode = SizeMode.Flexible,
				VerticalSizeMode = SizeMode.CanShrink
			};
			body.Layout.Margin = new Sandbox.UI.Margin( 4, 4, 12, 4 );
			body.Layout.Spacing = 2;
			body.OnPaintOverride = () =>
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.WidgetBackground.Darken( 0.1f ) );
				Paint.DrawRect( body.LocalRect );
				return true;
			};
			_rows.Add( body, 0 );

			foreach ( var parameter in groupedParameter )
			{
				IParameterRow row = parameter switch
				{
					BlackboardParameter bpParameter => new ParameterRow( _window, this, bpParameter ),
					_ => throw new NotSupportedException()
				};
				_rowWidgets.Add( row );
				body.Layout.Add( (Widget)row );
			}
		}

		_rows.AddStretchCell();
	}

	public void RebuildFromGraph( bool preserveSelection = false )
	{
		if ( Graph is not null )
			BuildFromParameters( Graph.Parameters, preserveSelection );
	}

	internal void RebuildParameterOrder()
	{
		var parameterReferences = new HashSet<Guid>();

		// Group 1 : 0,1 ---> 0,1
		// Group 2 : 0,1,2,3 ---> 2,3,4,5
		// Group 3 : 0 ---> 6
		// Resulting new index's for each parameter in Graph.Parameters : 0,1,2,3,4,5,6

		foreach ( var group in Graph.GroupData )
		{
			foreach ( var parameterReference in group.ParameterReferences )
			{
				var parameter = Graph.FindParameter( parameterReference );

				if ( parameterReferences.Add( parameterReference ) )
				{
					/*
					var currentIndex = parameterReferences.Count;
					SGPLogger.Info( $"Parameter \"{parameter.Name}\" at index \"{( currentIndex > 0 ? currentIndex - 1 : currentIndex )}\"" );
					*/
				}
			}
		}

		foreach ( var (parameterIndex, parameterIdentifier) in parameterReferences.Index() )
		{
			Graph.ReOrderParameter( parameterIdentifier, parameterIndex );
		}
	}

	/// <summary>
	/// Repaint rows so selection highlights stay in sync with the properties target.
	/// </summary>
	internal void UpdateSelection()
	{
		foreach ( var row in _rowWidgets )
			((Widget)row).Update();
	}

	private record GroupEntry( IGroupableBlackboardParameter Parameter, string GroupName );

	private IEnumerable<IGrouping<string, IGroupableBlackboardParameter>> FilteredGroups( IEnumerable<IBlackboardParameter> parameters )
	{
		var bpParameters = (parameters.Cast<IGroupableBlackboardParameter>() ?? []);
		var filter = _filter.Text?.Trim() ?? "";

		var result = bpParameters.Where( p => string.IsNullOrEmpty( filter )
			|| p.DisplayInfo.Name.Contains( filter, StringComparison.OrdinalIgnoreCase )
			|| p.Name.Contains( filter, StringComparison.OrdinalIgnoreCase )
			|| GroupTitle( GroupName( p ) ).Contains( filter, StringComparison.OrdinalIgnoreCase ) )
			.OrderBy( p => Graph.GetParameterIndexInGroup( GroupName( p ), p.Identifier ) );

		return result
			.GroupBy( GroupName )
			.OrderBy( group => string.IsNullOrEmpty( group.Key ) ? 0 : 1 )
			.ThenBy( group => group.Key, StringComparer.OrdinalIgnoreCase );
	}

	private IEnumerable<IGroupableBlackboardParameter> DisplayedParameters( IEnumerable<IBlackboardParameter> parameters )
	{
		var filtering = !string.IsNullOrWhiteSpace( _filter.Text );
		foreach ( var group in FilteredGroups( parameters ) )
		{
			if ( !filtering && _collapsedGroups.Contains( group.Key ) )
				continue;

			foreach ( var parameter in group )
				yield return parameter;
		}
	}

	/// <summary>
	/// Get a normalized version of the <paramref name="parameter"/> name.
	/// </summary>
	/// <param name="parameter"></param>
	/// <returns>The <paramref name="parameter"/> name as-is, or if the <paramref name="parameter"/> is named "General" just an empty string.</returns>
	internal static string GroupName( IGroupableBlackboardParameter parameter ) => NormalizeGroup( parameter.Group );

	/// <summary>
	/// Get a non null, empty or just whitespace version of the <paramref name="groupName"/>.
	/// </summary>
	/// <param name="groupName"></param>
	/// <returns>The <paramref name="groupName"/> if it isnt null, empty or just whitespace, otherwise the empty group name.</returns>
	internal static string GroupTitle( string groupName ) => string.IsNullOrWhiteSpace( groupName ) ? BlackboardGlobals.EmptyGroupName : groupName;

	internal void Remove( BlackboardParameter parameter )
	{
		var parameterNodes = Graph.Nodes.OfType<IBlackboardNode>()
			.Where( node => node.ParameterIdentifier == parameter.Identifier )
			.ToArray();

		void Delete()
		{
			using var undoScope = UndoScope( "Remove Parameter" );

			foreach ( var node in parameterNodes )
			{
				Graph.RemoveNode( (BaseNodePlus)node );
			}

			if ( parameterNodes.Any() )
			{
				OnParameterNodesDeleted?.Invoke();
			}

			Graph.RemoveParameter( parameter );

			RemoveParameterFromGroupData( parameter );

			RebuildParameterOrder();

			//OnDirty?.Invoke( true );
		}

		var referenceCount = parameterNodes.Length;
		if ( referenceCount == 0 )
		{
			Delete();
			return;
		}

		var usages = new List<string>();
		if ( parameterNodes.Length > 0 )
			usages.Add( $"'{parameterNodes.Length}' parameter node{(parameterNodes.Length == 1 ? "" : "s")}" );

		var confirm = new PopupWindow(
			"Delete Parameter",
			$"Delete '{parameter.Name}'?\n\nIs referenced by {string.Join( " and ", usages )}. These references will be cleared.",
			"Cancel",
			new Dictionary<string, Action> { ["Delete"] = Delete } );
		confirm.Show();
		confirm.Window.Position = _window.ScreenRect.Center - confirm.Window.Size * 0.5f;
	}

	internal static SerializedProperty ResolveParameterProperty( SerializedProperty property )
	{
		while ( property?.Parent?.ParentProperty is { } parent )
			property = parent;

		return property;
	}

	internal void Rename( IBlackboardParameter parameter, string name )
	{
		name = name?.Trim();

		if ( string.IsNullOrEmpty( name ) || name == parameter.Name )
			return;

		if ( Graph.Parameters.Any( p => p != parameter && p.Name.Equals( name, StringComparison.OrdinalIgnoreCase ) ) )
			return;

		using var undoScope = UndoScope( "Rename Parameter" );

		parameter.Name = name;

		_window.OnParameterSelected( parameter );
	}

	internal void BeginRename( IBlackboardParameter parameter )
	=> _rowWidgets.FirstOrDefault( r => r.Parameter == parameter )?.StartRename();

	internal void ToggleGroup( string group )
	{
		if ( !string.IsNullOrWhiteSpace( _filter.Text ) )
			return;

		if ( !_collapsedGroups.Add( group ) )
			_collapsedGroups.Remove( group );

		SaveCollapsedGroups();
		BuildFromParameters( Graph.Parameters );
	}

	internal void MoveToGroup( IGroupableBlackboardParameter parameter, string targetGroupName )
	{
		targetGroupName = NormalizeGroup( targetGroupName );
		if ( GroupName( parameter ).Equals( targetGroupName, StringComparison.OrdinalIgnoreCase ) )
			return;

		using var undoScope = UndoScope( "Move Parameter" );

		var sourceGroupName = parameter.Group;
		ExpandGroup( targetGroupName );
		PruneCollapsedGroups();

		SetGroupData( sourceGroupName, targetGroupName, parameter );

		RebuildParameterOrder();

		_window.OnParameterSelected( parameter );
	}

	internal void ReorderGroup( GroupData group, int priority )
	{
		using var undoScope = UndoScope( "Move Parameter Group" );

		Graph.ReOrderGroup( group, priority );

		RebuildParameterOrder();

		RebuildFromGraph();
	}

	internal void RenameGroup( string oldName )
	{
		if ( string.IsNullOrEmpty( oldName ) )
			return;

		OpenGroupDialog( "Rename Parameter Group", oldName, newName =>
		{
			newName = NormalizeGroup( newName );
			if ( newName.Equals( oldName, StringComparison.OrdinalIgnoreCase ) )
				return;

			using var undoScope = UndoScope( "Rename Parameter Group" );

			foreach ( var parameter in Graph.Parameters.Where( p => GroupName( p ).Equals( oldName, StringComparison.OrdinalIgnoreCase ) ) )
			{
				SetGroupData( oldName, newName, parameter, true );
			}

			if ( GroupTitle( newName ).Equals( BlackboardGlobals.EmptyGroupName, StringComparison.OrdinalIgnoreCase ) )
			{
				Graph.ReOrderGroup( BlackboardGlobals.EmptyGroupName, 0 );
			}

			RebuildParameterOrder();

			if ( _collapsedGroups.Remove( oldName ) && !string.IsNullOrEmpty( newName ) )
				_collapsedGroups.Add( newName );

			PruneCollapsedGroups();
		} );
	}

	internal void ClearGroup( string group )
	{
		if ( string.IsNullOrEmpty( group ) )
			return;

		using var undoScope = UndoScope( "Remove Parameter Group" );

		foreach ( var parameter in Graph.Parameters.Where( p => GroupName( p ).Equals( group, StringComparison.OrdinalIgnoreCase ) ) )
		{
			// Migrate each parameter to the 'General' group.
			SetGroupData( parameter.Group, "", parameter );
		}

		RebuildParameterOrder();

		_collapsedGroups.Remove( group );
		SaveCollapsedGroups();
	}

	internal void AddGroupOptions( Menu menu, IGroupableBlackboardParameter parameter )
	{
		var groups = menu.AddMenu( "Move to Group", "folder" );
		groups.AddOption( BlackboardGlobals.EmptyGroupName, string.IsNullOrEmpty( GroupName( parameter ) ) ? "check" : "", () => MoveToGroup( parameter, "" ) );

		foreach ( var group in ExistingGroups() )
		{
			groups.AddOption( group, GroupName( parameter ).Equals( group, StringComparison.OrdinalIgnoreCase ) ? "check" : "", () => MoveToGroup( parameter, group ) );
		}

		groups.AddSeparator();
		groups.AddOption( "New Group…", "create_new_folder", () => OpenGroupDialog( "New Parameter Group", "", group => MoveToGroup( parameter, group ) ) );
	}

	private IEnumerable<string> ExistingGroups() => Graph.Parameters.OfType<IGroupableBlackboardParameter>()
	.Select( GroupName )
	.Where( group => !string.IsNullOrEmpty( group ) )
	.Distinct( StringComparer.OrdinalIgnoreCase )
	.OrderBy( group => group, StringComparer.OrdinalIgnoreCase );

	private void OpenGroupDialog( string title, string initialValue, Action<string> accepted )
	{
		Dialog.AskString(
			name => accepted( name.Trim() ),
			"Group name:",
			okay: "Apply",
			initialName: initialValue,
			title: title,
			minLength: 1
		);
	}

	private void OpenAddMenu( Widget anchor )
	{
		var menu = CreateMenu();
		AddParameterOptions( menu, "" );
		//AddGroupedOptions( menu, false );
		menu.OpenAt( anchor.ScreenRect.BottomLeft );
	}

	private void AddGroupedOptions( Menu menu, bool isVirtual )
	{
		var groups = ExistingGroups().ToList();
		if ( groups.Count == 0 )
			return;

		menu.AddSeparator();
		var grouped = menu.AddMenu( "Add to Group", "folder" );
		foreach ( var group in groups )
		{
			var groupMenu = grouped.AddMenu( group, "folder" );
			AddParameterOptions( groupMenu, group );
		}
	}

	private void RemoveParameterFromGroupData( BlackboardParameter parameter )
	{
		if ( Graph is null )
			return;

		var group = string.IsNullOrWhiteSpace( parameter.Group ) ? BlackboardGlobals.EmptyGroupName : parameter.Group;

		if ( Graph.TryFindGroupData( group, out var groupData ) )
		{
			groupData.ParameterReferences.Remove( parameter.Identifier );

			if ( groupData.ParameterReferences.Count == 0 )
			{
				Graph.RemoveGroupData( groupData );
			}
		}
	}

	private void SetGroupData( string sourceGroupName, string targetGroupName, IGroupableBlackboardParameter parameter, bool renaming = false )
	{
		void RemoveParameterFromGroup( GroupData group )
		{
			group.ParameterReferences.Remove( parameter.Identifier );

			if ( group.ParameterReferences.Count == 0 )
			{
				Graph.RemoveGroupData( group );
			}
		}

		if ( Graph is null )
			return;

		sourceGroupName = GroupTitle( sourceGroupName );
		parameter.Group = targetGroupName;
		targetGroupName = GroupTitle( targetGroupName );

		//SGPLogger.Info( $"Moving Parameter \"{parameter.Name}\" from group \"{sourceGroupName}\" to group \"{targetGroupName}\"" );

		if ( renaming )
		{
			if ( Graph.TryFindGroupData( targetGroupName, out var targetGroupData ) )
			{
				if ( Graph.TryFindGroupData( sourceGroupName, out var sourceGroupData ) )
				{
					RemoveParameterFromGroup( sourceGroupData );
				}

				if ( !targetGroupData.ParameterReferences.Contains( parameter.Identifier ) )
				{
					targetGroupData.ParameterReferences.Add( parameter.Identifier );
				}
			}
			else if ( Graph.TryFindGroupData( sourceGroupName, out var sourceGroupData ) )
			{
				// Just rename the source group to the targetGroupName
				// if a group with the targetGroupName does not exist yet.
				sourceGroupData.Name = targetGroupName;
			}
		}
		else
		{

			if ( Graph.TryFindGroupData( sourceGroupName, out var sourceGroupData ) && !sourceGroupName.Equals( targetGroupName, StringComparison.OrdinalIgnoreCase ) )
			{
				RemoveParameterFromGroup( sourceGroupData );
			}

			if ( Graph.TryFindGroupData( targetGroupName, out var existingGroup ) )
			{
				existingGroup.ParameterReferences.Add( parameter.Identifier );
			}
			else
			{
				var newGroup = new GroupData
				{
					Name = targetGroupName,
					ParameterReferences = [parameter.Identifier]
				};

				Graph.AddGroupData( newGroup, targetGroupName.Equals( BlackboardGlobals.EmptyGroupName, StringComparison.OrdinalIgnoreCase ) ? 0 : -1 );
			}
		}
	}

	private void CreateNewParameter( IBlackboardParameterType type, string group = "" )
	{
		group = NormalizeGroup( group );

		_filter.Text = "";
		ExpandGroup( group );

		using var undoScope = UndoScope( "Add Parameter" );

		var parameter = (BlackboardParameter)type.CreateParameter( Graph );

		if ( parameter is IGroupableBlackboardParameter groupable )
		{
			SetGroupData( group, group, groupable );
		}

		Graph.AddParameter( parameter );

		RebuildParameterOrder();

		OnDirty?.Invoke( true );

		_window.OnParameterSelected( parameter );

		RebuildFromGraph( true );
	}

	public IBlackboardParameter CreateNewParameter( IBlackboardParameterType type, string name = "", Action onCreated = null )
	{
		if ( type == null )
			return null;

		var group = NormalizeGroup( "" );

		_filter.Text = "";
		ExpandGroup( group );

		var parameter = type.CreateParameter( Graph, name );

		if ( parameter == null )
			return null;

		if ( parameter is IGroupableBlackboardParameter groupable )
		{
			SetGroupData( group, group, groupable );
		}

		onCreated?.Invoke();

		Graph.AddParameter( parameter );

		RebuildParameterOrder();

		return parameter;
	}

	internal void AddParameterOptions( Menu menu, string group )
	{
		void AddOption( Menu menu, IBlackboardParameterType parameterType, string icon, string description )
		{
			var option = menu.AddOption( parameterType.Type.Title, !string.IsNullOrWhiteSpace( icon ) ? icon : null, () =>
			{
				CreateNewParameter( parameterType, group );
			} );

			option.ToolTip = description;
		}

		IBlackboardParameterType[] avalibleTypes = BlackboardParameter.GetRelevantParameters( _availableParameters, Graph.IsSubgraph ).ToArray();

		if ( !Graph.IsSubgraph )
		{
			var materialParametersMenu = menu.AddMenu( "Parameter" );
			materialParametersMenu.Icon = "edit_attributes";

			var attributesMenu = menu.AddMenu( "Attribute" );
			attributesMenu.Icon = "edit_attributes";

			var materialCombosMenu = menu.AddMenu( "Combo" );
			materialCombosMenu.Icon = "alt_route";

			foreach ( var parameterType in avalibleTypes.OfType<ClassBlackboardParameterType>().OrderBy( x => x.Type.Order ) )
			{
				var targetType = parameterType.Type.TargetType;
				var icon = parameterType.DisplayInfo.Icon;
				var description = parameterType.DisplayInfo.Description;

				if ( targetType.IsAssignableTo( typeof( IBlackboardMaterialParameter ) ) || targetType.IsAssignableTo( typeof( BlackboardTextureMaterialParameter ) ) )
				{
					menu = materialParametersMenu;
				}
				else if ( targetType.IsAssignableTo( typeof( IBlackboardShaderFeatureParameter ) ) )
				{
					menu = materialCombosMenu;
				}
				else if ( targetType == typeof( SamplerStateParameter ) )
				{
					menu = attributesMenu;
				}

				AddOption( menu, parameterType, icon, description );
			}
		}
		else
		{
			var subgraphInputsMenu = menu.AddMenu( "Input" );
			subgraphInputsMenu.Icon = "input";

			var subgraphOutputsMenu = menu.AddMenu( "Output" );
			subgraphOutputsMenu.Icon = "output";

			foreach ( var parameterType in avalibleTypes.OfType<ClassBlackboardParameterType>().OrderBy( x => x.Type.Order ) )
			{
				var targetType = parameterType.Type.TargetType;
				var icon = parameterType.DisplayInfo.Icon;
				var description = parameterType.DisplayInfo.Description;

				if ( targetType.IsAssignableTo( typeof( IBlackboardSubgraphInputParameter ) ) )
				{
					menu = subgraphInputsMenu;
				}
				else if ( targetType.IsAssignableTo( typeof( IBlackboardSubgraphOutputParameter ) ) )
				{
					menu = subgraphOutputsMenu;
				}

				AddOption( menu, parameterType, icon, description );
			}
		}
	}

	/// <summary>
	/// Get a normalized version of <paramref name="group"/>.
	/// </summary>
	/// <param name="group"></param>
	/// <returns>The <paramref name="group"/> string as-is, or if <paramref name="group"/> is "General" just an empty string.</returns>
	private static string NormalizeGroup( string group )
	{
		group = group?.Trim() ?? "";
		return group.Equals( BlackboardGlobals.EmptyGroupName, StringComparison.OrdinalIgnoreCase ) ? "" : group;
	}

	private void ExpandGroup( string group )
	{
		if ( _collapsedGroups.Remove( group ) )
			SaveCollapsedGroups();
	}

	private void PruneCollapsedGroups()
	{
		var groups = Graph.Parameters.OfType<IGroupableBlackboardParameter>().Select( GroupName ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		_collapsedGroups.RemoveWhere( group => !groups.Contains( group ) );
		SaveCollapsedGroups();
	}

	private void SaveCollapsedGroups()
	=> EditorCookie.Set( CollapsedGroupsCookie, _collapsedGroups.OrderBy( x => x ).ToList() );

	internal void OnGraphDirty()
	{
		if ( Graph is null )
			return;

		var parameters = DisplayedParameters( Graph.Parameters ).ToList();
		var stale = _rowWidgets.Count != parameters.Count;

		for ( var i = 0; !stale && i < _rowWidgets.Count; i++ )
		{
			var p = parameters[i];
			stale = _rowWidgets[i].Parameter != p || _rowWidgets[i].BuiltGroup != GroupName( p );
		}

		if ( stale )
		{
			BuildFromParameters( Graph.Parameters );
			return;
		}

		//UpdateUnusedButton();
		UpdateSelection();
	}

	internal Menu CreateMenu() => new( _window );

	private static void AddDivider( Layout row )
	{
		row.AddSpacingCell( 2 );
		row.Add( new Separator( 1 ) { FixedWidth = 1, FixedHeight = Theme.RowHeight - 8, Color = Color.White.WithAlpha( 0.1f ) } );
		row.AddSpacingCell( 2 );
	}

	private ToolButton HeaderButton( string icon, string toolTip, Action onClick )
	{
		var button = new ToolButton( "", icon, this ) { ToolTip = toolTip, MouseLeftPress = onClick };
		button.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( button.Enabled && Paint.HasMouseOver ? Theme.ControlBackground.Lighten( 0.1f ) : Theme.ControlBackground );
			Paint.DrawRect( button.LocalRect, Theme.ControlRadius );

			Paint.ClearBrush();
			Paint.SetPen( button.Enabled ? Theme.Primary : Theme.TextControl.WithAlpha( 0.25f ) );
			Paint.DrawIcon( button.LocalRect, icon, 14, TextFlag.Center );
			return true;
		};
		return button;
	}
}

/// <summary>
/// DragData when dragging a parameter onto the graph or when reordering it in the blackboard.
/// </summary>
public record ParameterDragData( BlackboardParameter Parameter, GroupData SourceGroup );

/// <summary>
/// DragData when reordering a group in the blackboard.
/// </summary>
public record ParameterGroupDragData( GroupData Group );

internal interface IParameterRow
{
	IGroupableBlackboardParameter Parameter { get; }
	string BuiltGroup { get; }
	void StartRename();
}

internal sealed class ParameterGroupHeader : InspectorHeader
{
	private readonly BlackboardView _blackboardView;
	private readonly string _title;
	private readonly int _count;

	private Vector2? _dragStart;
	private bool _draggingAbove = false;
	private bool _draggingOnto = false;
	private bool _draggingBelow = false;
	private bool _draggingGroup = false;

	public GroupData Group { get; }

	public ParameterGroupHeader( BlackboardView list, GroupData group, int count, bool collapsed, bool collapsible )
	{
		_blackboardView = list;
		Group = group;
		group.Graph = list.Graph;
		_title = BlackboardView.GroupTitle( group.Name );
		_count = count;

		Title = "";
		Icon = "folder";
		Color = Theme.Blue;
		IsDraggable = true;
		AcceptDrops = true;
		IsCollapsable = collapsible;
		IsExpanded = !collapsed;

		BuildUI();

		Cursor = collapsible ? CursorShape.Finger : CursorShape.Arrow;
		AcceptDrops = true;

		ToolTip = collapsible ?
		@$"
			<strong>{_title}</strong><br>
			Click to collapse or expand.<br>
			Drag parameters here to move them into this group.<br>
			{(_title != BlackboardGlobals.EmptyGroupName ? "Drag to reorder this group. " : "")}<br>
			Right-click for group actions.
		" :
		@$"
			<strong>{_title}</strong><br>
			Matching groups are expanded while filtering.<br>
			Drag parameters here to move them into this group.<br>
			{(_title != BlackboardGlobals.EmptyGroupName ? "Drag to reorder this group. " : "")}<br>
			Right-click for group actions.
		";
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.WidgetBackground );
		Paint.DrawRect( LocalRect );

		base.OnPaint();

		var count = _count.ToString();
		Paint.SetDefaultFont( 8, 500 );
		var countMarginY = MathF.Floor( (LocalRect.Height - 18) * 0.5f );
		var countHeight = LocalRect.Height - countMarginY * 2;
		var countWidth = MathF.Max( countHeight, Paint.MeasureText( count ).x + 8 );
		var countRect = new Rect( LocalRect.Right - countWidth - 12, countMarginY + 1, countWidth, countHeight );

		Paint.SetPen( Theme.Text.WithAlpha( IsExpanded || !IsCollapsable ? 1f : 0.8f ) );
		Paint.SetHeadingFont( 11, 440, sizeInPixels: true );
		Paint.DrawText( LocalRect.Shrink( 48, 0, countWidth + 20, 0 ), _title, TextFlag.LeftCenter | TextFlag.SingleLine );

		Paint.ClearPen();
		Paint.SetBrush( Theme.TextControl.WithAlpha( 0.08f ) );
		Paint.DrawRect( countRect, Theme.ControlRadius );
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
		Paint.SetDefaultFont( 8, 500 );
		Paint.DrawText( countRect, count, TextFlag.Center );

		if ( !_draggingGroup && _draggingOnto )
		{
			Paint.ClearBrush();
			Paint.SetPen( Theme.Primary, 1.5f );
			Paint.DrawRect( LocalRect.Shrink( 0.75f ) );
		}
		else
		{
			if ( _draggingAbove )
			{
				Paint.SetPen( Theme.Primary, 2f, PenStyle.Dot );
				Paint.DrawLine( LocalRect.TopLeft, LocalRect.TopRight );
				_draggingAbove = false;
			}
			else if ( _draggingBelow )
			{
				Paint.SetPen( Theme.Primary, 2f, PenStyle.Dot );
				Paint.DrawLine( LocalRect.BottomLeft, LocalRect.BottomRight );
				_draggingBelow = false;
			}
		}
	}

	protected override void BuildRightIcons( Layout layout )
	{
	}

	protected override void OnExpandChanged()
	{
		_blackboardView.ToggleGroup( Group.Name == BlackboardGlobals.EmptyGroupName ? "" : Group.Name );
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( e.LeftMouseButton )
		{
			_dragStart = e.LocalPosition;
		}

		if ( e.RightMouseButton )
			OpenContextMenu();
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		_dragStart = null;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		Update();

		if ( _dragStart is null || !e.ButtonState.HasFlag( MouseButtons.Left ) )
			return;

		if ( e.LocalPosition.Distance( _dragStart.Value ) < 10 )
			return;

		_dragStart = null;

		var drag = new Drag( this );
		drag.Data.Text = Group.Name;
		drag.Data.Object = new ParameterGroupDragData( Group );

		drag.Execute();
	}

	private bool TryDragOperation( DragEvent ev, int sourceParameterIndex, int targetParameterIndex )
	{
		if ( Group.Name.Equals( BlackboardGlobals.EmptyGroupName, StringComparison.OrdinalIgnoreCase ) )
			return false;

		var sourceGroupDragData = ev.Data.OfType<ParameterGroupDragData>().FirstOrDefault();

		if ( sourceGroupDragData is null || Group is null || sourceGroupDragData.Group == Group )
			return false;

		if ( sourceParameterIndex <= targetParameterIndex )
		{
			//SGPLogger.Info( $"Dragging \"{sourceGroupDragData.Group.Name}\" at index \"{sourceParameterIndex}\"  below \"{Group.Name}\" at index \"{targetParameterIndex}\"" );
			_draggingBelow = true;
		}
		else
		{
			//SGPLogger.Info( $"Dragging \"{sourceGroupDragData.Group.Name}\" at index \"{sourceParameterIndex}\" Above \"{Group.Name}\" at index \"{targetParameterIndex}\"" );
			_draggingAbove = true;
		}

		return true;
	}

	public override void OnDragHover( DragEvent ev )
	{
		void OnDrag( DragEvent ev, DropAction dropAction )
		{
			ev.Action = dropAction;
			_draggingOnto = dropAction == DropAction.Move;
		}

		if ( ev.Data.Object is ParameterDragData parameterDragData )
		{
			if ( !BlackboardView.GroupName( parameterDragData.Parameter ).Equals( Group.Name, StringComparison.OrdinalIgnoreCase ) )
			{
				ev.Action = DropAction.Move;
				_draggingOnto = true;
			}
			else
			{
				ev.Action = DropAction.Ignore;
				_draggingOnto = false;
			}

			_draggingGroup = false;
			Update();

			return;
		}
		else if ( ev.Data.Object is ParameterGroupDragData groupDragData )
		{
			if ( groupDragData.Group.Name.Equals( BlackboardGlobals.EmptyGroupName, StringComparison.OrdinalIgnoreCase ) )
			{
				OnDrag( ev, DropAction.Ignore );
				return;
			}

			var sourceGroupIndex = _blackboardView.Graph.GetGroupDataIndex( groupDragData.Group );
			var targetGroupIndex = _blackboardView.Graph.GetGroupDataIndex( Group );

			if ( !TryDragOperation( ev, sourceGroupIndex, targetGroupIndex ) )
			{
				_draggingAbove = false;
				_draggingBelow = false;

				OnDrag( ev, DropAction.Ignore );
				return;
			}
			else
			{
				OnDrag( ev, DropAction.Move );
			}

			_draggingGroup = true;
			Update();

			return;
		}

		OnDrag( ev, DropAction.Ignore );
	}

	public override void OnDragDrop( DragEvent ev )
	{
		_draggingOnto = false;
		Update();

		if ( ev.Data.Object is ParameterDragData parameterDragData )
		{
			_blackboardView.MoveToGroup( parameterDragData.Parameter, Group.Name );
		}
		else if ( ev.Data.Object is ParameterGroupDragData groupDragData )
		{
			_blackboardView.ReorderGroup( groupDragData.Group, _blackboardView.Graph.GetGroupDataIndex( Group ) );
		}
	}

	public override void OnDragLeave()
	{
		_draggingOnto = false;
		Update();
	}

	private void OpenContextMenu()
	{
		var menu = _blackboardView.CreateMenu();
		var parameters = menu.AddMenu( "Add Parameter", "add" );
		_blackboardView.AddParameterOptions( parameters, Group.Name );

		if ( !Group.Name.Equals( "General", StringComparison.OrdinalIgnoreCase ) )
		{
			menu.AddSeparator();
			menu.AddOption( "Rename Group", "edit", () => _blackboardView.RenameGroup( Group.Name ) );
			menu.AddOption( "Remove Group", "folder_off", () => _blackboardView.ClearGroup( Group.Name ) );
		}

		menu.OpenAtCursor();
	}
}

internal class ParameterRow : Widget, IParameterRow
{
	IGroupableBlackboardParameter IParameterRow.Parameter => _parameter;

	private readonly BlackboardParameter _parameter;
	private readonly GroupData _groupData;

	private readonly MainWindow _window;
	private readonly BlackboardView _blackboardView;
	private readonly Widget _nameCell;
	private float _headerHeight = Theme.RowHeight;
	private Vector2? _dragStart;
	private LineEdit _renameEdit;

	private const float NameX = 3;

	private Rect PillRect
	{
		get
		{
			var rect = LocalRect;
			rect.Height = _headerHeight + 6;
			rect = rect.Shrink( 0, 1 );
			rect.Right = _nameCell.Position.x + _nameCell.Width;

			return rect;
		}
	}

	private bool _draggingAbove = false;
	private bool _draggingBelow = false;

	public string BuiltGroup { get; }

	public ParameterRow( MainWindow window, BlackboardView list, BlackboardParameter parameter ) : base( list )
	{
		_window = window;
		_blackboardView = list;
		parameter.Graph = list.Graph;
		_parameter = parameter;
		_groupData = _blackboardView.Graph.FindGroupData( string.IsNullOrWhiteSpace( _parameter.Group ) ? BlackboardGlobals.EmptyGroupName : _parameter.Group );

		BuiltGroup = BlackboardView.GroupName( parameter );

		FixedHeight = Theme.RowHeight + 6;
		Cursor = CursorShape.None;
		MouseTracking = true;
		FocusMode = FocusMode.Click;

		IsDraggable = true;
		AcceptDrops = true;

		ToolTip = @$"
			<strong>{_parameter.DisplayInfo.Name} parameter</strong><br>
			Drag onto the graph to create a node linked to this parameter.<br>
			Drag onto another parameter to reorder.<br>
			Double-click the parameter pill to rename.
		";

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( NameX, 3, NameX, 3 );

		Layout.Spacing = 4f;
		Layout.Alignment = TextFlag.LeftTop;

		_renameEdit = new LineEdit( this ) { Visible = false, FixedHeight = Theme.RowHeight };
		_renameEdit.SetStyles( $"background -color: {Theme.ControlBackground.Hex}; selection-background-color: {Theme.Primary.Hex}; selection-color: white; border: none;" );
		_renameEdit.EditingFinished += FinishRename;

		// The rename edit lives in the name area cell so the layout owns its geometry
		_nameCell = Layout.Add( new Widget( this ) { Layout = Layout.Column(), TransparentForMouseEvents = true, FixedHeight = Theme.RowHeight }, 2 );
		_nameCell.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 2, 0 );
		_nameCell.Layout.AddStretchCell( 1 );
		_nameCell.Layout.Add( _renameEdit );
		_nameCell.Layout.AddStretchCell( 1 );

		var so = parameter.GetSerialized();
		so.OnPropertyChanged += p =>
		{
			p = BlackboardView.ResolveParameterProperty( p );

			// Transient preview scrubs don't touch the document
			if ( p?.HasAttribute<JsonIgnoreAttribute>() != true )
				_window.SetDirty();
		};

		AddActions();
	}

	protected static void PaintTypeLabel( Rect row, string typeName, Color typeColor )
	{
		Color tint = "#48494c";

		var c = tint.ToHsv();
		var bg = c;

		if ( Paint.HasMouseOver )
		{
			bg = c with { Value = (c.Value + 0.1f) };
		}
		else
		{
			bg = c = Theme.SurfaceLightBackground;
		}

		if ( bg.Alpha > 0 )
		{
			float radius = 3;
			Paint.Antialiasing = true;

			Paint.ClearPen();
			Paint.SetBrush( bg with { Value = (bg.Value + 0.04f), Saturation = (c.Saturation * 0.8f) } );
			Paint.DrawRect( row, radius );

			Paint.SetBrushLinear( row.TopLeft, row.BottomRight, bg, bg with { Value = (bg.Value - 0.03f) } );
			Paint.DrawRect( row.Shrink( 1, 1, 1, 1 ), radius );
			Paint.SetPen( typeColor, 1 );
			Paint.DrawRect( row.Shrink( 1, 1, 1, 1 ), radius );

			var r2 = row.Grow( 1.25f );

			Paint.DrawRect( r2.Grow( 12, 0, 0, 0 ), radius );
		}
		else
		{
			c = Color.White.WithAlpha( 0.5f );
		}

		Paint.SetDefaultFont();
		Paint.SetPen( c with { Value = 0.99f, Saturation = c.Saturation * 0.20f } );
		Paint.DrawText( row, typeName );

		var iconRect = row;
		iconRect.Left -= 10;

		Paint.Pen = typeColor;
		Paint.DrawIcon( iconRect, "circle", 12, TextFlag.LeftCenter );
	}

	protected override void OnPaint()
	{
		var selected = _window.IsParameterSelected( _parameter );
		var hovered = PillRect.IsInside( FromScreen( Editor.Application.CursorPosition ) );
		var typeColor = Color.White;

		if ( ShaderGraphPlusTheme.BlackboardConfigs.TryGetValue( _parameter.GetType(), out var blackboardConfig ) )
		{
			typeColor = blackboardConfig.Color;
		}

		var chip = PillRect;

		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.SetBrush( blackboardConfig.Color.WithAlpha( selected ? 0.25f : hovered ? 0.18f : 0.1f ) );
		Paint.DrawRect( chip, Theme.ControlRadius );

		if ( selected )
		{
			Paint.ClearBrush();
			Paint.SetPen( blackboardConfig.Color.WithAlpha( 0.8f ) );
			Paint.DrawRect( chip.Shrink( 0.5f ), Theme.ControlRadius );
		}

		if ( _renameEdit.Visible )
			return;

		var typeName = _parameter.DisplayInfo.Name;
		var typeRectOffset = 24;
		var typeRect = Paint.MeasureText( chip.Shrink( chip.Left + typeRectOffset, 0, 0, 0 ), typeName, TextFlag.LeftCenter | TextFlag.SingleLine ).Grow( 4, 0, 4, 0 );

		PaintTypeLabel( typeRect, typeName, typeColor );

		var nameRectOffset = 8;
		var nameRect = chip.Shrink( typeRect.Right + nameRectOffset, 0, 0, 0 );

		Paint.SetPen( Theme.TextControl.WithAlpha( selected || hovered ? 0.9f : 0.8f ) );
		Paint.SetDefaultFont();
		Paint.DrawText( nameRect, $"{_parameter.Name}", TextFlag.LeftCenter | TextFlag.SingleLine );

		if ( _draggingAbove )
		{
			Paint.SetPen( Theme.Primary, 2f, PenStyle.Dot );
			Paint.DrawLine( LocalRect.TopLeft, LocalRect.TopRight );
			_draggingAbove = false;
		}
		else if ( _draggingBelow )
		{
			Paint.SetPen( Theme.Primary, 2f, PenStyle.Dot );
			Paint.DrawLine( LocalRect.BottomLeft, LocalRect.BottomRight );
			_draggingBelow = false;
		}
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !PillRect.IsInside( e.LocalPosition ) )
			return;

		if ( e.LeftMouseButton )
		{
			_dragStart = e.LocalPosition;
			_window.OnParameterSelected( _parameter );
		}
		else if ( e.RightMouseButton )
		{
			_window.OnParameterSelected( _parameter );
			OpenContextMenu();
		}
	}

	protected override void OnMouseReleased( MouseEvent e ) => _dragStart = null;

	protected override void OnMouseMove( MouseEvent e )
	{
		Cursor = PillRect.IsInside( e.LocalPosition ) ? CursorShape.Finger : CursorShape.None;
		Update();

		if ( _dragStart is null || !e.ButtonState.HasFlag( MouseButtons.Left ) )
			return;

		if ( e.LocalPosition.Distance( _dragStart.Value ) < 10 )
			return;

		_dragStart = null;

		var drag = new Drag( this );
		drag.Data.Text = _parameter.Name;
		drag.Data.Object = new ParameterDragData( _parameter, _groupData );
		drag.Execute();
	}

	protected override void OnMouseLeave()
	{
		Cursor = CursorShape.None;
		Update();
		base.OnMouseLeave();
	}

	protected override void OnDoubleClick( MouseEvent e )
	{
		if ( !e.LeftMouseButton || !PillRect.IsInside( e.LocalPosition ) )
			return;

		_dragStart = null;
		StartRename();
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		switch ( e.Key )
		{
			case KeyCode.F2:
				StartRename();
				break;
			case KeyCode.Delete:
				_blackboardView.Remove( _parameter );
				break;
			case KeyCode.Escape when _renameEdit.Visible:
				_renameEdit.Text = _parameter.Name;
				_renameEdit.Blur();
				break;
			default:
				base.OnKeyPress( e );
				break;
		}
	}

	private bool TryDragOperation( ParameterDragData parameterDragData )
	{
		if ( parameterDragData is null || _parameter is null || parameterDragData.Parameter == _parameter )
			return false;

		void TryDragInGroup( GroupData sharedGroup )
		{
			var sourceIndex = sharedGroup.ParameterReferences.IndexOf( parameterDragData.Parameter.Identifier );
			var targetIndex = sharedGroup.ParameterReferences.IndexOf( _parameter.Identifier );

			if ( sourceIndex < targetIndex )
			{
				_draggingBelow = true;
			}
			else
			{
				_draggingAbove = true;
			}
		}

		void TryDragToOtherGroup( GroupData sourceGroup, GroupData targetGroup )
		{
			var sourceGroupIndex = _blackboardView.Graph.GetGroupDataIndex( sourceGroup );
			var targetGroupIndex = _blackboardView.Graph.GetGroupDataIndex( targetGroup );

			if ( targetGroupIndex > sourceGroupIndex )
			{
				_draggingBelow = true;
			}
			else
			{
				_draggingAbove = true;
			}
		}

		if ( _groupData.Name == parameterDragData.SourceGroup.Name )
		{
			TryDragInGroup( parameterDragData.SourceGroup );
		}
		else if ( _groupData.Name != parameterDragData.SourceGroup.Name )
		{
			TryDragToOtherGroup( parameterDragData.SourceGroup, _groupData );
		}
		else
		{
			return false;
		}

		return true;
	}

	public override void OnDragHover( DragEvent ev )
	{
		base.OnDragHover( ev );

		if ( ev.Data.Object is ParameterDragData parameterDragData )
		{
			if ( !TryDragOperation( parameterDragData ) )
			{
				_draggingAbove = false;
				_draggingBelow = false;
				ev.Action = DropAction.Ignore;

				return;
			}
		}
		else
		{
			ev.Action = DropAction.Ignore;
		}
	}

	public override void OnDragDrop( DragEvent ev )
	{
		base.OnDragDrop( ev );

		if ( ev.Data.Object is not ParameterDragData parameterDragData )
			return;

		var sourceParameter = parameterDragData.Parameter;
		var targetParameter = _parameter;

		var sourceGroupName = BlackboardView.GroupTitle( sourceParameter.Group );
		var targetGroupName = BlackboardView.GroupTitle( _parameter.Group );

		if ( !TryDragOperation( parameterDragData ) )
			return;

		if ( _blackboardView.Graph.TryFindGroupData( sourceGroupName, out var sourceGroupData ) )
		{
			using var undoScope = _blackboardView.UndoScope( "Reorder Parameter" );

			if ( sourceGroupName != targetGroupName && _blackboardView.Graph.TryFindGroupData( targetGroupName, out var targetGroupData ) )
			{
				var targetIndex = targetGroupData.ParameterReferences.IndexOf( targetParameter.Identifier );

				if ( _draggingBelow )
				{
					targetIndex++;
				}

				sourceGroupData.ParameterReferences.Remove( sourceParameter.Identifier );

				if ( sourceGroupData.ParameterReferences.Count == 0 )
				{
					_blackboardView.Graph.RemoveGroupData( sourceGroupData );
				}

				targetGroupData.ParameterReferences.Insert( targetIndex, sourceParameter.Identifier );

				sourceParameter.Group = _parameter.Group;
			}
			else
			{
				var targetIndex = sourceGroupData.ParameterReferences.IndexOf( _parameter.Identifier );

				sourceGroupData.ParameterReferences.Remove( sourceParameter.Identifier );
				sourceGroupData.ParameterReferences.Insert( targetIndex, sourceParameter.Identifier );
			}

			_blackboardView.RebuildParameterOrder();
			_blackboardView.RebuildFromGraph();
		}
	}

	private void AddActions()
	{
		var actionsWidget = Layout.Add( new Widget( this ) { Layout = Layout.Row(), FixedHeight = _headerHeight } );
		var actions = actionsWidget.Layout;
		actions.Alignment = TextFlag.LeftTop;
		AddActions( actions );
	}

	private void AddActionColumn()
	{
		var column = Layout.AddColumn();
		AddActions( column.AddRow() );
		column.AddStretchCell();
	}

	private void AddActions( Layout actions )
	{
		actions.Add( new IconButton( "delete", () => _blackboardView.Remove( _parameter ), this )
		{
			ToolTip = "Delete parameter",
			IconSize = 16,
			Foreground = Theme.Red
		} );
	}

	public void StartRename()
	{
		if ( _renameEdit.Visible )
			return;

		_nameCell.TransparentForMouseEvents = false;
		_renameEdit.Text = _parameter.Name;
		_renameEdit.Visible = true;
		_renameEdit.SelectAll();
		_renameEdit.Focus();
	}

	private void FinishRename()
	{
		if ( !_renameEdit.Visible )
			return;

		_renameEdit.Visible = false;
		_nameCell.TransparentForMouseEvents = true;
		_blackboardView.Rename( _parameter, _renameEdit.Text );
		Update();
	}

	private void OpenContextMenu()
	{
		var menu = _blackboardView.CreateMenu();
		menu.AddOption( "Rename", "edit", StartRename, "F2" );
		_blackboardView.AddGroupOptions( menu, _parameter );

		menu.AddSeparator();
		menu.AddOption( "Delete", "delete", () => _blackboardView.Remove( _parameter ), "Del" );
		menu.OpenAtCursor();
	}
}