Editor/SuiDesignerController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using SboxUiDesigner.EditorUi.Commands;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi;
/// <summary>
/// Single source of truth for an open .sui document inside the editor window.
/// Owns the document, the selection state, the dirty state, and the command
/// stack. Region widgets read state through the controller and mutate state
/// only via commands — direct widget-to-widget event wiring is avoided so undo,
/// dirty-tracking, and persistence stay coherent.
///
/// All edits go through <see cref="Execute(ISuiCommand)"/>. Cosmetic-only state
/// (selection) goes through <see cref="SetSelected"/> and does NOT enter the
/// command stack — selection itself isn't undoable, only document mutations are.
/// </summary>
public sealed class SuiDesignerController
{
public SuiDocument Document { get; private set; }
/// <summary>
/// Multi-selection state. <see cref="Selected"/> is the "primary" — the most
/// recently focused element used by the Details panel and chrome handles.
/// <see cref="SelectedSet"/> is everyone in the current selection (always
/// contains <see cref="Selected"/> when non-null).
/// </summary>
public SuiElement Selected { get; private set; }
public IReadOnlyCollection<SuiElement> SelectedSet => _selectedSet;
public int SelectedCount => _selectedSet.Count;
private readonly HashSet<SuiElement> _selectedSet = new();
public bool IsDirty { get; private set; }
public SuiCommandStack Commands { get; }
public event Action DocumentChanged;
public event Action SelectionChanged;
public event Action DirtyChanged;
public event Action CommandsChanged;
public SuiDesignerController()
{
Commands = new SuiCommandStack();
Commands.Changed += () => CommandsChanged?.Invoke();
}
// ─────────────────────────────────────────────────────────────────────
// Document lifecycle
// ─────────────────────────────────────────────────────────────────────
public void SetDocument( SuiDocument doc )
{
Document = doc;
_selectedSet.Clear();
Selected = doc?.GetRoot();
if ( Selected != null ) _selectedSet.Add( Selected );
IsDirty = false;
Commands.Clear();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
DirtyChanged?.Invoke();
}
public void MarkSaved()
{
if ( !IsDirty ) return;
IsDirty = false;
DirtyChanged?.Invoke();
}
private void SetDirty()
{
if ( IsDirty ) return;
IsDirty = true;
DirtyChanged?.Invoke();
}
/// <summary>
/// Mark the document dirty when a non-command mutation happens — e.g. user
/// picks an output folder via Window's PromptOutputFolder. These mutations
/// don't go through the command stack (they're settings, not undoable
/// edits) but still need to trigger a Save prompt on close.
/// </summary>
public void MarkDirtyExternally()
{
SetDirty();
}
// ─────────────────────────────────────────────────────────────────────
// Selection
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Replace the entire selection with this single element (or clear when null).
/// </summary>
public void SetSelected( SuiElement element )
{
var same = _selectedSet.Count == 1 && Selected == element;
if ( same && _selectedSet.Contains( element ) ) return;
_selectedSet.Clear();
Selected = element;
if ( element != null ) _selectedSet.Add( element );
SelectionChanged?.Invoke();
}
public void SetSelectedById( string elementId )
{
if ( Document == null ) return;
var el = Document.GetElement( elementId );
SetSelected( el );
}
public void ClearSelection()
{
if ( _selectedSet.Count == 0 && Selected == null ) return;
_selectedSet.Clear();
Selected = null;
SelectionChanged?.Invoke();
}
/// <summary>Replace the selection with the given set. Primary becomes the
/// last element in the enumeration.</summary>
public void SetSelection( IEnumerable<SuiElement> elements )
{
_selectedSet.Clear();
Selected = null;
if ( elements != null )
{
foreach ( var el in elements )
{
if ( el == null ) continue;
_selectedSet.Add( el );
Selected = el;
}
}
SelectionChanged?.Invoke();
}
/// <summary>Add an element to the selection (Shift+click). Becomes primary.</summary>
public void AddSelected( SuiElement element )
{
if ( element == null ) return;
_selectedSet.Add( element );
Selected = element;
SelectionChanged?.Invoke();
}
/// <summary>Remove an element from the selection. Primary may shift.</summary>
public void RemoveSelected( SuiElement element )
{
if ( element == null ) return;
if ( !_selectedSet.Remove( element ) ) return;
if ( Selected == element )
Selected = _selectedSet.LastOrDefault();
SelectionChanged?.Invoke();
}
/// <summary>Toggle an element in/out of the selection (Shift+click on element).</summary>
public void ToggleSelected( SuiElement element )
{
if ( element == null ) return;
if ( _selectedSet.Contains( element ) ) RemoveSelected( element );
else AddSelected( element );
}
/// <summary>Is this element currently selected?</summary>
public bool IsSelected( SuiElement element )
=> element != null && _selectedSet.Contains( element );
// ─────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────
public void Execute( ISuiCommand cmd )
{
if ( Document == null || cmd == null ) return;
Commands.Push( cmd, Document );
SetDirty();
DocumentChanged?.Invoke();
}
public void Undo()
{
if ( Document == null || !Commands.CanUndo ) return;
Commands.Undo( Document );
SetDirty();
// If the selected element was deleted by the command we undid, the
// selection might now point at a stale instance. Validate and clear.
ValidateSelection();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
}
public void Redo()
{
if ( Document == null || !Commands.CanRedo ) return;
Commands.Redo( Document );
SetDirty();
ValidateSelection();
DocumentChanged?.Invoke();
SelectionChanged?.Invoke();
}
private void ValidateSelection()
{
// Drop any selected elements that no longer exist in the document.
// (Undo/redo can resurrect or remove elements, leaving stale references.)
_selectedSet.RemoveWhere( el => Document?.GetElement( el.Id ) == null );
if ( Selected != null && Document?.GetElement( Selected.Id ) == null )
Selected = _selectedSet.LastOrDefault() ?? Document?.GetRoot();
if ( Selected == null ) return;
if ( Document.GetElement( Selected.Id ) == null )
{
Selected = Document.GetRoot();
}
}
// ─────────────────────────────────────────────────────────────────────
// High-level operations
// These wrap a command + an optional selection update so callers don't
// have to construct command objects manually.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Add a new element of <paramref name="type"/> as a child of
/// <paramref name="parent"/> (or the current selection's container, or root).
/// Selects the new element automatically.
/// </summary>
public SuiElement AddElement( SuiElementType type, SuiElement parent = null )
{
if ( Document == null ) return null;
parent ??= ResolveAddTarget();
if ( parent == null ) return null;
var element = new SuiElement
{
Id = SuiDocument.NewElementId(),
Name = SuggestUniqueName( type ),
Type = type,
ParentId = parent.Id,
};
element.ApplyTypeDefaults();
element.Style.ClassName = SuiDocumentValidator.SanitizeClassName( element.Name );
Execute( new SuiAddElementCommand( element, parent.Id ) );
SetSelected( element );
return element;
}
/// <summary>
/// Pick the parent for a click-to-add operation. M5 default is "always Root"
/// — auto-nesting based on the current selection caused surprising behaviour
/// (click Panel → click Image → Image becomes child of Panel even though
/// the user clicked the palette without dragging). Drag-and-drop in M6 will
/// reintroduce nesting via explicit drop targets.
///
/// Callers that already know the parent (e.g. a future drag handler) should
/// pass the parent explicitly to <see cref="AddElement(SuiElementType, SuiElement)"/>
/// rather than rely on this resolver.
/// </summary>
private SuiElement ResolveAddTarget()
{
return Document.GetRoot();
}
private static bool IsContainer( SuiElementType type ) => type switch
{
SuiElementType.Canvas
or SuiElementType.Panel
or SuiElementType.Overlay
or SuiElementType.HorizontalBox
or SuiElementType.VerticalBox
or SuiElementType.Grid
or SuiElementType.ScrollPanel
or SuiElementType.InventoryGrid
or SuiElementType.Hotbar => true,
_ => false,
};
private string SuggestUniqueName( SuiElementType type )
{
var baseName = type.ToString();
if ( Document == null ) return baseName;
// Try the bare type name first; otherwise append _2, _3, ...
if ( !NameExists( baseName ) ) return baseName;
for ( int i = 2; i < 1000; i++ )
{
var candidate = $"{baseName}_{i}";
if ( !NameExists( candidate ) ) return candidate;
}
return $"{baseName}_{System.Guid.NewGuid().ToString( "N" ).Substring( 0, 4 )}";
}
private bool NameExists( string name )
{
foreach ( var el in Document.Elements )
if ( string.Equals( el.Name, name, StringComparison.OrdinalIgnoreCase ) ) return true;
return false;
}
/// <summary>
/// Delete the selected element (or the explicitly given one). Root cannot be deleted.
/// </summary>
public void DeleteElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return; // root or unset
var newSelection = Document.GetElement( element.ParentId ) ?? Document.GetRoot();
Execute( new SuiDeleteElementCommand( element.Id ) );
SetSelected( newSelection );
}
public void RenameElement( SuiElement element, string newName )
{
if ( element == null || string.IsNullOrEmpty( newName ) ) return;
if ( element.Name == newName ) return; // no-op
Execute( new SuiRenameElementCommand( element.Id, newName ) );
}
/// <summary>
/// Duplicate an element together with its descendants. New ids throughout
/// the cloned subtree, inserted as a sibling immediately after the source.
/// Selects the duplicate after the operation.
/// </summary>
public SuiElement DuplicateElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return null; // refuse root
var cmd = new SuiDuplicateElementCommand( element.Id );
Execute( cmd );
// Select the new clone if Apply succeeded.
if ( !string.IsNullOrEmpty( cmd.ResultingElementId ) && Document != null )
{
var newElement = Document.GetElement( cmd.ResultingElementId );
if ( newElement != null ) SetSelected( newElement );
}
return Selected;
}
/// <summary>Move the element up among its siblings (or selection if null).</summary>
public void MoveElementUp( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return;
Execute( new SuiReorderElementCommand( element.Id, -1 ) );
}
/// <summary>Move the element down among its siblings (or selection if null).</summary>
public void MoveElementDown( SuiElement element = null )
{
element ??= Selected;
if ( element == null || string.IsNullOrEmpty( element.ParentId ) ) return;
Execute( new SuiReorderElementCommand( element.Id, +1 ) );
}
// ─────────────────────────────────────────────────────────────────────
// Clipboard — Cut / Copy / Paste
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Copy the element (or current selection) and its descendants to the
/// process-local clipboard. Doesn't mutate the document — clipboard holds
/// deep clones, not references.
/// </summary>
public void CopyElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || Document == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return; // refuse copying root
var byId = new Dictionary<string, SuiElement>();
foreach ( var el in Document.Elements )
if ( !string.IsNullOrEmpty( el.Id ) ) byId[el.Id] = el;
var payload = new SuiClipboardPayload();
payload.Root = CloneSubtreeForClipboard( element, byId, payload.All );
SuiClipboard.Set( payload );
}
/// <summary>Copy + delete (Cut). Refuses to cut root.</summary>
public void CutElement( SuiElement element = null )
{
element ??= Selected;
if ( element == null || Document == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return;
CopyElement( element );
DeleteElement( element );
}
/// <summary>
/// Paste the clipboard subtree under <paramref name="parent"/> (defaults to
/// the current selection if it's a container, otherwise the selection's parent,
/// otherwise root). Selects the pasted root after the operation.
/// </summary>
public SuiElement PasteElement( SuiElement parent = null )
{
if ( Document == null || !SuiClipboard.HasContent ) return null;
parent ??= ResolvePasteParent();
if ( parent == null ) return null;
var cmd = new SuiPasteElementCommand( SuiClipboard.Get(), parent.Id );
Execute( cmd );
if ( !string.IsNullOrEmpty( cmd.ResultingElementId ) )
{
var pasted = Document.GetElement( cmd.ResultingElementId );
if ( pasted != null ) SetSelected( pasted );
return pasted;
}
return null;
}
/// <summary>True if there's anything to paste — used by menus to enable/disable.</summary>
public bool CanPaste => SuiClipboard.HasContent && Document != null;
private SuiElement ResolvePasteParent()
{
if ( Selected == null ) return Document.GetRoot();
// If selected is a container, paste INTO it.
if ( IsContainer( Selected.Type ) ) return Selected;
// Else paste as sibling (= same parent).
var parent = Document.GetElement( Selected.ParentId );
return parent ?? Document.GetRoot();
}
private static SuiElement CloneSubtreeForClipboard(
SuiElement source,
Dictionary<string, SuiElement> byId,
List<SuiElement> output )
{
var clone = source.Clone();
clone.Children = new List<string>(); // will be repopulated below with cloned ids
output.Add( clone );
foreach ( var childId in source.Children ?? new List<string>() )
{
if ( !byId.TryGetValue( childId, out var child ) ) continue;
var clonedChild = CloneSubtreeForClipboard( child, byId, output );
clonedChild.ParentId = clone.Id; // preserves intra-subtree linkage
clone.Children.Add( clonedChild.Id );
}
return clone;
}
/// <summary>
/// Reparent an element to a new container at a specific child index.
/// Refuses to move root and refuses to create cycles.
/// </summary>
public void ReparentElement( SuiElement element, SuiElement newParent, int insertIndex )
{
if ( element == null || newParent == null ) return;
if ( string.IsNullOrEmpty( element.ParentId ) ) return; // refuse root
Execute( new SuiReparentElementCommand( element.Id, newParent.Id, insertIndex ) );
}
public void MoveElement( SuiElement element, float newX, float newY )
{
if ( element == null ) return;
Execute( new SuiMoveElementCommand( element.Id, newX, newY ) );
NotifySelectionDataMaybeChanged( element );
}
/// <summary>
/// Change anchor while preserving the element's visual position. UMG / UI
/// Builder convention: switching anchor only changes the reference point;
/// the element should NOT jump on screen. The command captures the current
/// rect under the old anchor and re-derives X/Y/W/H under the new one.
/// </summary>
public void SetAnchor( SuiElement element, SuiAnchor newAnchor )
{
if ( element?.Layout == null ) return;
if ( element.Layout.Anchor == newAnchor ) return;
Execute( new SuiSetAnchorCommand( element.Id, newAnchor ) );
NotifySelectionDataMaybeChanged( element );
}
/// <summary>
/// Re-fire SelectionChanged when an external mutation (canvas drag, anchor
/// swap) changed properties of the currently-selected element. The Details
/// panel uses SelectionChanged to rebuild itself, so this keeps its row
/// values in sync without doing a full Refresh on every property edit
/// (which would yank the scroll position).
/// </summary>
private void NotifySelectionDataMaybeChanged( SuiElement element )
{
if ( element == null ) return;
if ( Selected == element || _selectedSet.Contains( element ) )
SelectionChanged?.Invoke();
}
public void ResizeElement( SuiElement element, float newWidth, float newHeight )
{
if ( element == null ) return;
Execute( new SuiResizeElementCommand( element.Id, newWidth, newHeight ) );
NotifySelectionDataMaybeChanged( element );
}
// ─────────────────────────────────────────────────────────────────────
// Multi-element alignment (V1.0)
//
// Filters: only Absolute-mode elements that share a single parent.
// Mixed parents / mixed anchors are out of scope — the bounding-box
// math assumes a common coord space. Locked elements are skipped.
// ─────────────────────────────────────────────────────────────────────
public void AlignSelection( SuiAlignElementsCommand.Mode mode )
{
var ids = CollectAlignableSelection();
if ( ids == null || ids.Count < 2 )
{
Log.Info( "[Sui] Align needs ≥2 absolute-mode elements with the same parent." );
return;
}
Execute( new SuiAlignElementsCommand( ids, mode ) );
SelectionChanged?.Invoke();
}
public void DistributeSelection( SuiDistributeElementsCommand.Axis axis )
{
var ids = CollectAlignableSelection();
if ( ids == null || ids.Count < 3 )
{
Log.Info( "[Sui] Distribute needs ≥3 absolute-mode elements with the same parent." );
return;
}
Execute( new SuiDistributeElementsCommand( ids, axis ) );
SelectionChanged?.Invoke();
}
/// <summary>
/// Filters the current selection down to absolute-mode elements that all
/// share the same parent and aren't locked. Returns null if no usable set.
/// </summary>
private System.Collections.Generic.List<string> CollectAlignableSelection()
{
if ( _selectedSet == null || _selectedSet.Count < 2 ) return null;
string commonParent = null;
var ids = new System.Collections.Generic.List<string>();
foreach ( var el in _selectedSet )
{
if ( el?.Layout == null ) continue;
if ( el.Layout.Mode != SuiLayoutMode.Absolute ) continue;
if ( el.Flags?.Locked == true ) continue;
if ( string.IsNullOrEmpty( el.ParentId ) ) continue; // skip root
if ( commonParent == null ) commonParent = el.ParentId;
else if ( commonParent != el.ParentId ) return null; // mixed parents
ids.Add( el.Id );
}
return ids;
}
/// <summary>
/// Generic property setter — see <see cref="SuiSetPropertyCommand{T}"/> for usage.
/// </summary>
public void SetProperty<T>(
SuiElement element,
Func<SuiElement, T> getter,
Action<SuiElement, T> setter,
T newValue,
string description )
{
if ( element == null ) return;
Execute( new SuiSetPropertyCommand<T>( element.Id, getter, setter, newValue, description ) );
}
}