Editor/Hierarchy/ControlRecordTreeNode.cs
using System.Collections.Generic;
using System.Linq;
using Editor;
using static Editor.BaseItemWidget;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Hierarchy;
public sealed class ControlRecordTreeNode : TreeNode<ControlRecord>
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public bool IsCanvasRoot { get; init; }
private readonly HierarchyPanel _hierarchy;
public ControlRecordTreeNode( ControlRecord record, HierarchyPanel hierarchy, bool isCanvasRoot = false )
{
Value = record;
_hierarchy = hierarchy;
IsCanvasRoot = isCanvasRoot;
}
// ClassName excluded: engine TreeNode.Think auto-rebuilds on hash change without clearing children first; BuildChildren would duplicate. Repaint via HierarchyPanel.NodeChanged instead.
public override int ValueHash => System.HashCode.Combine( Value?.Type, Value?.Children.Count, IsCanvasRoot );
public override bool CanEdit => !IsCanvasRoot && Value is not null;
// Engine TreeView reads this to seed the F2 rename popup. Setter is unused; OnRename owns the write path.
public override string Name
{
get => Value?.ClassName ?? "";
set { /* see OnRename */ }
}
public override void OnRename( VirtualWidget item, string text, List<TreeNode> selection = null )
{
if ( Value is null ) return;
Log.Info( $"{LogPrefix} TreeNode.OnRename: {Value.ClassName} -> {text}" );
_hierarchy?.NotifyRenameRequested( Value, text );
}
protected override void BuildChildren()
{
if ( Value is null ) return;
foreach ( var child in Value.Children )
AddItem( new ControlRecordTreeNode( child, _hierarchy ) );
}
public override bool HasChildren => Value is not null && Value.Children.Count > 0;
public override bool OnDragStart()
{
if ( IsCanvasRoot || Value is null ) return false;
if ( Value.IsSlot ) return false; // slots are structural; can't be moved
var drag = new Drag( TreeView );
drag.Data.Object = Value;
drag.Execute();
Log.Info( $"{LogPrefix} TreeNode.OnDragStart: {Value.ClassName}" );
return true;
}
public override bool OnContextMenu()
{
if ( Value is null ) return false;
var doc = _hierarchy?.Document;
var hasClipboard = doc?.Clipboard is not null;
// Canvas row: only Paste makes sense, and only when there's a clipboard.
if ( IsCanvasRoot )
{
if ( !hasClipboard ) return false;
var rootMenu = new Menu( TreeView );
rootMenu.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: into Canvas" );
_hierarchy?.NotifyPasteRequested( Value );
} );
rootMenu.OpenAtCursor( false );
return true;
}
if ( Value.IsSlot )
{
var slotMenu = new Menu( TreeView );
var slotPaste = slotMenu.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: into slot {Value.ClassName}" );
_hierarchy?.NotifyPasteRequested( Value );
} );
slotPaste.Enabled = hasClipboard;
slotMenu.OpenAtCursor( false );
return true;
}
var multi = _hierarchy?.SelectedRecords;
IReadOnlyList<ControlRecord> targets;
if ( multi is { Count: > 1 } && multi.Contains( Value ) )
targets = multi;
else
targets = new[] { Value };
var suffix = targets.Count > 1 ? $" ({targets.Count} items)" : "";
var m = new Menu( TreeView );
m.AddOption( $"Save as Template{suffix}…", "bookmark_add", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.SaveAsTemplate: {targets.Count} record(s)" );
_hierarchy?.NotifySaveAsTemplateRequested( targets );
} );
m.AddSeparator();
m.AddOption( $"Cut{suffix}", "content_cut", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Cut: {targets.Count} record(s)" );
_hierarchy?.NotifyCutRequested( targets );
} );
m.AddOption( $"Copy{suffix}", "content_copy", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Copy: {targets.Count} record(s)" );
_hierarchy?.NotifyCopyRequested( targets );
} );
var pasteOpt = m.AddOption( "Paste", "content_paste", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Paste: onto {Value.ClassName}" );
_hierarchy?.NotifyPasteRequested( Value );
} );
// Disable rather than hide so users learn paste exists.
pasteOpt.Enabled = hasClipboard;
m.AddSeparator();
m.AddOption( $"Delete{suffix}", "delete", () =>
{
Log.Info( $"{LogPrefix} TreeNode.ContextMenu.Delete: {targets.Count} record(s)" );
_hierarchy?.NotifyDeleteRequested( targets );
} );
m.OpenAtCursor( false );
return true;
}
public override DropAction OnDragDrop( ItemDragEvent e )
{
if ( Value is null ) return DropAction.Ignore;
var doc = _hierarchy?.Document;
if ( doc is null ) return DropAction.Ignore;
if ( e.Data.Object is ControlRecord dragged )
{
if ( dragged == Value ) return DropAction.Ignore;
if ( doc.IsDescendant( dragged, Value ) ) return DropAction.Ignore;
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out _ ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Move;
var ok = doc.MoveTo( dragged, newParent, index );
if ( !ok ) return DropAction.Ignore;
_hierarchy?.NotifyMoved( dragged );
return DropAction.Move;
}
else if ( e.Data.Object is ControlType paletteType )
{
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out var dropOnto ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Copy;
Log.Info( $"{LogPrefix} TreeNode.OnDragDrop create: {paletteType} -> parent={newParent.ClassName}, index={index}" );
var newRecord = doc.Add( paletteType, newParent );
if ( !dropOnto )
{
// Add always appends; shift to the intended sibling slot.
doc.MoveTo( newRecord, newParent, index );
}
_hierarchy?.NotifyCreated( newRecord );
return DropAction.Copy;
}
else if ( e.Data.Object is Grains.RazorDesigner.Templates.PaletteTemplate template )
{
if ( !TryComputeDropTarget( doc, e, out var newParent, out var index, out _ ) )
return DropAction.Ignore;
if ( !e.IsDrop ) return DropAction.Copy;
Log.Info( $"{LogPrefix} TreeNode.OnDragDrop template: \"{template.Name}\" -> parent={newParent.ClassName}, index={index}" );
_hierarchy?.NotifyTemplateDropRequested( template, newParent, index );
return DropAction.Copy;
}
else
{
return DropAction.Ignore;
}
}
private bool TryComputeDropTarget( DesignerDocument doc, ItemDragEvent e,
out ControlRecord newParent, out int index, out bool dropOnto )
{
newParent = null;
index = 0;
var edge = e.DropEdge;
var isMiddle = !edge.HasFlag( ItemEdge.Top ) && !edge.HasFlag( ItemEdge.Bottom );
var isContainer = Value is not null && ContractScanner.Table.Get( Value.Type ).IsContainer;
if ( isMiddle && !isContainer && !IsCanvasRoot )
{
var halfH = e.Item.Rect.Height * 0.5f;
edge = e.LocalPosition.y < halfH ? ItemEdge.Top : ItemEdge.Bottom;
isMiddle = false;
}
dropOnto = isMiddle;
if ( dropOnto )
{
if ( !isContainer ) return false;
newParent = Value;
index = Value.Children.Count;
return true;
}
if ( IsCanvasRoot ) return false;
newParent = doc.FindParent( Value );
if ( newParent is null ) return false;
var targetIdx = newParent.Children.IndexOf( Value );
index = edge.HasFlag( ItemEdge.Top ) ? targetIdx : targetIdx + 1;
return true;
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
var r = item.Rect;
var iconRect = new Rect( r.Left + 4, r.Top, r.Height, r.Height );
var textRect = r;
textRect.Left += r.Height + 8;
var iconType = IsCanvasRoot ? ControlType.Panel : Value.Type;
var icon = ContractScanner.Table.Get( iconType ).InspectorIcon;
Paint.SetPen( IsCanvasRoot ? ControlPresentation.ContainerTint : ControlPresentation.IconTint( iconType ) );
Paint.DrawIcon( iconRect, icon, r.Height - 6, TextFlag.Center );
Paint.SetDefaultFont();
if ( IsCanvasRoot )
{
Paint.SetPen( Theme.TextControl );
Paint.DrawText( textRect, "Canvas", TextFlag.LeftCenter );
}
else
{
Paint.SetPen( Theme.TextControl );
var typeLabel = Value.IsSlot ? $"Slot:{Value.SlotName} " : $"{Value.Type} ";
Paint.DrawText( textRect, typeLabel, TextFlag.LeftCenter );
var typeWidth = Paint.MeasureText( typeLabel ).x;
textRect.Left += typeWidth;
Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
Paint.DrawText( textRect, Value.ClassName, TextFlag.LeftCenter );
if ( Value.IsSlot )
{
var lockRect = new Rect( r.Right - r.Height, r.Top, r.Height, r.Height );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawIcon( lockRect, "lock", r.Height - 8, TextFlag.Center );
}
}
}
}