Editor/Hierarchy/HierarchyPanel.cs
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Diagnostics;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Hierarchy;
public sealed class HierarchyPanel : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
// Primary (last-clicked) selection. Drives inspector + canvas highlight.
public Action<ControlRecord> RecordSelected;
public Action<IReadOnlyList<ControlRecord>> SelectionChanged;
public Action<ControlRecord> RecordMoved;
public Action<IReadOnlyList<ControlRecord>> RecordsDeleteRequested;
public Action<ControlRecord> RecordCreated;
public Action<IReadOnlyList<ControlRecord>> RecordsCutRequested;
public Action<IReadOnlyList<ControlRecord>> RecordsCopyRequested;
public Action<IReadOnlyList<ControlRecord>> RecordsSaveAsTemplateRequested;
// (template, parent, index). Window calls Document.AddTemplate then RepopulateAndFocus.
public Action<Grains.RazorDesigner.Templates.PaletteTemplate, ControlRecord, int> TemplateDropRequested;
// Argument may be RootRecord. Window decides insertion point.
public Action<ControlRecord> RecordPasteRequested;
// (record, newName). Window validates + propagates.
public Action<ControlRecord, string> RecordRenameRequested;
// (type, parent or null = root). Window adds + selects.
public Action<ControlType, ControlRecord> RecordAddRequested;
private readonly DesignerDocument _document;
private readonly TreeView _tree;
private readonly LineEdit _filterEdit;
private string _filterText = "";
// Records the user-typed filter matches OR the ancestors of those matches; used by ShouldDisplayChild.
private readonly HashSet<ControlRecord> _filterMatches = new();
// Guards programmatic Highlight from re-emitting via ItemSelected / ItemsSelected.
private bool _suppressSelectionEvents;
public HierarchyPanel( Widget parent, DesignerDocument document ) : base( parent )
{
_document = document;
Log.Info( $"{LogPrefix} HierarchyPanel ctor" );
Layout = Layout.Column();
Layout.Margin = 0;
var toolbar = new ToolBar( this );
toolbar.SetIconSize( 16 );
_filterEdit = new LineEdit( toolbar ) { PlaceholderText = "Filter..." };
_filterEdit.TextEdited += OnFilterEdited;
toolbar.AddWidget( _filterEdit );
toolbar.AddOption( null, "add", OpenAddMenu ).ToolTip = "Add control to the canvas";
Layout.Add( toolbar );
_tree = new TreeView( this );
_tree.MultiSelect = true;
_tree.ItemSelected += OnTreeItemSelected;
_tree.OnSelectionChanged += OnTreeItemsSelected;
_tree.ShouldDisplayChild = ShouldDisplayNode;
Layout.Add( _tree, 1 );
}
internal DesignerDocument Document => _document;
internal void NotifyMoved( ControlRecord record ) => RecordMoved?.Invoke( record );
internal void NotifyDeleteRequested( IReadOnlyList<ControlRecord> records ) => RecordsDeleteRequested?.Invoke( records );
internal void NotifyCreated( ControlRecord record ) => RecordCreated?.Invoke( record );
internal void NotifyCutRequested( IReadOnlyList<ControlRecord> records ) => RecordsCutRequested?.Invoke( records );
internal void NotifyCopyRequested( IReadOnlyList<ControlRecord> records ) => RecordsCopyRequested?.Invoke( records );
internal void NotifySaveAsTemplateRequested( IReadOnlyList<ControlRecord> records ) => RecordsSaveAsTemplateRequested?.Invoke( records );
internal void NotifyTemplateDropRequested( Grains.RazorDesigner.Templates.PaletteTemplate template, ControlRecord parent, int index )
=> TemplateDropRequested?.Invoke( template, parent, index );
internal void NotifyPasteRequested( ControlRecord record ) => RecordPasteRequested?.Invoke( record );
internal void NotifyRenameRequested( ControlRecord record, string newName ) => RecordRenameRequested?.Invoke( record, newName );
public IReadOnlyList<ControlRecord> SelectedRecords
{
get
{
var raw = new List<ControlRecord>();
foreach ( var item in _tree.SelectedItems )
{
if ( item is ControlRecord rec )
raw.Add( rec );
}
return _document.ParentDedupe( raw );
}
}
private void OnTreeItemSelected( object value )
{
if ( _suppressSelectionEvents ) return;
if ( value is ControlRecord rec )
{
Log.Info( $"{LogPrefix} Hierarchy row -> {rec.ClassName}" );
RecordSelected?.Invoke( rec );
}
}
private void OnTreeItemsSelected( object[] items )
{
if ( _suppressSelectionEvents ) return;
var deduped = SelectedRecords;
Log.Info( $"{LogPrefix} Hierarchy multi-selection: {deduped.Count} record(s)" );
SelectionChanged?.Invoke( deduped );
}
// Renders `root` as the single top-level node labelled "Canvas".
public void SetRoot( ControlRecord root )
{
var probeSw = PerfProbes.Enabled ? System.Diagnostics.Stopwatch.StartNew() : null;
RecomputeFilterMatches();
_tree.Clear();
if ( root is null )
{
Log.Warning( $"{LogPrefix} HierarchyPanel.SetRoot: root is null; tree is empty" );
return;
}
Log.Info( $"{LogPrefix} HierarchyPanel.SetRoot: root={root.ClassName}, children={root.Children.Count}" );
_tree.AddItem( new ControlRecordTreeNode( root, this, isCanvasRoot: true ) );
if ( probeSw != null )
Log.Info( $"{LogPrefix} HierarchyPanel.SetRoot: {probeSw.Elapsed.TotalMilliseconds:F2}ms (children={root.Children.Count})" );
}
public void Highlight( ControlRecord record )
{
_suppressSelectionEvents = true;
try
{
_tree.SelectItem( record, false );
}
finally
{
_suppressSelectionEvents = false;
}
}
// Repaint after in-place mutation; OnPaint reads Value directly so no rebuild needed.
public void NodeChanged( ControlRecord record )
{
if ( record is null ) return;
// Keep filter match-set in sync with the new ClassName.
RecomputeFilterMatches();
_tree.Update();
}
private void OnFilterEdited( string text )
{
_filterText = text ?? "";
RecomputeFilterMatches();
_tree.Update();
}
// Build the set of records to display: each match plus its ancestor chain so they remain visible.
private void RecomputeFilterMatches()
{
_filterMatches.Clear();
if ( string.IsNullOrEmpty( _filterText ) ) return;
var ft = _filterText.ToLowerInvariant();
foreach ( var r in _document.WalkAll() )
{
var name = r.ClassName?.ToLowerInvariant() ?? "";
var typeName = r.Type.ToString().ToLowerInvariant();
if ( !name.Contains( ft ) && !typeName.Contains( ft ) ) continue;
for ( var node = r; node is not null && node != _document.RootRecord; node = _document.FindParent( node ) )
_filterMatches.Add( node );
}
}
private bool ShouldDisplayNode( object item )
{
// TreeView passes the TreeNode here; canvas root is always visible.
if ( item is ControlRecordTreeNode node )
{
if ( node.IsCanvasRoot ) return true;
if ( string.IsNullOrEmpty( _filterText ) ) return true;
return node.Value is ControlRecord rec && _filterMatches.Contains( rec );
}
return true;
}
private void OpenAddMenu()
{
var menu = new Menu( this );
foreach ( var type in System.Enum.GetValues<ControlType>() )
{
var icon = ContractScanner.Table.Get( type ).InspectorIcon;
var captured = type;
menu.AddOption( type.ToString(), icon, () =>
{
Log.Info( $"{LogPrefix} Hierarchy Add menu -> {captured}" );
RecordAddRequested?.Invoke( captured, null );
} );
}
menu.OpenAtCursor( false );
}
}