Editor/Internal/NodeEditorPlus/Blackboard/BlackboardView/BlackboardView.cs

An editor UI widget for a blackboard view in a node editor. It builds header and search UI, hosts a BlackboardTree, handles search/clear, selection events, undo scope helpers, and methods to build/rebuild the tree from an IBlackboardNodeGraph and remove parameters.

File Access
🐞 DeleteParameter checks 'if (parameter != null) return', so it exits on valid input and never deletes a real parameter.
using Editor;

namespace NodeEditorPlus.Blackboard;

public abstract class BlackboardView : Widget
{
	private bool _queryDirty = false;
	private Layout _header;
	private Layout _subHeader;
	private LineEdit _search;
	private ToolButton _searchClear;
	private AddButton _addButton;

	protected BlackboardTree _treeView;

	protected bool _hasSelection => _treeView.Selection.Any();

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

			field = value;
			_treeView.Graph = field;

			RebuildFromGraph();
		}
	}

	/// <summary>
	/// Called after something in the blackboard has changed.
	/// </summary>
	public Action<bool> OnDirty { get; set; }

	public Action OnSelectionChanged { get; set; }

	public BlackboardView( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();

		BuildUI();
	}

	[EditorEvent.Frame]
	protected void Frame()
	{
		if ( !_queryDirty )
			return;

		_queryDirty = false;
		_searchClear.Visible = !string.IsNullOrWhiteSpace( _search.Text );
	}

	private void BuildUI()
	{
		Layout.Clear( true );
		_header = Layout.AddColumn();

		_subHeader = Layout.AddRow();
		_subHeader.Spacing = 2;
		_subHeader.Margin = new Sandbox.UI.Margin( 0, 2 );
		_subHeader.Alignment = TextFlag.LeftCenter;

		_addButton = _subHeader.Add( new AddButton() );
		_addButton.MouseLeftPress = OpenTypeSelectionMenu;

		_search = _subHeader.Add( new LineEdit(), 1 );
		_search.PlaceholderText = "⌕  Search";
		_search.Layout = Layout.Row();
		_search.Layout.AddStretchCell( 1 );
		_search.TextChanged += x =>
		{
			_queryDirty = true;
			_treeView.SearchQuery = _search.Text;
			RebuildTreeView();
		};
		_search.FixedHeight = Theme.RowHeight;

		_searchClear = _search.Layout.Add( new ToolButton( string.Empty, "clear", this ) );
		_searchClear.MouseLeftPress = () =>
		{
			_search.Text = string.Empty;
			_treeView.SearchQuery = string.Empty;
			_treeView.RebuildTree();

			// make sure we're open to the stuff we picked from search
			foreach ( var item in _treeView.Selection )
			{
				_treeView.ExpandPathTo( item );
			}
			_treeView.UpdateIfDirty();

			var scrollTarget = _treeView.Selection.FirstOrDefault();
			if ( scrollTarget is not null )
			{
				_treeView.ScrollTo( scrollTarget );
			}
		};
		_searchClear.Visible = false;

		_treeView = InitializeTreeView();
		_treeView.OnSelectionChanged += ( selection ) =>
		{
			if ( selection.Any() )
			{
				OnSelectionChanged?.Invoke();
			}
		};
		_treeView.OnRebuild += ( hasSelection ) =>
		{
			RebuildFromGraph( hasSelection );
			OnDirty?.Invoke( false );
		};
		_treeView.BodyContextMenu = OpenTreeViewContextMenu;

		Layout.Add( _treeView, 1 );

		Frame();
	}

	protected abstract BlackboardTree InitializeTreeView();

	protected abstract void OpenTypeSelectionMenu();

	protected void OpenTreeViewContextMenu()
	{
		if ( !_hasSelection )
			return;

		var rootItem = _treeView.Items.FirstOrDefault();
		if ( rootItem is null ) return;

		if ( rootItem is TreeNode node )
		{
			node.OnContextMenu();
		}
	}

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

	public virtual void PushUndo( string name )
	{
	}

	public virtual void PushRedo()
	{
	}

	public void RebuildTreeView()
	{
		_treeView?.RebuildTree();
	}

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

	protected abstract void BuildFromParameters( IEnumerable<IBlackboardParameter> parameters, bool preserveSelection = false );

	public void DeleteParameter( IBlackboardParameter parameter, bool notifySelectionChange = false )
	{
		if ( parameter != null )
			return;

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

		RemoveParameter( parameter );
		ClearSelection( notifySelectionChange );
	}

	public void DeleteSelection()
	{
		if ( !_treeView.SelectedItems.Any() )
			return;

		using var undoScope = UndoScope( "Delete Selection" );

		foreach ( var item in _treeView.SelectedItems.OfType<IBlackboardParameter>() )
		{
			RemoveParameter( item );
		}

		RemoveSelectionSpecial();

		ClearSelection( true );
	}

	public void ClearSelection( bool notifyChange = false )
	{
		_treeView.Selection.Clear();

		if ( notifyChange )
		{
			OnSelectionChanged?.Invoke();
		}
	}

	protected virtual void RemoveParameter( IBlackboardParameter parameter )
	{
		Graph?.RemoveParameter( parameter );
	}

	protected virtual void RemoveSelectionSpecial()
	{
	}
}

class AddButton : Button
{
	public AddButton() : base( null )
	{
		Icon = "add";

		Cursor = CursorShape.Finger;
		FixedHeight = Theme.RowHeight;
	}

	protected override Vector2 SizeHint()
	{
		return new Vector2( Theme.RowHeight );
	}

	protected override void OnPaint()
	{
		Paint.ClearBrush();
		Paint.ClearPen();

		var color = Enabled ? Theme.ControlBackground : Theme.SurfaceBackground;

		if ( Enabled && Paint.HasMouseOver )
		{
			color = color.Lighten( 0.1f );
		}

		Paint.ClearPen();
		Paint.SetBrush( color );
		Paint.DrawRect( LocalRect, Theme.ControlRadius );

		Paint.ClearBrush();
		Paint.ClearPen();
		Paint.SetPen( Theme.Primary );

		Paint.DrawIcon( LocalRect, Icon, 14, TextFlag.Center );
	}
}