Editor/Document/DesignerDocument.cs
using System;
using System.Collections.Generic;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Templates;
using Sandbox;
namespace Grains.RazorDesigner.Document;
public sealed class DesignerDocument
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly Dictionary<ControlType, int> _counters = new();
public IReadOnlyList<ControlRecord> Clipboard { get; set; }
// isRoot sentinel. Serializer/Inspector branch on this.
public const string RootClassName = "root";
public ControlRecord RootRecord { get; } = new ControlRecord
{
Type = ControlType.Panel,
ClassName = RootClassName,
Direction = FlexDirection.Row,
Justify = JustifyContent.Start,
Align = AlignItems.Stretch,
Gap = 0f,
Padding = Edges.Zero,
Wrap = FlexWrap.NoWrap,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
FlexGrow = 0f,
FlexBasis = Length.Auto,
};
public Grains.RazorDesigner.Wiring.WiringEnvelope Wiring { get; set; } = Grains.RazorDesigner.Wiring.WiringEnvelope.Empty;
public ControlRecord Add( ControlType type, ControlRecord parent = null )
{
if ( parent is not null && parent.Type == ControlType.SplitContainer && parent.Children.Count > 0 )
{
var redirected = parent.Children[0];
Log.Info( $"{LogPrefix} Document.Add({type}): redirecting drop on SplitContainer '{parent.ClassName}' to slot '{redirected.ClassName}'" );
parent = redirected;
}
var defaults = ControlDefaults.For( type );
var record = new ControlRecord
{
Type = type,
ClassName = MintClassName( type ),
Width = defaults.DefaultWidth,
Height = defaults.DefaultHeight,
Content = defaults.DefaultContent,
IconName = defaults.DefaultIcon,
FlexGrow = defaults.DefaultFlexGrow,
Direction = defaults.DefaultDirection,
Wrap = defaults.DefaultWrap,
};
if ( parent is null )
{
RootRecord.Children.Add( record );
Log.Info( $"{LogPrefix} Document.Add({type}) -> {record.ClassName} (under root; now {RootRecord.Children.Count} root child(ren))" );
}
else
{
parent.Children.Add( record );
Log.Info( $"{LogPrefix} Document.Add({type}) -> {record.ClassName} child of {parent.ClassName} (now {parent.Children.Count} child(ren))" );
}
if ( type == ControlType.SplitContainer )
{
AddSlotChild( record, "left" );
AddSlotChild( record, "right" );
}
return record;
}
private void AddSlotChild( ControlRecord splitContainer, string slotName )
{
var defaults = ControlDefaults.For( ControlType.Panel );
var slot = new ControlRecord
{
Type = ControlType.Panel,
ClassName = MintClassName( ControlType.Panel ),
Width = defaults.DefaultWidth,
Height = defaults.DefaultHeight,
IsSlot = true,
SlotName = slotName,
};
splitContainer.Children.Add( slot );
Log.Info( $"{LogPrefix} Document.Add: auto-spawned slot '{slot.ClassName}' (slot={slotName}) under SplitContainer '{splitContainer.ClassName}'" );
}
public IReadOnlyList<ControlRecord> AddTemplate( PaletteTemplate template, ControlRecord parent = null )
{
if ( template is null )
{
Log.Warning( $"{LogPrefix} Document.AddTemplate: template is null; ignored" );
return System.Array.Empty<ControlRecord>();
}
if ( template.Roots is null || template.Roots.Count == 0 )
{
Log.Warning( $"{LogPrefix} Document.AddTemplate(\"{template.Name}\"): template has no roots; ignored" );
return System.Array.Empty<ControlRecord>();
}
var actualParent = parent ?? RootRecord;
var clones = new List<ControlRecord>( template.Roots.Count );
foreach ( var src in template.Roots )
{
if ( src is null ) continue;
var clone = Clone( src );
actualParent.Children.Add( clone );
clones.Add( clone );
}
Log.Info( $"{LogPrefix} Document.AddTemplate(\"{template.Name}\") -> {clones.Count} clone(s) under {actualParent.ClassName}" );
return clones;
}
public bool Remove( ControlRecord record )
{
if ( record is null ) return false;
if ( record.IsSlot )
{
Log.Warning( $"{LogPrefix} Document.Remove({record.ClassName}): refusing to remove structural slot record (SlotName='{record.SlotName}')" );
return false;
}
if ( RootRecord.Children.Remove( record ) )
{
DeleteLivePanelsRecursive( record );
Log.Info( $"{LogPrefix} Document.Remove({record.ClassName}): was root child (now {RootRecord.Children.Count} root child(ren))" );
return true;
}
var parent = FindParent( record );
if ( parent is not null && parent.Children.Remove( record ) )
{
DeleteLivePanelsRecursive( record );
Log.Info( $"{LogPrefix} Document.Remove({record.ClassName}): was child of {parent.ClassName}" );
return true;
}
Log.Warning( $"{LogPrefix} Document.Remove({record.ClassName}): not found in tree" );
return false;
}
public bool MoveTo( ControlRecord record, ControlRecord newParent, int index )
{
if ( record is null )
{
Log.Warning( $"{LogPrefix} Document.MoveTo: record is null; ignored" );
return false;
}
if ( record == RootRecord )
{
Log.Warning( $"{LogPrefix} Document.MoveTo: cannot move RootRecord" );
return false;
}
if ( record.IsSlot )
{
Log.Warning( $"{LogPrefix} Document.MoveTo({record.ClassName}): cannot move structural slot record (SlotName='{record.SlotName}')" );
return false;
}
if ( newParent is not null && newParent.Type == ControlType.SplitContainer && newParent.Children.Count > 0 )
{
var redirected = newParent.Children[0];
Log.Info( $"{LogPrefix} Document.MoveTo({record.ClassName}): redirecting target from SplitContainer '{newParent.ClassName}' to slot '{redirected.ClassName}'" );
newParent = redirected;
}
if ( newParent is null )
{
Log.Warning( $"{LogPrefix} Document.MoveTo({record.ClassName}): newParent is null" );
return false;
}
if ( !ContractScanner.Table.Get( newParent.Type ).IsContainer )
{
Log.Warning( $"{LogPrefix} Document.MoveTo({record.ClassName}): {newParent.ClassName} ({newParent.Type}) is not a container" );
return false;
}
if ( newParent == record || IsDescendant( record, newParent ) )
{
Log.Warning( $"{LogPrefix} Document.MoveTo({record.ClassName}): cycle. newParent {newParent.ClassName} is record or its descendant" );
return false;
}
var oldParent = FindParent( record );
if ( oldParent is null )
{
Log.Warning( $"{LogPrefix} Document.MoveTo({record.ClassName}): not found in tree" );
return false;
}
var oldIndex = oldParent.Children.IndexOf( record );
oldParent.Children.RemoveAt( oldIndex );
if ( oldParent == newParent && index > oldIndex ) index--;
index = System.Math.Clamp( index, 0, newParent.Children.Count );
newParent.Children.Insert( index, record );
Log.Info( $"{LogPrefix} Document.MoveTo({record.ClassName}) {oldParent.ClassName}[{oldIndex}] -> {newParent.ClassName}[{index}]" );
return true;
}
public IReadOnlyList<ControlRecord> ParentDedupe( IEnumerable<ControlRecord> records )
{
if ( records is null ) return System.Array.Empty<ControlRecord>();
var ordered = new List<ControlRecord>();
var seen = new HashSet<ControlRecord>();
foreach ( var r in records )
{
if ( r is null || r == RootRecord ) continue;
if ( seen.Add( r ) ) ordered.Add( r );
}
if ( ordered.Count <= 1 ) return ordered;
var result = new List<ControlRecord>( ordered.Count );
foreach ( var r in ordered )
{
var hasAncestor = false;
for ( var p = FindParent( r ); p is not null && p != RootRecord; p = FindParent( p ) )
{
if ( seen.Contains( p ) ) { hasAncestor = true; break; }
}
if ( !hasAncestor ) result.Add( r );
}
Log.Info( $"{LogPrefix} ParentDedupe: {ordered.Count} -> {result.Count}" );
return result;
}
public bool IsDescendant( ControlRecord ancestor, ControlRecord candidate )
{
if ( ancestor is null || candidate is null ) return false;
if ( ancestor == candidate ) return true;
foreach ( var c in ancestor.Children )
{
if ( IsDescendant( c, candidate ) ) return true;
}
return false;
}
public void Clear()
{
foreach ( var r in WalkAll() )
{
if ( !r.IsSlot )
r.LivePanel?.Delete();
r.LivePanel = null;
}
RootRecord.Children.Clear();
_counters.Clear();
Log.Info( $"{LogPrefix} Document.Clear (counters reset; RootRecord retained)" );
}
public bool HasAnyBindings()
{
if ( RootRecord.Bindings.Count > 0 ) return true;
foreach ( var r in WalkAll() )
if ( r.Bindings.Count > 0 )
return true;
return false;
}
// Depth-first, parent before children. RootRecord is not yielded.
public IEnumerable<ControlRecord> WalkAll()
{
foreach ( var r in RootRecord.Children )
{
yield return r;
foreach ( var nested in WalkSubtree( r ) )
yield return nested;
}
}
private static IEnumerable<ControlRecord> WalkSubtree( ControlRecord parent )
{
foreach ( var c in parent.Children )
{
yield return c;
foreach ( var nested in WalkSubtree( c ) )
yield return nested;
}
}
// RootRecord itself returns null (only record without a parent).
public ControlRecord FindParent( ControlRecord child )
{
if ( child is null || child == RootRecord ) return null;
if ( RootRecord.Children.Contains( child ) )
return RootRecord;
foreach ( var top in RootRecord.Children )
{
if ( top.Children.Contains( child ) ) return top;
var nested = FindParentInSubtree( top, child );
if ( nested is not null ) return nested;
}
return null;
}
private static ControlRecord FindParentInSubtree( ControlRecord parent, ControlRecord target )
{
foreach ( var c in parent.Children )
{
if ( c.Children.Contains( target ) ) return c;
var nested = FindParentInSubtree( c, target );
if ( nested is not null ) return nested;
}
return null;
}
// WalkAll yields parents-before-children, so the last hit is the deepest. Seeded with RootRecord.
public ControlRecord FindDeepestContainerAt( Vector2 fbPos )
{
ControlRecord deepest = RootRecord;
foreach ( var r in WalkAll() )
{
if ( !ContractScanner.Table.Get( r.Type ).IsContainer ) continue;
if ( r.LivePanel is null || !r.LivePanel.IsValid ) continue;
if ( !r.LivePanel.IsInside( fbPos ) ) continue;
deepest = r;
}
return deepest;
}
public ControlRecord Clone( ControlRecord source )
{
if ( source is null ) return null;
var clone = new ControlRecord
{
Type = source.Type,
ClassName = MintClassName( source.Type ),
};
source.CopyFieldsTo( clone );
foreach ( var c in source.Children )
clone.Children.Add( Clone( c ) );
return clone;
}
public void SeedCounters()
{
_counters.Clear();
// Walk root + all descendants (including slots).
var allRecords = new System.Collections.Generic.List<ControlRecord>( 64 );
allRecords.Add( RootRecord );
CollectAllRecords( RootRecord, allRecords );
foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
{
var prefix = ControlDefaults.ClassNamePrefix( type );
var max = 0;
foreach ( var r in allRecords )
{
var cn = r.ClassName;
if ( cn is null || !cn.StartsWith( prefix, System.StringComparison.Ordinal ) ) continue;
var rest = cn.Substring( prefix.Length );
if ( rest.Length > 0 && int.TryParse( rest, out var n ) && n > max )
max = n;
}
if ( max > 0 )
_counters[type] = max;
}
Log.Info( $"{LogPrefix} Document.SeedCounters: seeded {_counters.Count} type counter(s)" );
}
private static void CollectAllRecords( ControlRecord parent, System.Collections.Generic.List<ControlRecord> into )
{
foreach ( var child in parent.Children )
{
into.Add( child );
CollectAllRecords( child, into );
}
}
// Walk past in-use names: a user rename (panel1 -> panel5) can occupy a slot the counter will mint later.
private string MintClassName( ControlType type )
{
var prefix = ControlDefaults.ClassNamePrefix( type );
_counters.TryGetValue( type, out var count );
string candidate;
do
{
count++;
candidate = $"{prefix}{count}";
} while ( IsClassNameInUse( candidate ) );
_counters[type] = count;
return candidate;
}
private bool IsClassNameInUse( string className )
{
if ( RootRecord.ClassName == className ) return true;
foreach ( var r in WalkAll() )
{
if ( r.ClassName == className ) return true;
}
return false;
}
// Returns null on success, else a short failure reason. `self` is excluded from collision check.
public string ValidateClassName( string name, ControlRecord self )
{
if ( string.IsNullOrWhiteSpace( name ) )
return "empty";
if ( name == RootClassName )
return $"'{RootClassName}' is reserved for the canvas record";
if ( !IsValidCssIdentifier( name ) )
return "must start with a letter or underscore and contain only letters, digits, '_', or '-'";
if ( CollidesWithAnyOther( name, self ) )
return $"another record already uses the name '{name}'";
return null;
}
private static bool IsValidCssIdentifier( string s )
{
var first = s[0];
if ( !( char.IsLetter( first ) || first == '_' ) ) return false;
for ( int i = 1; i < s.Length; i++ )
{
var c = s[i];
if ( !( char.IsLetterOrDigit( c ) || c == '_' || c == '-' ) ) return false;
}
return true;
}
private bool CollidesWithAnyOther( string name, ControlRecord self )
{
if ( RootRecord != self && RootRecord.ClassName == name )
return true;
foreach ( var r in WalkAll() )
{
if ( r == self ) continue;
if ( r.ClassName == name ) return true;
}
return false;
}
private static void DeleteLivePanelsRecursive( ControlRecord r )
{
foreach ( var c in r.Children )
DeleteLivePanelsRecursive( c );
if ( !r.IsSlot )
r.LivePanel?.Delete();
r.LivePanel = null;
}
}