Editor/Inspector/InspectorPanel.cs
using System.Collections.Generic;
using System.Linq;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Diagnostics;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Validation;
using Sandbox;
using Margin = Sandbox.UI.Margin;
namespace Grains.RazorDesigner.Inspector;
public sealed class InspectorPanel : Widget
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string ExpandStateCookie = "razordesigner.inspector.expanded";
public System.Action ValueChanged;
// (record, oldClassName). Surgical: caller swaps LivePanel class + repaints tree.
public System.Action<ControlRecord, string> ClassNameChanged;
private readonly DesignerDocument _document;
private ControlRecord _target;
private SerializedObject _serialized;
private string _priorClassName;
// Re-entry guard: SetValue on rejected edit fires OnFinishEdit again.
private bool _isRevertingClassName;
private LineEdit _filterEdit;
private string _filterText = "";
private CollapsibleSection _identityBanner;
private ScrollArea _scroll;
private Widget _canvas;
private AppearanceGroupBuilder _builder;
private readonly InspectorExpandStateStore _expandStore = new( ExpandStateCookie );
// Diagnostics from the last Validator pass. Empty = no issues.
private IReadOnlyList<ValidationDiagnostic> _diagnostics = System.Array.Empty<ValidationDiagnostic>();
// Status row shown at the bottom of the inspector when diagnostics are present.
private Widget _diagnosticsRow;
private Label _diagnosticsLabel;
public System.Action<ControlRecord> OpenStateStylingRequested;
private Button _stateStylingButton;
private Label _stateStylingCount;
private static readonly (string Group, bool Expanded, string Icon)[] GroupOrder =
{
( "Identity", true, "label" ),
( "Layout", true, "dashboard" ),
( "Flex", true, "view_column" ),
( "Content", true, "text_fields" ),
( "Image", true, "image" ),
( "Icon", true, "star" ),
( "Typography", false, "format_size" ),
( "Constraints", false, "fit_screen" ),
( "Background", false, "palette" ),
( "Border", false, "border_style" ),
( "Effects", false, "auto_awesome" ),
( "Interaction", false, "touch_app" ),
};
public InspectorPanel( Widget parent, DesignerDocument document ) : base( parent )
{
_document = document;
Log.Info( $"{LogPrefix} InspectorPanel ctor" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
MinimumWidth = 280;
var toolbar = new Widget( this );
toolbar.Layout = Layout.Row();
toolbar.Layout.Margin = 4;
toolbar.Layout.Spacing = 4;
_filterEdit = new LineEdit( toolbar ) { PlaceholderText = "Filter properties..." };
_filterEdit.TextEdited += OnFilterEdited;
toolbar.Layout.Add( _filterEdit, 1 );
Layout.Add( toolbar );
Layout.AddSeparator();
_identityBanner = new CollapsibleSection( this, "", "category" );
_identityBanner.IsCollapsible = false;
Layout.Add( _identityBanner );
_scroll = new ScrollArea( this );
_scroll.Canvas = new Widget();
_scroll.Canvas.Layout = Layout.Column();
_scroll.Canvas.Layout.Margin = new Margin( 0, 4 );
_scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
_scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
_canvas = _scroll.Canvas;
Layout.Add( _scroll, 1 );
Layout.AddStretchCell();
// State styling footer — sits above diagnostics. (grd-ge50 D3.)
var stateRow = new Widget( this );
stateRow.Layout = Layout.Row();
stateRow.Layout.Margin = new Margin( 8, 4 );
stateRow.Layout.Spacing = 6;
_stateStylingButton = new Button( "State styling…", stateRow );
_stateStylingButton.Clicked = () =>
{
if ( _target is null ) return;
OpenStateStylingRequested?.Invoke( _target );
};
stateRow.Layout.Add( _stateStylingButton );
_stateStylingCount = new Label( "", stateRow );
_stateStylingCount.SetStyles( "color: " + Theme.TextLight.Hex + ";" );
stateRow.Layout.Add( _stateStylingCount, 1 );
Layout.Add( stateRow );
_diagnosticsRow = new Widget( this );
_diagnosticsRow.Layout = Layout.Row();
_diagnosticsRow.Layout.Margin = new Margin( 6, 4 );
_diagnosticsRow.Layout.Spacing = 4;
_diagnosticsLabel = new Label( _diagnosticsRow );
_diagnosticsLabel.WordWrap = true;
_diagnosticsRow.Layout.Add( _diagnosticsLabel, 1 );
_diagnosticsRow.Visible = false;
Layout.Add( _diagnosticsRow );
Log.Info( $"{LogPrefix} Inspector diagnostics status row created" );
Rebuild();
}
public void SetTarget( ControlRecord record )
{
_target = record;
_priorClassName = _target?.ClassName;
Log.Info( $"{LogPrefix} Inspector.SetTarget({_target?.ClassName ?? "<none>"})" );
Rebuild();
}
public void SetDiagnostics( IReadOnlyList<ValidationDiagnostic> diagnostics )
{
_diagnostics = diagnostics ?? System.Array.Empty<ValidationDiagnostic>();
RebuildDiagnosticsRow();
Log.Info( $"{LogPrefix} Inspector.SetDiagnostics: {_diagnostics.Count} diagnostic(s)" );
}
public void NotifyValueChanged() => ValueChanged?.Invoke();
private void RebuildDiagnosticsRow()
{
if ( _diagnosticsRow is null || _diagnosticsLabel is null ) return;
if ( _diagnostics.Count == 0 )
{
_diagnosticsRow.Visible = false;
return;
}
var errorCount = 0;
var warnCount = 0;
foreach ( var d in _diagnostics )
{
if ( d.Severity == DiagnosticSeverity.Error ) errorCount++;
else warnCount++;
}
string text;
if ( errorCount > 0 )
text = $"⚠ {errorCount} error(s){(warnCount > 0 ? $", {warnCount} warning(s)" : "")} — Razor regeneration blocked";
else
text = $"{warnCount} warning(s)";
_diagnosticsLabel.Text = text;
_diagnosticsRow.Visible = true;
}
private void Rebuild()
{
// Unwire prior _serialized: orphaned instance still holds delegate refs.
if ( _serialized is not null )
{
_serialized.OnPropertyChanged -= OnPropertyChanged;
var oldClassName = _serialized.GetProperty( "ClassName" );
if ( oldClassName is not null )
oldClassName.OnFinishEdit -= OnClassNameFinishEdit;
_serialized = null;
}
if ( _target is null )
{
_identityBanner.Visible = false;
_scroll.Visible = false;
_canvas.Layout.Clear( true );
_builder = null;
Update();
RefreshStateStylingFooter();
return;
}
_identityBanner.Visible = true;
_identityBanner.Title = BuildHeaderText();
_identityBanner.Icon = ContractScanner.Table.Get( _target.Type ).InspectorIcon;
_scroll.Visible = true;
_serialized = EditorTypeLibrary.GetSerializedObject( _target );
_serialized.OnPropertyChanged += OnPropertyChanged;
// ClassName: commit-time only. OnFinishEdit fires on focus loss/Enter, not per keystroke.
var classNameProp = _serialized.GetProperty( "ClassName" );
if ( classNameProp is not null )
classNameProp.OnFinishEdit += OnClassNameFinishEdit;
BuildSections();
RefreshStateStylingFooter();
}
private void BuildSections()
{
var probeSw = PerfProbes.Enabled ? System.Diagnostics.Stopwatch.StartNew() : null;
_canvas.Layout.Clear( true );
if ( _serialized is null || _target is null )
{
if ( probeSw != null )
Log.Info( $"{LogPrefix} InspectorPanel.BuildSections: empty ({probeSw.Elapsed.TotalMilliseconds:F2}ms)" );
return;
}
var spec = GroupOrder.Select( g => new AppearanceGroupBuilder.GroupSpec( g.Group, g.Expanded, g.Icon ) ).ToList();
_builder = new AppearanceGroupBuilder( spec, _expandStore );
_builder.Build( _canvas.Layout, _serialized, _filterText, ShouldShow );
_canvas.Layout.AddStretchCell();
_builder.ApplyReadOnlyStates( _target, ContractScanner.Table, _target.Type );
_scroll.Update();
if ( probeSw != null )
Log.Info( $"{LogPrefix} InspectorPanel.BuildSections[{_target.Type}]: {probeSw.Elapsed.TotalMilliseconds:F2}ms" );
}
// Painted when no target is bound. Mirrors canonical Editor Inspector.cs OnPaint behaviour.
protected override void OnPaint()
{
if ( _target is not null ) return;
Paint.ClearPen();
Paint.ClearBrush();
Paint.SetDefaultFont( italic: true );
Paint.SetPen( Theme.SurfaceLightBackground );
var r = LocalRect;
r.Top += 128;
Paint.DrawText( r, "No control selected.", TextFlag.CenterTop );
}
private bool ShouldShow( SerializedProperty p )
{
if ( p.HasAttribute<HideAttribute>() )
return false;
if ( _target.IsSlot )
return p.Name is "ClassName" or "Padding";
var contract = ContractScanner.Table.Get( _target.Type );
var isContainer = contract.IsContainer;
var isRoot = _target == _document.RootRecord;
if ( p.Name is "Direction" or "Justify" or "Align" or "Gap" or "Wrap" )
{
if ( !isContainer ) return false;
}
if ( isContainer && p.Name == "Content" )
return false;
var contractFieldNames = new HashSet<string>(
contract.PayloadFields.Select( f => f.Name ),
System.StringComparer.Ordinal );
if ( p.Name is "Source" or "IconName" or "Placeholder" or "CheckboxSize" or "Content" )
{
if ( !contractFieldNames.Contains( p.Name ) ) return false;
}
var isTextControl = _target.Type is ControlType.Label or ControlType.Button
or ControlType.TextEntry or ControlType.Checkbox;
var isIconControl = _target.Type is ControlType.IconPanel;
if ( p.Name is "OverrideTypography" or "FontSize" or "Color" )
{
if ( !isTextControl && !isIconControl ) return false;
}
if ( p.Name is "FontFamily" or "FontWeight" or "FontStyleItalic" or "TextTransform" or "LetterSpacing" or "LineHeight" )
{
if ( !isTextControl ) return false;
}
if ( p.Name == "TextAlign" && _target.Type != ControlType.Label )
return false;
if ( isRoot && p.Name == "Margin" ) return false;
if ( isRoot && p.Name is "FlexGrow" or "FlexShrink" or "FlexBasis" or "AlignSelf"
or "Position" or "Top" or "Left" or "Right" or "Bottom" )
return false;
// RootClassName is the isRoot sentinel; renaming would break serializer.
if ( isRoot && p.Name == "ClassName" )
return false;
if ( !string.IsNullOrEmpty( _filterText ) )
{
var ft = _filterText.ToLowerInvariant();
if ( (p.Name?.ToLowerInvariant().Contains( ft )) == true ) return true;
if ( (p.DisplayName?.ToLowerInvariant().Contains( ft )) == true ) return true;
if ( (p.GroupName?.ToLowerInvariant().Contains( ft )) == true ) return true;
return false;
}
return true;
}
private void OnFilterEdited( string text )
{
_filterText = text ?? "";
if ( _serialized is null ) return;
BuildSections();
}
private void OnPropertyChanged( SerializedProperty property )
{
// ClassName handled on commit via OnClassNameFinishEdit.
if ( property.Name == "ClassName" )
return;
Log.Info( $"{LogPrefix} Inspector.{property.Name} -> {property.GetValue<object>()}" );
ValueChanged?.Invoke();
RefreshStateStylingFooter();
if ( property.Name.StartsWith( "Override" ) )
_builder?.ApplyReadOnlyStates( _target, ContractScanner.Table, _target.Type );
if ( property.Name == "Position" )
Rebuild();
}
private void OnClassNameFinishEdit( SerializedProperty property )
{
HandleClassNameChange();
}
private void HandleClassNameChange()
{
if ( _target is null ) return;
if ( _isRevertingClassName ) return;
var newName = _target.ClassName;
if ( newName == _priorClassName )
return;
var error = _document.ValidateClassName( newName, _target );
if ( error is not null )
{
Log.Warning( $"{LogPrefix} Inspector.ClassName '{newName}' rejected: {error} -> reverting to '{_priorClassName}'" );
// bd memory: engine-stringcontrolwidget-onvalue. SetValue re-syncs LineEdit on focus loss.
_isRevertingClassName = true;
try
{
var prop = _serialized?.GetProperty( "ClassName" );
if ( prop is not null )
prop.SetValue<string>( _priorClassName );
else
_target.ClassName = _priorClassName;
}
finally
{
_isRevertingClassName = false;
}
return;
}
Log.Info( $"{LogPrefix} Inspector.ClassName '{_priorClassName}' -> '{newName}'" );
var oldName = _priorClassName;
_priorClassName = newName;
_identityBanner.Title = BuildHeaderText();
_identityBanner.Update();
ClassNameChanged?.Invoke( _target, oldName );
}
private string BuildHeaderText()
{
if ( _target is null ) return "";
if ( _target == _document.RootRecord ) return "Canvas";
return $"{_target.Type}: {_target.ClassName}";
}
private void RefreshStateStylingFooter()
{
if ( _stateStylingButton is null ) return;
_stateStylingButton.Enabled = _target is not null;
var count = _target?.StateRules?.Count ?? 0;
_stateStylingCount.Text = count switch
{
0 => "",
1 => "1 rule",
_ => $"{count} rules"
};
}
}