Editor/DesignerWindowController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Grains.RazorDesigner.Canvas;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Diagnostics;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Hierarchy;
using Grains.RazorDesigner.Inspector;
using Grains.RazorDesigner.Palette;
using Grains.RazorDesigner.Selection;
using Grains.RazorDesigner.Templates;
using Sandbox;
namespace Grains.RazorDesigner;
public sealed class DesignerWindowController
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private readonly DesignerDocument _document;
private readonly LiveTreeMirror _mirror;
private readonly SelectionController _selection;
private readonly OverlayController _overlay;
private readonly HierarchyPanel _hierarchy;
private readonly InspectorPanel _inspector;
private readonly PalettePanel _palette;
private readonly Func<float> _getCanvasDpiScale;
private readonly Action _applyPreviewStylesheet;
private readonly Widget _ownerWidget;
private readonly Action _onValidated;
public DesignerWindowController(
DesignerDocument document,
LiveTreeMirror mirror,
SelectionController selection,
OverlayController overlay,
HierarchyPanel hierarchy,
InspectorPanel inspector,
PalettePanel palette,
Func<float> getCanvasDpiScale,
Action applyPreviewStylesheet,
Widget ownerWidget,
Action onValidated = null )
{
_document = document;
_mirror = mirror;
_selection = selection;
_overlay = overlay;
_hierarchy = hierarchy;
_inspector = inspector;
_palette = palette;
_getCanvasDpiScale = getCanvasDpiScale;
_applyPreviewStylesheet = applyPreviewStylesheet;
_ownerWidget = ownerWidget;
_onValidated = onValidated;
Log.Info( $"{LogPrefix} DesignerWindowController ctor" );
}
public CanvasManipulator Manipulator { get; set; }
public void ApplyWiringChange( Grains.RazorDesigner.Wiring.WiringEnvelope env )
{
if ( env is null ) return;
_document.Wiring = env;
Log.Info( $"{LogPrefix} ApplyWiringChange: symbols={env.Symbols.Count}, " +
$"namespace='{env.Namespace}', class='{env.ClassName}', base='{env.BaseClass}'" );
_onValidated?.Invoke();
}
public void RepopulateMirrorAndRefresh()
{
if ( !_mirror.Repopulate() ) return;
// Repopulate wiped Root's children — re-create the selection overlay under the new tree.
_overlay?.Reattach( _mirror.CurrentRoot );
_selection?.Deselect();
_inspector?.SetTarget( null );
_applyPreviewStylesheet();
RefreshHierarchy();
_onValidated?.Invoke();
}
public void RefreshHierarchy()
{
if ( _hierarchy is null || _document is null ) return;
_hierarchy.SetRoot( _document.RootRecord );
_hierarchy.Highlight( _selection?.Selected );
}
// RepopulateMirror clears selection; re-establish focus.
private void RepopulateAndFocus( ControlRecord record )
{
RepopulateMirrorAndRefresh();
if ( record is null ) return;
_selection.Select( record );
_inspector.SetTarget( record );
_hierarchy.Highlight( record );
}
// ─── Palette ───────────────────────────────────────────────────────────
public void OnPaletteTypeAddRequested( ControlType type )
{
// Click-to-add: append under the active selection if it's a container, else under root.
var sel = _selection?.Selected;
ControlRecord parent = null;
if ( sel is not null && ContractScanner.Table.Get( sel.Type ).IsContainer )
parent = sel;
Log.Info( $"{LogPrefix} Palette click-to-add {type} under {parent?.ClassName ?? "<root>"}" );
OnHierarchyAddRequested( type, parent );
}
public void OnPaletteTemplateAddRequested( PaletteTemplate template )
{
// Click-to-add: append under the active selection if it's a container, else under root.
var sel = _selection?.Selected;
ControlRecord parent = null;
if ( sel is not null && ContractScanner.Table.Get( sel.Type ).IsContainer )
parent = sel;
Log.Info( $"{LogPrefix} Palette click-to-add template \"{template.Name}\" under {parent?.ClassName ?? "<root>"}" );
InsertTemplate( template, parent ?? _document.RootRecord );
}
// ─── Canvas ────────────────────────────────────────────────────────────
private static Vector2 ToLogicalPx( Vector2 widgetPx ) => widgetPx;
public void OnCanvasClicked( Vector2 widgetPx, bool isRightClick, Sandbox.KeyboardModifiers mods )
{
if ( (mods & Sandbox.KeyboardModifiers.Alt) != 0 && !isRightClick )
{
var deepest = _selection.FindDeepestAt( widgetPx.x, widgetPx.y, _getCanvasDpiScale() );
if ( deepest is null ) return;
var parent = _document.FindParent( deepest );
var target = (parent is null || parent == _document.RootRecord) ? deepest : parent;
_selection.Select( target );
_inspector.SetTarget( target );
_hierarchy.Highlight( target );
Log.Info( $"{LogPrefix} alt-walk: deepest={deepest.ClassName} parent={parent?.ClassName ?? "<root>"} → select {target.ClassName}" );
return;
}
if ( Manipulator is not null && Manipulator.OnPress( widgetPx, isRightClick ) )
{
_overlay?.SetHoverTarget( null ); // drag started → clear hover the same frame
return;
}
if ( isRightClick )
{
OpenCanvasContextMenu( widgetPx );
return;
}
var logical = ToLogicalPx( widgetPx );
_selection.TrySelectAt( logical.x, logical.y, _getCanvasDpiScale() );
_inspector.SetTarget( _selection.Selected );
_hierarchy.Highlight( _selection.Selected );
}
private void OpenCanvasContextMenu( Vector2 widgetPx )
{
var logical = ToLogicalPx( widgetPx );
var dpi = _getCanvasDpiScale();
var hit = _selection?.FindDeepestAt( logical.x, logical.y, dpi );
var multi = _hierarchy?.SelectedRecords;
IReadOnlyList<ControlRecord> targets;
ControlRecord menuHit;
bool isSlot = false;
if ( hit is null || hit == _document.RootRecord )
{
menuHit = null;
targets = System.Array.Empty<ControlRecord>();
}
else if ( hit.IsSlot )
{
menuHit = hit;
targets = new[] { hit };
isSlot = true;
}
else if ( multi is { Count: > 1 } && multi.Contains( hit ) )
{
menuHit = hit;
targets = multi;
}
else
{
_selection.Select( hit );
_inspector.SetTarget( hit );
_hierarchy.Highlight( hit );
menuHit = hit;
targets = new[] { hit };
}
var hasClipboard = _document?.Clipboard is not null;
CanvasContextMenu.Open(
parent: _ownerWidget,
hit: menuHit,
targets: targets,
hasClipboard: hasClipboard,
isSlot: isSlot,
pasteRootTarget: _document.RootRecord,
callbacks: new CanvasContextMenu.Callbacks
{
Cut = OnHierarchyCutRequested,
Copy = OnHierarchyCopyRequested,
Paste = OnHierarchyPasteRequested,
Delete = OnHierarchyDeleteRequested,
SaveAsTemplate = OnHierarchySaveAsTemplateRequested,
} );
}
public void OnCanvasMoved( Vector2 widgetPx, Sandbox.KeyboardModifiers mods )
{
Manipulator?.OnMove( widgetPx, mods );
if ( Manipulator is { IsDragging: true } ) return;
var hover = _selection.FindDeepestAt( widgetPx.x, widgetPx.y, _getCanvasDpiScale() );
_overlay?.SetHoverTarget( hover );
}
public void OnCanvasHoverEnded()
{
_overlay?.SetHoverTarget( null );
}
public void OnCanvasReleased( Vector2 widgetPx ) => Manipulator?.OnRelease( widgetPx );
public void CommitCanvasManipulation( ControlRecord record )
{
if ( record is null ) return;
_applyPreviewStylesheet();
_onValidated?.Invoke();
if ( _selection?.Selected == record )
_inspector?.SetTarget( record );
_hierarchy?.NodeChanged( record );
}
public bool NudgeSelected( float dx, float dy, float dw, float dh )
{
if ( Manipulator is { IsDragging: true } ) return false;
var rec = _selection?.Selected;
if ( rec is null || rec == _document.RootRecord ) return false;
const float MinSize = 4f; // matches CanvasManipulator.OnMove's MinSize
bool changed = false;
if ( dw != 0f )
{
if ( rec.Width.Unit == LengthUnit.Px )
{
rec.Width = Length.Px( System.MathF.Max( MinSize, rec.Width.Value + dw ) );
changed = true;
}
else Log.Info( $"{LogPrefix} NudgeSelected: {rec.ClassName} Width is not a px size; keyboard-resize ignored (set a px size via the inspector or a drag-resize first)" );
}
if ( dh != 0f )
{
if ( rec.Height.Unit == LengthUnit.Px )
{
rec.Height = Length.Px( System.MathF.Max( MinSize, rec.Height.Value + dh ) );
changed = true;
}
else Log.Info( $"{LogPrefix} NudgeSelected: {rec.ClassName} Height is not a px size; keyboard-resize ignored (set a px size via the inspector or a drag-resize first)" );
}
if ( dx != 0f || dy != 0f )
{
if ( rec.Position == PositionKind.Absolute )
{
if ( dx != 0f ) { rec.Left = Length.Px( (rec.Left.Unit == LengthUnit.Px ? rec.Left.Value : 0f) + dx ); changed = true; }
if ( dy != 0f ) { rec.Top = Length.Px( (rec.Top.Unit == LengthUnit.Px ? rec.Top.Value : 0f) + dy ); changed = true; }
}
else Log.Info( $"{LogPrefix} NudgeSelected: {rec.ClassName} is not Position=Absolute; arrow-move ignored (drag it once to place it)" );
}
if ( !changed ) return false;
Log.Info( $"{LogPrefix} NudgeSelected: {rec.ClassName} dx={dx} dy={dy} dw={dw} dh={dh}" );
CommitCanvasManipulation( rec );
return true;
}
public void OnCanvasRecordDropped( ControlType type, Vector2 widgetPx )
{
Log.Info( $"{LogPrefix} OnCanvasRecordDropped: {type} at widget ({widgetPx.x:F0}, {widgetPx.y:F0})" );
var logical = ToLogicalPx( widgetPx );
var dpi = _getCanvasDpiScale();
var fbPos = new Vector2( logical.x * dpi, logical.y * dpi );
var parent = _document.FindDeepestContainerAt( fbPos );
var record = _document.Add( type, parent );
if ( !_mirror.MirrorInserted( record, parent ) )
{
RepopulateAndFocus( record );
return;
}
_mirror.UpdateChromeLabel( parent );
_applyPreviewStylesheet();
_selection.Select( record );
_inspector.SetTarget( record );
RefreshHierarchy();
}
public void OnCanvasTemplateDropped( PaletteTemplate template, Vector2 widgetPx )
{
Log.Info( $"{LogPrefix} OnCanvasTemplateDropped: \"{template.Name}\" at widget ({widgetPx.x:F0}, {widgetPx.y:F0})" );
var logical = ToLogicalPx( widgetPx );
var dpi = _getCanvasDpiScale();
var fbPos = new Vector2( logical.x * dpi, logical.y * dpi );
var parent = _document.FindDeepestContainerAt( fbPos );
InsertTemplate( template, parent );
}
// ─── Hierarchy ─────────────────────────────────────────────────────────
public void OnHierarchyTemplateDropRequested( PaletteTemplate template, ControlRecord parent, int index )
{
if ( parent is null ) parent = _document.RootRecord;
Log.Info( $"{LogPrefix} OnHierarchyTemplateDropRequested: \"{template.Name}\" -> {parent.ClassName}[{index}]" );
var clones = _document.AddTemplate( template, parent );
if ( clones.Count == 0 ) return;
// AddTemplate appends; shift the contiguous block of clones to the requested slot.
var firstAppendedAt = parent.Children.Count - clones.Count;
if ( index < firstAppendedAt )
{
for ( int i = 0; i < clones.Count; i++ )
_document.MoveTo( clones[i], parent, index + i );
}
var focus = clones[clones.Count - 1];
foreach ( var clone in clones )
{
if ( !_mirror.MirrorInserted( clone, parent ) )
{
RepopulateAndFocus( focus );
return;
}
}
_applyPreviewStylesheet();
_selection.Select( focus );
_inspector.SetTarget( focus );
_hierarchy.Highlight( focus );
RefreshHierarchy();
}
public void OnHierarchySaveAsTemplateRequested( IReadOnlyList<ControlRecord> records )
{
if ( records is null || records.Count == 0 )
{
Log.Warning( $"{LogPrefix} OnHierarchySaveAsTemplateRequested: empty selection; ignored" );
return;
}
var lca = ComputeLowestCommonAncestor( records );
var dialog = new SaveTemplateDialog( _ownerWidget, _palette.TemplateStore, records, lca );
dialog.Show( onConfirm: template =>
{
Log.Info( $"{LogPrefix} OnHierarchySaveAsTemplateRequested: saving \"{template.Name}\"" );
_palette.TemplateStore.Save( template );
} );
}
public void OnHierarchyRecordCreated( ControlRecord record )
{
Log.Info( $"{LogPrefix} OnHierarchyRecordCreated: {record.ClassName}" );
var parent = _document.FindParent( record );
if ( !_mirror.MirrorInserted( record, parent ) )
{
RepopulateAndFocus( record );
return;
}
_applyPreviewStylesheet();
_selection.Select( record );
_inspector.SetTarget( record );
_hierarchy.Highlight( record );
RefreshHierarchy();
}
public void OnHierarchyRowClicked( ControlRecord record )
{
_selection.Select( record );
_inspector.SetTarget( record );
// Don't Highlight: row is already selected by the click; would feed back via ItemSelected.
}
public void OnHierarchySelectionChanged( IReadOnlyList<ControlRecord> records )
{
Log.Info( $"{LogPrefix} OnHierarchySelectionChanged: {records?.Count ?? 0} record(s)" );
}
public void OnRecordMoved( ControlRecord moved )
{
Log.Info( $"{LogPrefix} OnRecordMoved: {moved?.ClassName ?? "<null>"} (surgical)" );
if ( moved is null ) { RepopulateMirrorAndRefresh(); return; }
var newParent = _document.FindParent( moved );
var newLiveParent = newParent?.LivePanel;
var live = moved.LivePanel;
if ( newParent is null || newLiveParent is null || !newLiveParent.IsValid
|| live is null || !live.IsValid )
{
Log.Warning( $"{LogPrefix} OnRecordMoved: surgical re-parent unavailable; falling back to RepopulateMirror" );
RepopulateMirrorAndRefresh();
return;
}
live.Parent = newLiveParent;
var docIndex = newParent.Children.IndexOf( moved );
if ( docIndex >= 0 && docIndex < newParent.Children.Count - 1 )
newLiveParent.SetChildIndex( live, docIndex );
foreach ( var r in _document.WalkAll() )
_mirror.UpdateChromeLabel( r );
_applyPreviewStylesheet();
RefreshHierarchy();
}
public void OnHierarchyCutRequested( IReadOnlyList<ControlRecord> records )
{
if ( records is null || records.Count == 0 ) return;
var operable = new List<ControlRecord>( records.Count );
foreach ( var r in records )
if ( r is not null && r != _document.RootRecord ) operable.Add( r );
if ( operable.Count == 0 ) return;
// Stash references first; delete after so the records still exist when stored.
_document.Clipboard = operable;
Log.Info( $"{LogPrefix} Cut {operable.Count} record(s) -> clipboard" );
foreach ( var r in operable )
DeleteRecordCascade( r );
}
// Clone at copy time so subsequent edits to source don't bleed into pastes.
public void OnHierarchyCopyRequested( IReadOnlyList<ControlRecord> records )
{
if ( records is null || records.Count == 0 ) return;
var snapshots = new List<ControlRecord>( records.Count );
foreach ( var r in records )
{
if ( r is null || r == _document.RootRecord ) continue;
snapshots.Add( _document.Clone( r ) );
}
if ( snapshots.Count == 0 ) return;
_document.Clipboard = snapshots;
Log.Info( $"{LogPrefix} Copy {snapshots.Count} record(s) -> clipboard" );
}
public void OnHierarchyPasteRequested( ControlRecord target )
{
if ( _document.Clipboard is null || _document.Clipboard.Count == 0 || target is null ) return;
ControlRecord parent;
int index;
if ( ContractScanner.Table.Get( target.Type ).IsContainer )
{
parent = target;
index = target.Children.Count;
}
else
{
parent = _document.FindParent( target ) ?? _document.RootRecord;
index = parent.Children.IndexOf( target ) + 1;
}
var pastedRoots = new List<ControlRecord>( _document.Clipboard.Count );
foreach ( var entry in _document.Clipboard )
{
if ( entry is null ) continue;
var pasted = _document.Clone( entry );
parent.Children.Insert( index, pasted );
Log.Info( $"{LogPrefix} Paste {pasted.ClassName} into {parent.ClassName}[{index}]" );
index++;
pastedRoots.Add( pasted );
}
if ( pastedRoots.Count == 0 ) return;
var lastPasted = pastedRoots[pastedRoots.Count - 1];
foreach ( var pasted in pastedRoots )
{
if ( !_mirror.MirrorInserted( pasted, parent ) )
{
RepopulateAndFocus( lastPasted );
return;
}
}
_applyPreviewStylesheet();
_selection.Select( lastPasted );
_inspector.SetTarget( lastPasted );
_hierarchy.Highlight( lastPasted );
RefreshHierarchy();
}
public void OnHierarchyRenameRequested( ControlRecord record, string newName )
{
if ( record is null ) return;
newName = newName?.Trim() ?? "";
if ( newName == record.ClassName ) return;
var error = _document.ValidateClassName( newName, record );
if ( error is not null )
{
Log.Warning( $"{LogPrefix} Hierarchy rename '{newName}' rejected: {error}" );
return;
}
var oldName = record.ClassName;
record.ClassName = newName;
Log.Info( $"{LogPrefix} Hierarchy rename '{oldName}' -> '{newName}'" );
var live = record.LivePanel;
if ( live is not null && live.IsValid )
{
if ( !string.IsNullOrEmpty( oldName ) )
live.RemoveClass( oldName );
if ( !string.IsNullOrEmpty( newName ) )
live.AddClass( newName );
}
if ( ContractScanner.Table.Get( record.Type ).IsContainer )
_mirror.RefreshChromeLabelText( record );
_applyPreviewStylesheet();
_hierarchy.NodeChanged( record );
// Inspector caches _priorClassName + binds a LineEdit to the property; re-target syncs both.
if ( _selection.Selected == record )
_inspector.SetTarget( record );
}
public void OnHierarchyAddRequested( ControlType type, ControlRecord parent )
{
var actualParent = parent ?? _document.RootRecord;
Log.Info( $"{LogPrefix} OnHierarchyAddRequested: {type} under {actualParent.ClassName}" );
var record = _document.Add( type, actualParent );
if ( !_mirror.MirrorInserted( record, actualParent ) )
{
RepopulateAndFocus( record );
return;
}
_mirror.UpdateChromeLabel( actualParent );
_applyPreviewStylesheet();
_selection.Select( record );
_inspector.SetTarget( record );
RefreshHierarchy();
}
public void OnHierarchyDeleteRequested( IReadOnlyList<ControlRecord> records )
{
if ( records is null || records.Count == 0 ) return;
Log.Info( $"{LogPrefix} OnHierarchyDeleteRequested: {records.Count} record(s)" );
foreach ( var r in records )
{
if ( r is null || r == _document.RootRecord ) continue;
DeleteRecordCascade( r );
}
}
public void DeleteRecordCascade( ControlRecord record )
{
var parent = _document.FindParent( record );
_selection.Select( record );
_selection.DeleteSelected();
_mirror.UpdateChromeLabel( parent );
_inspector.SetTarget( null );
_applyPreviewStylesheet();
RefreshHierarchy();
}
// ─── Inspector ─────────────────────────────────────────────────────────
public void OnInspectorClassNameChanged( ControlRecord record, string oldClassName )
{
if ( record is null ) return;
Log.Info( $"{LogPrefix} OnInspectorClassNameChanged: '{oldClassName}' -> '{record.ClassName}' (surgical)" );
var live = record.LivePanel;
if ( live is not null && live.IsValid )
{
if ( !string.IsNullOrEmpty( oldClassName ) )
live.RemoveClass( oldClassName );
if ( !string.IsNullOrEmpty( record.ClassName ) )
live.AddClass( record.ClassName );
}
if ( ContractScanner.Table.Get( record.Type ).IsContainer )
{
_mirror.RefreshChromeLabelText( record );
}
_applyPreviewStylesheet();
_hierarchy?.NodeChanged( record );
}
public void OnInspectorValueChanged()
{
var probeSw = PerfProbes.Enabled ? System.Diagnostics.Stopwatch.StartNew() : null;
var sel = _selection?.Selected;
if ( sel is not null )
_mirror.Reapply( sel );
var probeReapplyMs = probeSw?.Elapsed.TotalMilliseconds ?? 0.0;
probeSw?.Restart();
_applyPreviewStylesheet();
if ( probeSw != null )
{
var probeStyleMs = probeSw.Elapsed.TotalMilliseconds;
Log.Info( $"{LogPrefix} OnInspectorValueChanged: reapply={probeReapplyMs:F2}ms style={probeStyleMs:F2}ms total={(probeReapplyMs + probeStyleMs):F2}ms" );
}
}
// ─── Helpers ───────────────────────────────────────────────────────────
// Common AddTemplate path used by click-to-add and canvas drop.
private void InsertTemplate( PaletteTemplate template, ControlRecord parent )
{
var actualParent = parent ?? _document.RootRecord;
var clones = _document.AddTemplate( template, actualParent );
if ( clones.Count == 0 ) return;
var focus = clones[clones.Count - 1];
foreach ( var clone in clones )
{
if ( !_mirror.MirrorInserted( clone, actualParent ) )
{
RepopulateAndFocus( focus );
return;
}
}
_applyPreviewStylesheet();
_selection.Select( focus );
_inspector.SetTarget( focus );
_hierarchy.Highlight( focus );
RefreshHierarchy();
}
private ControlRecord ComputeLowestCommonAncestor( IReadOnlyList<ControlRecord> records )
{
if ( records is null || records.Count == 0 ) return null;
if ( records.Count == 1 ) return null; // wrap meaningless for single root
ControlRecord shared = _document.FindParent( records[0] );
for ( int i = 1; i < records.Count; i++ )
{
var p = _document.FindParent( records[i] );
if ( p != shared ) return null; // not all siblings -> wrap not meaningful
}
return shared;
}
}