Editor/Inspector/AppearanceGroupBuilder.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Sandbox;
using Margin = Sandbox.UI.Margin;
namespace Grains.RazorDesigner.Inspector;
public sealed class AppearanceGroupBuilder
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public readonly record struct GroupSpec( string Group, bool DefaultExpanded, string Icon );
private readonly IReadOnlyList<GroupSpec> _spec;
private readonly IExpandStateStore _store; // null = in-memory only.
private readonly Dictionary<string, bool> _memoryState = new();
private readonly Dictionary<string, CollapsibleSection> _sections = new();
private readonly Dictionary<string, ControlWidget> _propertyWidgets = new();
public IReadOnlyDictionary<string, CollapsibleSection> Sections => _sections;
public IReadOnlyDictionary<string, ControlWidget> PropertyWidgets => _propertyWidgets;
public AppearanceGroupBuilder( IReadOnlyList<GroupSpec> spec, IExpandStateStore store )
{
_spec = spec;
_store = store;
}
public IReadOnlySet<string> Build(
Layout host,
SerializedObject obj,
string filterText,
Func<SerializedProperty, bool> predicate = null )
{
_sections.Clear();
_propertyWidgets.Clear();
var rendered = new HashSet<string>();
var filterActive = !string.IsNullOrEmpty( filterText );
foreach ( var (groupName, defaultExpanded, icon) in _spec )
{
var sheet = new ControlSheet();
sheet.IncludePropertyNames = true;
var addedAny = false;
foreach ( var prop in obj )
{
if ( !string.Equals( prop.GroupName, groupName, StringComparison.Ordinal ) ) continue;
if ( predicate != null && !predicate( prop ) ) continue;
if ( filterActive )
{
var nameHit = prop.Name?.Contains( filterText, StringComparison.OrdinalIgnoreCase ) == true;
var displayHit = prop.DisplayName?.Contains( filterText, StringComparison.OrdinalIgnoreCase ) == true;
var groupHit = prop.GroupName?.Contains( filterText, StringComparison.OrdinalIgnoreCase ) == true;
if ( !nameHit && !displayHit && !groupHit ) continue;
}
var widget = sheet.AddRow( prop );
if ( widget is null ) continue;
_propertyWidgets[prop.Name] = widget;
rendered.Add( prop.Name );
addedAny = true;
}
if ( !addedAny ) continue;
var preferred = GetExpand( groupName, defaultExpanded );
var section = new CollapsibleSection( null, groupName, icon );
section.Expanded = filterActive || preferred;
section.BodyLayout.Add( sheet );
var capturedFilterActive = filterActive;
var capturedGroupName = groupName;
section.ExpandedChanged = expanded =>
{
if ( capturedFilterActive ) return;
SetExpand( capturedGroupName, expanded );
};
host.Add( section );
_sections[groupName] = section;
}
return rendered;
}
public void ApplyReadOnlyStates( object gateSource, IContractTable contracts, ControlType targetType )
{
if ( gateSource is null ) return;
var contract = contracts.Get( targetType );
var gateSourceType = gateSource.GetType();
foreach ( var group in contract.Groups() )
{
var gateField = contract.PayloadFields.FirstOrDefault( f => f.IsOverrideGate && f.Group == group );
if ( gateField.Name is null or "" ) continue;
var gateProp = gateSourceType.GetProperty( gateField.Name, BindingFlags.Public | BindingFlags.Instance );
if ( gateProp is null )
{
Log.Warning( $"{LogPrefix} AppearanceGroupBuilder.ApplyReadOnlyStates: Override gate '{gateField.Name}' " +
$"not found on {gateSourceType.Name} for group '{group}'. Skipping gray-out." );
continue;
}
var gateOn = gateProp.GetValue( gateSource ) is true;
var gateOff = !gateOn;
foreach ( var name in contract.PropertyNamesIn( group ) )
{
if ( _propertyWidgets.TryGetValue( name, out var w ) ) w.ReadOnly = gateOff;
}
}
}
private bool GetExpand( string key, bool fallback )
{
if ( _store != null ) return _store.Get( key, fallback );
return _memoryState.TryGetValue( key, out var v ) ? v : fallback;
}
private void SetExpand( string key, bool expanded )
{
if ( _store != null ) { _store.Set( key, expanded ); return; }
_memoryState[key] = expanded;
}
}