Editor/SuiDesignerWindow.cs
using Editor;
using Sandbox;
using SboxUiDesigner.EditorUi.Canvas;
using SboxUiDesigner.EditorUi.Commands;
using SboxUiDesigner.EditorUi.Widgets;
using SboxUiDesigner.Generation;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi;
/// <summary>
/// Asset editor window for .sui documents — registered for the "sui" extension
/// via <see cref="EditorForAssetTypeAttribute"/>. Opens on double-click in the
/// Asset Browser.
///
/// M4 (current) — DockWindow with menu bar, toolbar, and 5 region docks
/// (Palette, Hierarchy, Canvas, Details, Bottom). Region widgets are visually
/// built but not yet wired to a controller.
///
/// M5 — wires <see cref="SuiDesignerController"/> for selection + dirty state +
/// command stack between region widgets.
///
/// M10 (Spike 01) — replaces the Canvas widget's placeholder with
/// Editor.SceneRenderingWidget hosting an editor-owned Scene with
/// ScreenPanel + the generated PanelComponent.
///
/// Pattern reference: Facepunch Sound Editor
/// (sbox-public/game/addons/tools/Code/Editor/SoundEditor/Window.cs).
/// </summary>
[EditorForAssetType( "sui" )]
public class SuiDesignerWindow : DockWindow, IAssetEditor
{
public bool CanOpenMultipleAssets => false;
/// <summary>
/// IAssetEditor contract — invoked when a host (e.g. graph editor) asks the
/// asset editor to focus on a named member of the asset. M5 has no member
/// navigation surface, so this is intentionally a no-op until M8 wires the
/// Details panel's per-property editors and gives them stable names.
/// </summary>
public void SelectMember( string memberName ) { }
private Asset _asset;
private SuiAsset _resource;
// M5 — controller mediates selection/dirty/commands between widgets.
private readonly SuiDesignerController _controller = new();
// Convenience access to the document; comes from the controller.
private SuiDocument Document => _controller.Document;
// Region widgets — owned by our custom layout (NO DockManager).
private SuiPaletteWidget _palette;
private SuiHierarchyWidget _hierarchy;
private SuiCenterTabsWidget _centerTabs;
// Backwards-compatible accessor — returns the canvas inside the center tabs.
private SuiCanvasWidget _canvas => _centerTabs?.Canvas;
private SuiDetailsWidget _details;
private SuiBottomTabsWidget _bottomTabs;
// Backwards-compatible accessors (so existing code paths don't break).
private SuiCompileResultsWidget _compileResults => _bottomTabs?.CompileResults;
public SuiDesignerWindow()
{
DeleteOnClose = true;
WindowTitle = "Sbox UI Designer";
Title = "Sbox UI Designer";
Size = new Vector2( 1600, 900 );
SetWindowIcon( "view_quilt" );
_controller.DocumentChanged += OnControllerDocumentChanged;
_controller.SelectionChanged += OnControllerSelectionChanged;
_controller.DirtyChanged += OnControllerDirtyChanged;
BuildMenuBar();
BuildToolBar();
BuildDocks();
Show();
}
// ─────────────────────────────────────────────────────────────────────
// IAssetEditor
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Set when the asset's file on disk has size suggesting it should contain
/// elements, but deserialization produced an empty Document. Cause: invalid
/// enum value, schema mismatch, or other JSON parse error that
/// System.Text.Json swallowed silently (it returns null/empty for the
/// failing collection rather than throwing all the way up to us).
///
/// While true, <see cref="Save"/> and <see cref="OnClose"/> REFUSE to write
/// back to disk — otherwise auto-save would replace the user's file with a
/// 1-element default doc, destroying their work.
/// </summary>
private bool _loadLikelyFailed;
public void AssetOpen( Asset asset )
{
_asset = asset;
_resource = asset?.LoadResource<SuiAsset>();
var hadExistingResource = _resource != null;
_resource ??= new SuiAsset();
var doc = _resource.Document;
_loadLikelyFailed = false;
if ( doc == null || doc.Elements.Count == 0 )
{
// Distinguish "brand new asset" from "existing file failed to parse".
// New asset: no file on disk (or trivially small). Safe to seed with default.
// Failed parse: file exists with non-trivial size. NEVER replace — that
// would erase the user's data when OnClose triggers auto-save.
var path = asset?.AbsolutePath;
long fileSize = 0;
if ( !string.IsNullOrEmpty( path ) )
{
try { fileSize = new System.IO.FileInfo( path ).Length; } catch { /* ignored */ }
}
// 1KB is roughly the size of a minimal valid .sui (just a default root).
// Anything bigger almost certainly had children that failed to parse.
const long suspiciousSizeThreshold = 1024;
var probableLoadFailure = hadExistingResource && fileSize > suspiciousSizeThreshold;
if ( probableLoadFailure )
{
_loadLikelyFailed = true;
Log.Error(
$"[Sui] Failed to load '{asset?.Path}' — the file on disk is {fileSize} bytes " +
$"but parsed to {doc?.Elements?.Count ?? 0} element(s). This usually means an " +
$"invalid enum value (e.g. FontWeight='Black' — valid values are Normal/Bold/Light/Medium/SemiBold/ExtraBold). " +
$"Save is DISABLED while in this state to avoid overwriting your file. " +
$"Close the editor WITHOUT saving and fix the JSON, or recreate the .sui." );
}
var nameHint = !string.IsNullOrEmpty( asset?.Name ) ? asset.Name : "NewUi";
doc = SuiDocument.CreateDefault( nameHint );
_resource.Document = doc;
}
// Apply schema migrations (idempotent) — converts pre-2026-05-08 Text
// elements with explicit W/H to Fixed mode so Auto doesn't shrink them.
SuiDocumentMigration.Apply( doc );
_controller.SetDocument( doc );
// Controller raises DocumentChanged + SelectionChanged synchronously,
// which pushes state to all region widgets.
}
private void OnControllerDocumentChanged()
{
_hierarchy?.SetDocument( Document );
_centerTabs?.SetDocument( Document );
_bottomTabs?.SetDocument( Document );
_details?.SetDocument( Document );
// IMPORTANT: do NOT cascade into OnControllerSelectionChanged here.
// DocumentChanged fires on every property edit (e.g. dragging a color
// slider commits per-frame). If we cascaded, Details would tear down
// + rebuild all rows 60Hz, leaving LineEdits half-painted.
//
// Selection-derived UI (Hierarchy highlight, Details rows) only needs
// to refresh when the selection itself OR data on the SELECTED element
// changes — both of those come through the SelectionChanged event
// independently. Canvas + Hierarchy.SetDocument above already invalidate
// for repaint.
RefreshTitle();
// Note: the preview cache (Code/_sui_preview/...) is NO LONGER
// regenerated on every doc change. Doing so triggered a full S&Box
// hot-reload after every palette add / hierarchy move because the
// engine compiles every .razor under Code/. The cache now refreshes
// only when the user activates the Preview tab or clicks Refresh
// inside it (see SuiCenterTabsWidget tab change + Preview button).
// Sweep any legacy `sui-generated-backups/` inside Code/. Older
// builds of the writer placed backups there; the engine compiles
// every .razor under Code/ so each backup turned into a duplicate
// `partial class <Name>` declaration → CS0111. New backups land in
// `<projectRoot>/.sui-backups/`; legacy folders are deleted here.
AutoCleanLegacyBackupsInsideCode();
}
/// <summary>
/// Silent auto-cleanup of legacy backup folders. Logs only when something
/// is actually deleted (no spam on every doc load).
/// </summary>
private void AutoCleanLegacyBackupsInsideCode()
{
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) ) return;
var codeRoot = System.IO.Path.Combine( projectRoot, "Code" );
if ( !System.IO.Directory.Exists( codeRoot ) ) return;
int deleted = 0;
try
{
foreach ( var dir in System.IO.Directory.EnumerateDirectories(
codeRoot, "sui-generated-backups", System.IO.SearchOption.AllDirectories ) )
{
try
{
System.IO.Directory.Delete( dir, recursive: true );
deleted++;
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] auto-clean: failed to delete '{dir}': {ex.Message}" );
}
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] auto-clean walk failed: {ex.Message}" );
}
if ( deleted > 0 )
Log.Info( $"[Sui] auto-cleaned {deleted} legacy backup folder(s) under Code/. New backups now live at <projectRoot>/.sui-backups/." );
}
/// <summary>
/// Always write the preview cache (Code/_sui_preview/...) — used when the
/// user activates the Preview tab or clicks Refresh inside it. This DOES
/// trigger an engine hot-reload, which is fine here because it's tied to
/// an explicit user action, not to every document edit.
/// </summary>
private void RegeneratePreviewCacheNow()
{
if ( Document == null ) return;
try
{
var r = SuiPreviewCacheWriter.Write( Document );
if ( r.Ok && (r.RazorChanged || r.ScssChanged) )
Log.Info( $"[Sui] preview cache refreshed → {r.TypeFullName}" );
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to refresh preview cache: {ex.Message}" );
}
}
private void RefreshStalePreviewCacheIfPresent()
{
if ( Document == null ) return;
var rawClass = !string.IsNullOrEmpty( Document.Output?.ClassName )
? Document.Output.ClassName
: Document.Name;
var className = SuiDocumentValidator.SanitizeClassName( rawClass ?? "" );
if ( string.IsNullOrEmpty( className ) ) return;
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) ) return;
var cacheRazor = System.IO.Path.Combine(
projectRoot, "Code", "_sui_preview", className, $"{className}.razor" );
if ( !System.IO.File.Exists( cacheRazor ) ) return;
// Cache exists from a previous Preview session. Re-emit so it reflects
// the current generator (e.g. picks up the .SuiPreview namespace).
try
{
var r = SuiPreviewCacheWriter.Write( Document );
if ( r.Ok && (r.RazorChanged || r.ScssChanged) )
Log.Info( $"[Sui] preview cache refreshed → {r.TypeFullName}" );
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to refresh preview cache: {ex.Message}" );
}
}
private void OnControllerSelectionChanged()
{
// Push both the primary (for inline rename, scroll-to, etc.) AND the
// full set (for highlighting + multi-select-aware UI).
_hierarchy?.SetSelected( _controller.Selected );
_hierarchy?.SetSelectedSet( _controller.SelectedSet );
_hierarchy?.Refresh();
_details?.SetSelectedSet( _controller.Selected, _controller.SelectedCount );
}
private void OnControllerDirtyChanged()
{
RefreshTitle();
}
private void RefreshTitle()
{
var docName = Document?.Name ?? "(unsaved)";
var dirtyMark = _controller.IsDirty ? " *" : "";
WindowTitle = $"Sbox UI Designer — {docName}{dirtyMark}";
Title = WindowTitle;
}
// ─────────────────────────────────────────────────────────────────────
// Save
// ─────────────────────────────────────────────────────────────────────
private void Save()
{
if ( _asset == null || _resource == null || Document == null )
{
Log.Warning( "[Sui] cannot save — no document loaded" );
return;
}
// Guard: if the load failed (suspicious-size file produced empty doc),
// never write back — the in-memory doc is a placeholder, writing it
// would replace the user's actual data with an empty document.
if ( _loadLikelyFailed )
{
Log.Warning( "[Sui] save BLOCKED — original file failed to parse and the in-memory document is a placeholder. Fix the .sui JSON on disk before saving." );
return;
}
var report = SuiDocumentValidator.Validate( Document );
foreach ( var err in report.Errors )
Log.Warning( $"[Sui] validation: {err}" );
_resource.Document = Document;
_asset.SaveToDisk( _resource );
Log.Info( $"[Sui] saved {_asset.Path}" );
_controller.MarkSaved();
RefreshTitle();
}
protected override bool OnClose()
{
// Save() itself checks _loadLikelyFailed; double-guard here to keep the
// intent visible at the call site.
if ( !_loadLikelyFailed )
Save();
return true;
}
[Shortcut( "editor.save", "Ctrl+S", ShortcutType.Window )]
private void OnShortcutSave() => Save();
// ─────────────────────────────────────────────────────────────────────
// Hotload — DockWindow + IAssetEditor pattern requires rebuilding
// menu/toolbar/docks after a hotload (per the Sound Editor reference).
// ─────────────────────────────────────────────────────────────────────
[EditorEvent.Hotload]
public void OnHotload()
{
MenuBar.Clear();
// Tear down old custom layout so it gets rebuilt fresh.
if ( _rootContainer.IsValid() )
{
try { _rootContainer.Destroy(); } catch { }
_rootContainer = null;
}
BuildMenuBar();
BuildToolBar();
BuildDocks();
// Re-fan the controller's current state out to the freshly-rebuilt widgets.
OnControllerDocumentChanged();
}
protected override void RestoreDefaultDockLayout()
{
// 100% custom layout — nothing to restore from a dock cookie.
}
// ─────────────────────────────────────────────────────────────────────
// Menu bar
// ─────────────────────────────────────────────────────────────────────
private void BuildMenuBar()
{
var file = MenuBar.AddMenu( "File" );
file.AddOption( "Save", "save", Save, "editor.save" );
file.AddOption( "Compile", "build", Compile );
file.AddSeparator();
file.AddOption( "Change Output Folder…", "folder_open", ChangeOutputFolder );
file.AddOption( "Open Generated Folder", "folder", OpenGeneratedFolder );
file.AddSeparator();
file.AddOption( "Close", "close", Close );
var edit = MenuBar.AddMenu( "Edit" );
edit.AddOption( "Undo", "undo", Undo, "editor.undo" );
edit.AddOption( "Redo", "redo", Redo, "editor.redo" );
edit.AddSeparator();
edit.AddOption( "Cut", "content_cut", () => _controller.CutElement() );
edit.AddOption( "Copy", "content_copy", () => _controller.CopyElement() );
edit.AddOption( "Paste", "content_paste", () => _controller.PasteElement() );
edit.AddOption( "Duplicate", "content_copy", () => _controller.DuplicateElement() );
edit.AddOption( "Delete", "delete", () => _controller.DeleteElement() );
edit.AddOption( "Rename", "edit", () => _hierarchy?.BeginRenameSelected() );
edit.AddSeparator();
var align = edit.AddMenu( "Align" );
align.AddOption( "Left", "align_horizontal_left", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Left ) );
align.AddOption( "Center (H)", "align_horizontal_center", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.HCenter ) );
align.AddOption( "Right", "align_horizontal_right", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Right ) );
align.AddSeparator();
align.AddOption( "Top", "align_vertical_top", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Top ) );
align.AddOption( "Center (V)", "align_vertical_center", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.VCenter ) );
align.AddOption( "Bottom", "align_vertical_bottom", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Bottom ) );
align.AddSeparator();
align.AddOption( "Distribute Horizontally", "horizontal_distribute", () => _controller?.DistributeSelection( SuiDistributeElementsCommand.Axis.Horizontal ) );
align.AddOption( "Distribute Vertically", "vertical_distribute", () => _controller?.DistributeSelection( SuiDistributeElementsCommand.Axis.Vertical ) );
var view = MenuBar.AddMenu( "View" );
view.AddOption( "Zoom In", "zoom_in", ZoomIn );
view.AddOption( "Zoom Out", "zoom_out", ZoomOut );
view.AddOption( "Fit to Screen", "fit_screen", FitToScreen );
var tools = MenuBar.AddMenu( "Tools" );
tools.AddOption( "Validate Document", "rule", ValidateDocument );
tools.AddOption( "Regenerate Preview", "refresh", RegeneratePreview );
tools.AddOption( "Clean Preview Cache", "delete_sweep", CleanPreviewCache );
tools.AddOption( "Clean All SUI Caches (preview + backups)", "broom", CleanLegacyBackups );
tools.AddSeparator();
tools.AddOption( "Install Sample Documents", "library_books", InstallSamples );
var help = MenuBar.AddMenu( "Help" );
help.AddOption( "Open PRD", "menu_book", () => { } );
}
// ─────────────────────────────────────────────────────────────────────
// Toolbar
// ─────────────────────────────────────────────────────────────────────
private SuiTopBar _topBar;
private SuiTopBarButton _gridBtn;
private Widget _rootContainer;
private void BuildToolBar()
{
// 100% custom layout — NO DockManager, NO Editor.ToolBar.
// Root is a Column container holding:
// [SuiTopBar]
// [Body (Row): leftSidebar | center | rightSidebar]
// [BottomPanel]
// Built by BuildDocks() — this method just creates the top bar.
if ( _topBar.IsValid() )
{
try { _topBar.Destroy(); } catch { }
_topBar = null;
}
_topBar = new SuiTopBar();
// Group 1: file ops
_topBar.AddButton( "Save", "save", Save, tooltip: "Save (Ctrl+S)" );
_topBar.AddButton( "Compile", "auto_fix_high", Compile, tooltip: "Compile (Ctrl+B)" );
_topBar.AddSeparator();
// Group 2: preview
_topBar.AddButton( "Test in Play", "play_arrow", LaunchTestInPlay, hasChevron: false, tooltip: "Compile current .sui, load the preview stage scene (player + ground + light), and enter Play mode with the UI mounted on a ScreenPanel." );
_topBar.AddSeparator();
// Group 3: history
_topBar.AddButton( "Undo", "undo", Undo, tooltip: "Undo (Ctrl+Z)" );
_topBar.AddButton( "Redo", "redo", Redo, tooltip: "Redo (Ctrl+Y)" );
_topBar.AddSeparator();
// Group 4: grid toggle
// (Snap / Zoom / Screen Size moved to the canvas mini-toolbar — they're
// canvas-scoped controls and felt redundant in the global top bar.)
_gridBtn = _topBar.AddButton( "Grid", "apps", ToggleGrid, tooltip: "Toggle grid overlay" );
_topBar.AddStretch();
_topBar.AddButton( "Settings", "settings", () => Log.Info( "[Sui] settings dialog — V2" ), hasChevron: true, tooltip: "Editor settings" );
RefreshGridButton();
}
private string SnapValueFor( SuiDocumentSettings s )
{
if ( s == null || !s.SnapToGrid ) return "off";
return $"{s.GridSize}px";
}
private string ZoomValueFor()
{
var vp = _canvas?.GetViewport();
var pct = vp != null ? (int)System.Math.Round( vp.Zoom * 100 ) : 100;
return $"{pct}%";
}
private string ScreenValueFor()
{
var c = Document?.Canvas;
if ( c == null ) return "1920 x 1080";
var w = c.PreviewWidth > 0 ? c.PreviewWidth : c.BaseWidth;
var h = c.PreviewHeight > 0 ? c.PreviewHeight : c.BaseHeight;
return $"{w} x {h}";
}
private void RefreshGridButton()
{
if ( _gridBtn == null ) return;
_gridBtn.IsActive = Document?.Settings?.ShowGrid == true;
_gridBtn.Update();
}
private void RefreshToolbarLabels()
{
// The dropdowns now live inside the canvas mini-toolbar (owned by
// SuiCenterTabsWidget). Pull them via getters and refresh.
if ( _centerTabs?.SnapDropdown != null ) _centerTabs.SnapDropdown.Value = SnapValueFor( Document?.Settings );
if ( _centerTabs?.ZoomDropdown != null ) _centerTabs.ZoomDropdown.Value = ZoomValueFor();
if ( _centerTabs?.ScreenDropdown != null ) _centerTabs.ScreenDropdown.Value = ScreenValueFor();
RefreshGridButton();
}
private void OpenSnapMenuAt( Widget anchor )
{
if ( Document?.Settings == null ) return;
var menu = new Menu( anchor );
menu.AddOption( "Off", "close", () =>
{
Document.Settings.SnapToGrid = false;
_canvas?.GetViewport()?.Invalidate();
RefreshToolbarLabels();
} );
menu.AddSeparator();
void Add( int px ) => menu.AddOption( $"{px} px", "grid_4x4", () =>
{
Document.Settings.SnapToGrid = true;
Document.Settings.GridSize = px;
_canvas?.GetViewport()?.Invalidate();
RefreshToolbarLabels();
} );
Add( 4 ); Add( 8 ); Add( 16 ); Add( 32 ); Add( 64 );
menu.OpenAtCursor( true );
}
private void OpenZoomMenuAt( Widget anchor )
{
var vp = _canvas?.GetViewport();
if ( vp == null ) return;
var menu = new Menu( anchor );
void Add( int pct ) => menu.AddOption( $"{pct}%", "zoom_in", () =>
{
vp.SetZoom( pct / 100f );
RefreshToolbarLabels();
} );
Add( 25 ); Add( 50 ); Add( 75 ); Add( 100 ); Add( 150 ); Add( 200 ); Add( 400 );
menu.AddSeparator();
menu.AddOption( "Fit", "fit_screen", () => { vp.FitCanvas(); RefreshToolbarLabels(); } );
menu.OpenAtCursor( true );
}
private void OpenScreenSizeMenuAt( Widget anchor )
{
if ( Document?.Canvas == null ) return;
var menu = new Menu( anchor );
void Add( string label, int w, int h ) => menu.AddOption( label, "aspect_ratio", () =>
{
Document.Canvas.PreviewWidth = w;
Document.Canvas.PreviewHeight = h;
_canvas?.GetViewport()?.Invalidate();
RefreshToolbarLabels();
} );
Add( "1920 × 1080 (FHD)", 1920, 1080 );
Add( "1280 × 720 (HD)", 1280, 720 );
Add( "2560 × 1440 (QHD)", 2560, 1440 );
Add( "3840 × 2160 (4K)", 3840, 2160 );
Add( "2560 × 1080 (Ultrawide)", 2560, 1080 );
menu.AddSeparator();
menu.AddOption( "Reset", "restart_alt", () =>
{
Document.Canvas.PreviewWidth = 0;
Document.Canvas.PreviewHeight = 0;
_canvas?.GetViewport()?.Invalidate();
RefreshToolbarLabels();
} );
menu.OpenAtCursor( true );
}
private void ToggleGrid()
{
if ( Document?.Settings == null ) return;
Document.Settings.ShowGrid = !Document.Settings.ShowGrid;
_canvas?.GetViewport()?.Invalidate();
RefreshGridButton();
}
private void ToggleRulers()
{
if ( Document?.Settings == null ) return;
Document.Settings.ShowRulers = !Document.Settings.ShowRulers;
_canvas?.GetViewport()?.Invalidate();
_centerTabs?.RefreshToggleStates();
}
private void ToggleResponsiveDebug()
{
if ( Document?.Settings == null ) return;
Document.Settings.ResponsiveDebug = !Document.Settings.ResponsiveDebug;
_canvas?.GetViewport()?.Invalidate();
_centerTabs?.RefreshToggleStates();
}
private void ToggleAnchors()
{
if ( Document?.Settings == null ) return;
Document.Settings.ShowAnchors = !Document.Settings.ShowAnchors;
_canvas?.GetViewport()?.Invalidate();
_centerTabs?.RefreshToggleStates();
}
private void ToggleLayoutBounds()
{
if ( Document?.Settings == null ) return;
Document.Settings.ShowLayoutBounds = !Document.Settings.ShowLayoutBounds;
_canvas?.GetViewport()?.Invalidate();
_centerTabs?.RefreshToggleStates();
}
// Mini-toolbar Align/Distribute dropdowns — both anchor under the button
// that triggered them so the menu opens visually attached to the toolbar.
private void OpenAlignMenuAt( Widget anchor )
{
var menu = new Menu( anchor );
menu.AddOption( "Left", "align_horizontal_left", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Left ) );
menu.AddOption( "Center (H)", "align_horizontal_center", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.HCenter ) );
menu.AddOption( "Right", "align_horizontal_right", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Right ) );
menu.AddSeparator();
menu.AddOption( "Top", "align_vertical_top", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Top ) );
menu.AddOption( "Center (V)", "align_vertical_center", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.VCenter ) );
menu.AddOption( "Bottom", "align_vertical_bottom", () => _controller?.AlignSelection( SuiAlignElementsCommand.Mode.Bottom ) );
menu.OpenAtCursor();
}
private void OpenDistributeMenuAt( Widget anchor )
{
var menu = new Menu( anchor );
menu.AddOption( "Horizontally", "horizontal_distribute", () => _controller?.DistributeSelection( SuiDistributeElementsCommand.Axis.Horizontal ) );
menu.AddOption( "Vertically", "vertical_distribute", () => _controller?.DistributeSelection( SuiDistributeElementsCommand.Axis.Vertical ) );
menu.OpenAtCursor();
}
private void OpenSnapMenuFromToolbar()
{
if ( Document?.Settings == null ) return;
var menu = new Menu( this );
menu.AddOption( "Off", "close", () => { Document.Settings.SnapToGrid = false; _canvas?.GetViewport()?.Invalidate(); } );
menu.AddSeparator();
void Add( int px ) => menu.AddOption( $"{px} px", "grid_4x4", () =>
{
Document.Settings.SnapToGrid = true;
Document.Settings.GridSize = px;
_canvas?.GetViewport()?.Invalidate();
} );
Add( 4 ); Add( 8 ); Add( 16 ); Add( 32 ); Add( 64 );
menu.OpenAtCursor( true );
}
private void OpenZoomMenuFromToolbar()
{
var vp = _canvas?.GetViewport();
if ( vp == null ) return;
var menu = new Menu( this );
void Add( int pct ) => menu.AddOption( $"{pct}%", "zoom_in", () => vp.SetZoom( pct / 100f ) );
Add( 25 ); Add( 50 ); Add( 75 ); Add( 100 ); Add( 150 ); Add( 200 ); Add( 400 );
menu.AddSeparator();
menu.AddOption( "Fit", "fit_screen", () => vp.FitCanvas() );
menu.OpenAtCursor( true );
}
private void OpenScreenSizeMenuFromToolbar()
{
if ( Document?.Canvas == null ) return;
var menu = new Menu( this );
void Add( string label, int w, int h ) => menu.AddOption( label, "aspect_ratio", () =>
{
Document.Canvas.PreviewWidth = w;
Document.Canvas.PreviewHeight = h;
_canvas?.GetViewport()?.Invalidate();
} );
Add( "1920 × 1080 (FHD)", 1920, 1080 );
Add( "1280 × 720 (HD)", 1280, 720 );
Add( "2560 × 1440 (QHD)", 2560, 1440 );
Add( "3840 × 2160 (4K)", 3840, 2160 );
Add( "2560 × 1080 (Ultrawide)", 2560, 1080 );
menu.AddSeparator();
menu.AddOption( "Reset", "restart_alt", () =>
{
Document.Canvas.PreviewWidth = 0;
Document.Canvas.PreviewHeight = 0;
_canvas?.GetViewport()?.Invalidate();
} );
menu.OpenAtCursor( true );
}
private void OpenInventoryGridWizard()
{
if ( Document == null ) return;
var wizard = new SuiInventoryGridWizard();
wizard.OnCreate = cfg =>
{
// Build the grid + populate with empty slots.
var grid = _controller.AddElement( SuiElementType.InventoryGrid );
if ( grid == null ) return;
grid.Layout.Width = cfg.Columns * cfg.SlotSize + (cfg.Columns - 1) * cfg.Gap;
grid.Layout.Height = cfg.Rows * cfg.SlotSize + (cfg.Rows - 1) * cfg.Gap;
grid.Layout.Anchor = cfg.Anchor;
grid.Layout.PivotX = cfg.Anchor.ToString().Contains( "Center" ) ? 0.5f
: cfg.Anchor.ToString().Contains( "Right" ) ? 1f : 0f;
grid.Layout.PivotY = cfg.Anchor.ToString().StartsWith( "Middle" ) ? 0.5f
: cfg.Anchor.ToString().StartsWith( "Bottom" ) ? 1f : 0f;
grid.Layout.Gap = cfg.Gap;
grid.Props.Columns = cfg.Columns;
grid.Props.Rows = cfg.Rows;
grid.Props.CellWidth = cfg.SlotSize;
grid.Props.CellHeight = cfg.SlotSize;
grid.Props.GridGap = cfg.Gap;
for ( int i = 0; i < cfg.Columns * cfg.Rows; i++ )
{
var slot = _controller.AddElement( SuiElementType.InventorySlot, grid );
if ( slot == null ) continue;
slot.Layout.Width = cfg.SlotSize;
slot.Layout.Height = cfg.SlotSize;
slot.Props.SlotIndex = i;
}
};
}
private void LaunchTestInPlay()
{
if ( Document == null )
{
Log.Warning( "[Sui] cannot launch Test in Play — no document loaded" );
return;
}
SuiPreviewLauncher.Launch( Document );
}
// ─────────────────────────────────────────────────────────────────────
// Docks
// ─────────────────────────────────────────────────────────────────────
private void BuildDocks()
{
// 100% custom layout — NO DockManager. Trade-off: panels are fixed,
// no drag/move, no saved layouts. User explicitly chose this path.
//
// ┌─────────────────────────────────────────────────────┐
// │ SuiTopBar │
// ├──────────┬──────────────────────────────┬──────────┤
// │ Palette │ │ │
// ├──────────┤ SuiCenterTabsWidget │ Details │
// │ Hierarchy│ (tabs + mini-toolbar + │ │
// │ │ canvas/preview/code) │ │
// ├──────────┴──────────────────────────────┴──────────┤
// │ SuiBottomTabsWidget (Animations/Bindings/...) │
// └─────────────────────────────────────────────────────┘
_palette = new SuiPaletteWidget();
_hierarchy = new SuiHierarchyWidget();
_centerTabs = new SuiCenterTabsWidget();
_details = new SuiDetailsWidget();
_bottomTabs = new SuiBottomTabsWidget();
_details.SetController( _controller );
_centerTabs.SetController( _controller );
// When the user activates the Preview tab, regenerate the cache once
// (lazy — avoids the engine hotload loop that used to fire on every
// property edit; see comment in OnControllerDocumentChanged).
_centerTabs.PreviewTabActivated += RegeneratePreviewCacheNow;
// Wire controller events.
_hierarchy.ElementSelected += el => _controller.SetSelected( el );
_palette.ElementRequested += type =>
{
if ( type == SuiElementType.InventoryGrid )
{
OpenInventoryGridWizard();
return;
}
_controller.AddElement( type );
};
_hierarchy.AddChildRequested += ( parent, type ) => _controller.AddElement( type, parent );
_hierarchy.RenameRequested += ( el, newName ) =>
{
if ( newName == null )
{
_controller.SetSelected( el );
_hierarchy.BeginRenameSelected();
}
else
{
_controller.RenameElement( el, newName );
}
};
_hierarchy.DeleteRequested += el => _controller.DeleteElement( el );
_hierarchy.DuplicateRequested += el => _controller.DuplicateElement( el );
_hierarchy.MoveUpRequested += el => _controller.MoveElementUp( el );
_hierarchy.MoveDownRequested += el => _controller.MoveElementDown( el );
_hierarchy.ReparentRequested += ( child, newParent, idx ) => _controller.ReparentElement( child, newParent, idx );
_hierarchy.FlagsChanged += () => _canvas?.GetViewport()?.Invalidate();
// Wire mini-toolbar (dropdowns + view-toggle buttons + fit) inside the center widget.
_centerTabs.WireMiniToolbar(
ScreenValueFor(), OpenScreenSizeMenuAt,
ZoomValueFor(), OpenZoomMenuAt,
SnapValueFor( Document?.Settings ), OpenSnapMenuAt,
ToggleRulers,
ToggleResponsiveDebug,
ToggleAnchors,
ToggleLayoutBounds,
OpenAlignMenuAt,
OpenDistributeMenuAt,
FitToScreen );
_centerTabs.RefreshToggleStates();
// ── Custom layout ────────────────────────────────────────────────
// Root background — #111111 (rgba 17,17,17). This is the color that
// shows through any gap between docked widgets (top-bar bottom edge,
// sidebar margins, etc).
_rootContainer = new Widget( this );
_rootContainer.SetStyles( "background-color: rgb(17,17,17);" );
_rootContainer.Layout = Layout.Column();
_rootContainer.Layout.Margin = 0;
_rootContainer.Layout.Spacing = 0;
// Top bar.
_rootContainer.Layout.Add( _topBar );
// Body row: leftSidebar | center | rightSidebar.
// 4px margin on top + left + right = breathing room around the body
// so the root #111111 background shows through as thin gutters.
var body = new Widget( _rootContainer );
body.SetStyles( "background-color: rgb(17,17,17);" );
body.Layout = Layout.Row();
body.Layout.Margin = new Sandbox.UI.Margin( 4, 4, 4, 0 );
body.Layout.Spacing = 0;
// Wrap each side panel widget in a SuiDockPanel for the docked-look
// chrome (header bar + body + border). Pure cosmetic; no real
// DockManager — this is the "imitação de docker" layer.
var paletteDock = new SuiDockPanel( "Palette", "category", _palette );
var hierarchyDock = new SuiDockPanel( "Hierarchy", "account_tree", _hierarchy );
var detailsDock = new SuiDockPanel( "Details", "tune", _details );
var leftSidebar = new Widget( body );
leftSidebar.SetStyles( "background-color: transparent;" );
leftSidebar.Layout = Layout.Column();
leftSidebar.Layout.Margin = 0;
leftSidebar.Layout.Spacing = 4; // 4px gap between Palette and Hierarchy
leftSidebar.FixedWidth = 280;
leftSidebar.Layout.Add( paletteDock, 1 );
leftSidebar.Layout.Add( hierarchyDock, 1 );
body.Layout.Add( leftSidebar );
// 4px gap between left sidebar and center.
var leftGap = new Widget( body );
leftGap.SetStyles( "background-color: transparent;" );
leftGap.FixedWidth = 4;
body.Layout.Add( leftGap );
// Center column = canvas tabs + bottom panel stacked. The bottom
// panel sits ONLY under the canvas — sidebars (Palette/Hierarchy on
// the left, Details on the right) extend full height to the bottom
// of the window, per user spec.
var centerColumn = new Widget( body );
centerColumn.SetStyles( "background-color: transparent; border: none;" );
centerColumn.Layout = Layout.Column();
centerColumn.Layout.Margin = 0;
centerColumn.Layout.Spacing = 4; // 4px gap between canvas and bottom panel
centerColumn.Layout.Add( _centerTabs, 1 );
_bottomTabs.FixedHeight = 220;
centerColumn.Layout.Add( _bottomTabs );
body.Layout.Add( centerColumn, 1 );
// 4px gap between center and right sidebar.
var rightGap = new Widget( body );
rightGap.SetStyles( "background-color: transparent;" );
rightGap.FixedWidth = 4;
body.Layout.Add( rightGap );
detailsDock.MinimumSize = new Vector2( 320, 0 );
detailsDock.MaximumSize = new Vector2( 320, int.MaxValue );
body.Layout.Add( detailsDock );
_rootContainer.Layout.Add( body, 1 );
Canvas = _rootContainer;
}
// ─────────────────────────────────────────────────────────────────────
// Stub commands (real implementations land in later milestones)
// ─────────────────────────────────────────────────────────────────────
private bool _compileRunning;
private void Compile()
{
if ( Document == null )
{
Log.Warning( "[Sui] cannot compile — no document loaded" );
return;
}
if ( _compileRunning )
{
Log.Info( "[Sui] compile already in progress" );
return;
}
// First-compile UX: prompt for output folder if not configured.
if ( !Document.Output.Configured || string.IsNullOrEmpty( Document.Output.RootFolder ) )
{
if ( !PromptOutputFolder() )
{
Log.Info( "[Sui] compile cancelled — no output folder selected" );
return;
}
}
_compileRunning = true;
try
{
// Sweep any legacy backup folders under Code/ before compiling so a
// stale `sui-generated-backups/.../UiTest2.razor` doesn't get pulled
// into the build alongside the freshly-written final output and
// trigger CS0111 (duplicate partial class declarations).
AutoCleanLegacyBackupsInsideCode();
// Save first so the on-disk .sui matches what the generator sees.
Save();
var outputFolderAbs = ResolveOutputFolderAbsolute( Document.Output.RootFolder );
var ctx = new SuiGenerationContext
{
Document = Document,
Mode = SuiGenerationMode.Final,
// Don't prefix file.Path with the output folder — the writer
// joins paths against outputFolderAbs itself, so doubling
// the prefix here yields `<output>/<output>/file.razor` on
// disk (CS0111 nightmare).
OutputFolder = "",
ClassName = Document.Output.ClassName,
Namespace = Document.Output.Namespace,
};
var generation = SuiGenerationPipeline.Run( ctx );
var compile = SuiCompileWriter.Run( generation, Document, outputFolderAbs );
_bottomTabs?.DisplayCompileResult( generation, compile );
SetCompileBanner( compile );
if ( compile.Ok )
{
Log.Info( $"[Sui] Compile OK — Generated {compile.Generated.Count}, Skipped {compile.Skipped.Count}, Preserved {compile.Preserved.Count}, Obsolete {compile.Obsolete.Count}. Folder: {compile.OutputFolder}" );
if ( compile.BackupFolder != null )
Log.Info( $"[Sui] backup of overwritten files: {compile.BackupFolder}" );
}
else
{
foreach ( var e in compile.Errors ) Log.Error( $"[Sui] {e}" );
foreach ( var c in compile.Conflicts )
Log.Warning( $"[Sui] conflict: {c.RelativePath} — {c.ConflictReason}" );
}
}
finally
{
_compileRunning = false;
}
}
/// <summary>
/// Resolve a stored project-relative folder to an absolute on-disk path.
/// If the stored value already looks absolute, use it as-is.
/// </summary>
private static string ResolveOutputFolderAbsolute( string storedFolder )
{
if ( string.IsNullOrEmpty( storedFolder ) ) return null;
if ( System.IO.Path.IsPathRooted( storedFolder ) ) return storedFolder;
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) ) return storedFolder;
return System.IO.Path.GetFullPath( System.IO.Path.Combine( projectRoot, storedFolder ) );
}
/// <summary>
/// Open a folder browser for the user to pick the output folder. Stores
/// the result on the document (project-relative if inside the project root,
/// absolute otherwise). Returns true on selection, false on cancel.
/// </summary>
private bool PromptOutputFolder()
{
if ( Document == null ) return false;
var fd = new FileDialog( null )
{
Title = "Choose output folder for generated UI files",
};
fd.SetFindDirectory();
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
var existingAbs = ResolveOutputFolderAbsolute( Document.Output.RootFolder );
if ( !string.IsNullOrEmpty( existingAbs ) && System.IO.Directory.Exists( existingAbs ) )
fd.Directory = existingAbs;
else if ( !string.IsNullOrEmpty( projectRoot ) )
fd.Directory = System.IO.Path.Combine( projectRoot, "Code" );
if ( !fd.Execute() ) return false;
var picked = fd.SelectedFile;
if ( string.IsNullOrEmpty( picked ) ) return false;
// Store project-relative when possible; falls back to absolute.
string stored = picked;
if ( !string.IsNullOrEmpty( projectRoot ) )
{
var rootFull = System.IO.Path.GetFullPath( projectRoot );
var pickedFull = System.IO.Path.GetFullPath( picked );
if ( pickedFull.StartsWith( rootFull, System.StringComparison.OrdinalIgnoreCase ) )
{
stored = pickedFull.Substring( rootFull.Length ).TrimStart( '/', '\\' ).Replace( '\\', '/' );
}
}
Document.Output.RootFolder = stored;
Document.Output.Configured = true;
// Default ClassName/Namespace if user hasn't set them yet.
if ( string.IsNullOrEmpty( Document.Output.ClassName ) )
Document.Output.ClassName = SuiDocumentValidator.SanitizeClassName( Document.Name ?? "GeneratedUi" );
if ( string.IsNullOrEmpty( Document.Output.Namespace ) )
Document.Output.Namespace = "Game.UI";
_controller.MarkDirtyExternally();
Log.Info( $"[Sui] output folder set to '{stored}' (abs: {picked})" );
return true;
}
private void ChangeOutputFolder()
{
if ( Document == null )
{
Log.Warning( "[Sui] cannot change output — no document loaded" );
return;
}
PromptOutputFolder();
}
/// <summary>
/// Push a banner over the canvas summarising the compile state. Cleared
/// (banner = null) when compile is fully OK; populated with the most
/// urgent issue otherwise.
/// </summary>
private void SetCompileBanner( SuiCompileResult compile )
{
var vp = _canvas?.GetViewport();
if ( vp == null ) return;
if ( compile == null || compile.Ok )
{
vp.ErrorBanner = null;
vp.Update();
return;
}
string title;
string detail;
if ( compile.Errors.Count > 0 )
{
title = $"Compile failed — {compile.Errors.Count} error(s)";
detail = compile.Errors[0];
}
else if ( compile.Conflicts.Count > 0 )
{
title = $"Compile blocked — {compile.Conflicts.Count} conflict(s)";
detail = $"{compile.Conflicts[0].RelativePath}: {compile.Conflicts[0].ConflictReason}";
}
else
{
title = "Compile produced warnings";
detail = compile.Warnings.Count > 0 ? compile.Warnings[0] : "(no detail)";
}
vp.ErrorBanner = new SuiCanvasErrorBanner
{
Title = title,
Detail = detail,
OnClick = () => Log.Info( "[Sui] click on banner — see Compile Results dock for full report" ),
OnDismiss = () => Log.Info( "[Sui] banner dismissed (errors still in Compile Results)" ),
};
vp.Update();
}
private void OpenGeneratedFolder()
{
if ( Document == null )
{
Log.Warning( "[Sui] no document loaded" );
return;
}
var abs = ResolveOutputFolderAbsolute( Document.Output?.RootFolder );
if ( string.IsNullOrEmpty( abs ) || !System.IO.Directory.Exists( abs ) )
{
Log.Warning( $"[Sui] output folder not set or doesn't exist: {abs}" );
return;
}
try
{
System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo
{
FileName = abs,
UseShellExecute = true,
Verb = "open",
} );
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to open folder: {ex.Message}" );
}
}
private void Undo() => _controller.Undo();
private void Redo() => _controller.Redo();
[Shortcut( "editor.undo", "Ctrl+Z", ShortcutType.Window )]
private void OnShortcutUndo() => Undo();
[Shortcut( "editor.redo", "Ctrl+Y", ShortcutType.Window )]
private void OnShortcutRedo() => Redo();
[Shortcut( "editor.delete", "DEL", ShortcutType.Window )]
private void OnShortcutDelete() => _controller.DeleteElement();
[Shortcut( "editor.rename", "F2", ShortcutType.Window )]
private void OnShortcutRename() => _hierarchy?.BeginRenameSelected();
[Shortcut( "editor.duplicate", "Ctrl+D", ShortcutType.Window )]
private void OnShortcutDuplicate() => _controller.DuplicateElement();
[Shortcut( "editor.cut", "Ctrl+X", ShortcutType.Window )]
private void OnShortcutCut() => _controller.CutElement();
[Shortcut( "editor.copy", "Ctrl+C", ShortcutType.Window )]
private void OnShortcutCopy() => _controller.CopyElement();
[Shortcut( "editor.paste", "Ctrl+V", ShortcutType.Window )]
private void OnShortcutPaste() => _controller.PasteElement();
[Shortcut( "editor.compile", "Ctrl+B", ShortcutType.Window )]
private void OnShortcutCompile() => Compile();
[Shortcut( "editor.zoomin", "Ctrl++", ShortcutType.Window )]
private void OnShortcutZoomIn() => ZoomIn();
[Shortcut( "editor.zoomout", "Ctrl+-", ShortcutType.Window )]
private void OnShortcutZoomOut() => ZoomOut();
[Shortcut( "editor.fittoscreen", "Ctrl+0", ShortcutType.Window )]
private void OnShortcutFitToScreen() => FitToScreen();
// ─────────────────────────────────────────────────────────────────────
// View / Tools handlers
// ─────────────────────────────────────────────────────────────────────
private void ZoomIn()
{
var vp = _canvas?.GetViewport();
if ( vp == null ) return;
vp.SetZoom( vp.Zoom * 1.25f );
}
private void ZoomOut()
{
var vp = _canvas?.GetViewport();
if ( vp == null ) return;
vp.SetZoom( vp.Zoom / 1.25f );
}
private void FitToScreen()
{
_canvas?.GetViewport()?.FitCanvas();
}
private void RegeneratePreview()
{
if ( Document == null )
{
Log.Warning( "[Sui] no document loaded" );
return;
}
// Force a fresh write to the preview cache; modal preview re-opens load it.
var className = Document.Output?.ClassName ?? Document.Name ?? "Generated";
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( !string.IsNullOrEmpty( projectRoot ) )
{
var cacheFolder = System.IO.Path.Combine( projectRoot, "Code", "_sui_preview", className );
try
{
if ( System.IO.Directory.Exists( cacheFolder ) )
{
System.IO.Directory.Delete( cacheFolder, recursive: true );
Log.Info( $"[Sui] cleared preview cache for '{className}'" );
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to clear preview cache: {ex.Message}" );
}
}
// Re-write via SuiPreviewCacheWriter so the engine hot-loads it.
var writeResult = SuiPreviewCacheWriter.Write( Document );
if ( writeResult.Ok )
Log.Info( $"[Sui] preview regenerated → {writeResult.TypeFullName}" );
else
foreach ( var e in writeResult.Errors ) Log.Error( $"[Sui] {e}" );
}
/// <summary>
/// Install the canonical sample .sui documents into the project's
/// Assets/SuiSamples/ folder (PRD doc 14 § Sample projects). Skips files
/// that already exist so the user can re-run safely without losing edits.
/// </summary>
private void InstallSamples()
{
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) )
{
Log.Warning( "[Sui] cannot install samples — no project loaded" );
return;
}
var dest = System.IO.Path.Combine( projectRoot, "Assets", "SuiSamples" );
try { System.IO.Directory.CreateDirectory( dest ); }
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to create samples folder: {ex.Message}" );
return;
}
int installed = 0, skipped = 0;
foreach ( var (name, doc) in SuiSampleGenerator.All() )
{
var path = System.IO.Path.Combine( dest, $"{name}.sui" );
if ( System.IO.File.Exists( path ) )
{
skipped++;
continue;
}
var asset = AssetSystem.CreateResource( "sui", path );
if ( asset == null )
{
Log.Warning( $"[Sui] failed to create sample asset: {path}" );
continue;
}
// Hydrate the resource with the generated document.
var resource = asset.LoadResource<SuiAsset>();
if ( resource == null ) resource = new SuiAsset();
resource.Document = doc;
asset.SaveToDisk( resource );
installed++;
}
Log.Info( $"[Sui] samples installed: {installed} new, {skipped} skipped (already existed). Folder: {dest}" );
}
/// <summary>
/// Aggressive cleanup — wipes both the preview cache and all legacy
/// `sui-generated-backups/` folders inside Code/. The engine stops
/// seeing duplicate `partial class` declarations on next compile.
///
/// New backups created by SuiCompileWriter live OUTSIDE Code/ (in
/// projectRoot/.sui-backups/) so they never reach the compiler;
/// existing legacy ones are deleted by this tool.
/// </summary>
private void CleanLegacyBackups()
{
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) ) return;
var codeRoot = System.IO.Path.Combine( projectRoot, "Code" );
if ( !System.IO.Directory.Exists( codeRoot ) )
{
Log.Info( "[Sui] no Code/ folder, nothing to clean" );
return;
}
int previewCacheDeleted = 0;
int backupFoldersDeleted = 0;
// 1) Wipe the entire preview cache root.
var cacheRoot = System.IO.Path.Combine( codeRoot, "_sui_preview" );
if ( System.IO.Directory.Exists( cacheRoot ) )
{
try
{
System.IO.Directory.Delete( cacheRoot, recursive: true );
previewCacheDeleted = 1;
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to delete preview cache '{cacheRoot}': {ex.Message}" );
}
}
// 2) Delete every `sui-generated-backups/` folder anywhere under Code/.
try
{
foreach ( var dir in System.IO.Directory.EnumerateDirectories(
codeRoot, "sui-generated-backups", System.IO.SearchOption.AllDirectories ) )
{
try
{
System.IO.Directory.Delete( dir, recursive: true );
backupFoldersDeleted++;
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to delete '{dir}': {ex.Message}" );
}
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] cleanup walk failed: {ex.Message}" );
}
Log.Info( $"[Sui] cleanup done: preview cache wiped ({previewCacheDeleted}), legacy backup folders deleted ({backupFoldersDeleted}). Compile again — preview cache rebuilds on next Preview click." );
}
private void CleanPreviewCache()
{
var projectRoot = Sandbox.Project.Current?.RootDirectory?.FullName;
if ( string.IsNullOrEmpty( projectRoot ) ) return;
var cacheRoot = System.IO.Path.Combine( projectRoot, "Code", "_sui_preview" );
try
{
if ( System.IO.Directory.Exists( cacheRoot ) )
{
System.IO.Directory.Delete( cacheRoot, recursive: true );
Log.Info( $"[Sui] cleaned all preview cache: {cacheRoot}" );
}
else
{
Log.Info( "[Sui] preview cache already empty" );
}
}
catch ( System.Exception ex )
{
Log.Warning( $"[Sui] failed to clean preview cache: {ex.Message}" );
}
}
private void ValidateDocument()
{
if ( Document == null )
{
Log.Warning( "[Sui] no document loaded" );
return;
}
var report = SuiDocumentValidator.Validate( Document );
if ( report.IsValid )
{
Log.Info( $"[Sui] document is valid ({Document.Elements.Count} elements)" );
}
else
{
foreach ( var e in report.Errors ) Log.Error( $"[Sui] {e}" );
}
}
}