Editor/Templates/PaletteTemplateSerializer.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Grains.RazorDesigner.Document;
using Sandbox;
using Sandbox.UI;
using Length = Grains.RazorDesigner.Document.Length;
namespace Grains.RazorDesigner.Templates;
public sealed class PaletteTemplateException : Exception
{
public PaletteTemplateException( string message ) : base( message ) { }
public PaletteTemplateException( string message, Exception inner ) : base( message, inner ) { }
}
public static class PaletteTemplateSerializer
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
private static FieldDescriptor[] _fields;
private static FieldDescriptor[] Fields => _fields ??= BuildFieldDescriptors();
public static string Serialize( PaletteTemplate t )
{
if ( t is null ) throw new ArgumentNullException( nameof( t ) );
var doc = new JsonObject
{
["version"] = CurrentVersion,
["name"] = t.Name,
["icon"] = t.IconName,
["wrappedInContainer"] = t.WrappedInContainer,
["roots"] = WriteRecordList( t.Roots ),
};
return doc.ToJsonString( JsonOpts );
}
public static PaletteTemplate Deserialize( string json, string filePath )
{
if ( string.IsNullOrEmpty( json ) )
throw new PaletteTemplateException( "empty JSON" );
JsonNode parsed;
try
{
parsed = JsonNode.Parse( json );
}
catch ( JsonException ex )
{
throw new PaletteTemplateException( $"malformed JSON: {ex.Message}", ex );
}
if ( parsed is not JsonObject root )
throw new PaletteTemplateException( "deserialised to null" );
int version = TryReadInt( root["version"] );
if ( version != CurrentVersion )
throw new PaletteTemplateException( $"unsupported schema version {version} (expected {CurrentVersion})" );
var name = TryReadString( root["name"] );
if ( string.IsNullOrWhiteSpace( name ) )
throw new PaletteTemplateException( "name field missing or empty" );
return new PaletteTemplate(
Name: name,
IconName: TryReadString( root["icon"] ) ?? "",
WrappedInContainer: TryReadBool( root["wrappedInContainer"] ),
Roots: ReadRecordList( root["roots"] as JsonArray ),
FilePath: filePath );
}
// ---------- Walker (records) ----------
private static JsonArray WriteRecordList( IReadOnlyList<ControlRecord> records )
{
var arr = new JsonArray();
if ( records is null ) return arr;
foreach ( var r in records )
arr.Add( WriteRecord( r ) );
return arr;
}
private static JsonObject WriteRecord( ControlRecord r )
{
var o = new JsonObject();
o["type"] = r.Type.ToString();
foreach ( var f in Fields )
o[f.JsonName] = f.Write( f.Get( r ) );
if ( r.IsSlot )
{
o["isSlot"] = true;
o["slotName"] = r.SlotName ?? "";
}
if ( r.StateRules is { Count: > 0 } )
o["stateRules"] = WriteStateRuleList( r.StateRules );
o["children"] = WriteRecordList( r.Children );
return o;
}
private static List<ControlRecord> ReadRecordList( JsonArray arr )
{
var list = new List<ControlRecord>();
if ( arr is null ) return list;
foreach ( var node in arr )
{
var rec = ReadRecord( node as JsonObject );
if ( rec is null ) continue; // unknown type — already warned
list.Add( rec );
}
return list;
}
private static ControlRecord ReadRecord( JsonObject o )
{
if ( o is null ) return null;
var typeStr = TryReadString( o["type"] ) ?? "";
if ( !Enum.TryParse<ControlType>( typeStr, ignoreCase: false, out var type ) )
{
Log.Warning( $"{LogPrefix} PaletteTemplateSerializer: unknown ControlType \"{typeStr}\", skipping node" );
return null;
}
var rec = new ControlRecord { Type = type };
foreach ( var f in Fields )
{
var node = o[f.JsonName];
if ( node is null ) continue; // missing → keep ControlRecord property default
var value = f.Read( node );
if ( value is not null ) f.Set( rec, value );
}
if ( o["isSlot"] is JsonNode isSlotNode && TryReadBool( isSlotNode ) )
{
rec.IsSlot = true;
rec.SlotName = TryReadString( o["slotName"] ) ?? "";
}
RunMigrations( rec );
if ( o["stateRules"] is JsonArray stateRulesArr )
{
foreach ( var rule in ReadStateRuleList( stateRulesArr ) )
rec.StateRules.Add( rule );
}
if ( o["children"] is JsonArray children )
{
foreach ( var child in ReadRecordList( children ) )
rec.Children.Add( child );
}
return rec;
}
private static JsonArray WriteStateRuleList( IReadOnlyList<StateRule> rules )
{
var arr = new JsonArray();
if ( rules is null ) return arr;
foreach ( var rule in rules )
arr.Add( WriteStateRule( rule ) );
return arr;
}
private static JsonObject WriteStateRule( StateRule rule )
{
var o = new JsonObject();
o["state"] = rule.State.ToString();
// NthChild extras — omit when at default (Literal / 1) to match IR wire format economy.
if ( rule.State == PseudoKind.NthChild )
{
o["nthChildMode"] = rule.NthChildMode.ToString();
o["nthChildArg"] = rule.NthChildArg;
}
o["delta"] = WriteAppearance( rule.Delta );
return o;
}
private static List<StateRule> ReadStateRuleList( JsonArray arr )
{
var list = new List<StateRule>();
if ( arr is null ) return list;
foreach ( var node in arr )
{
if ( node is not JsonObject o ) continue;
var stateStr = TryReadString( o["state"] ) ?? "";
if ( !Enum.TryParse<PseudoKind>( stateStr, ignoreCase: true, out var state ) )
{
Log.Warning( $"{LogPrefix} PaletteTemplateSerializer: unknown PseudoKind \"{stateStr}\", skipping state rule" );
continue;
}
var mode = NthChildMode.Literal;
var arg = 1;
if ( o["nthChildMode"] is JsonNode modeNode )
{
var modeStr = TryReadString( modeNode ) ?? "";
if ( Enum.TryParse<NthChildMode>( modeStr, ignoreCase: true, out var parsedMode ) )
mode = parsedMode;
}
if ( o["nthChildArg"] is JsonNode argNode )
arg = TryReadInt( argNode );
var delta = o["delta"] is JsonObject deltaObj ? ReadAppearance( deltaObj ) : Appearance.Default;
list.Add( new StateRule { State = state, NthChildMode = mode, NthChildArg = arg, Delta = delta } );
}
return list;
}
private static JsonNode WriteAppearance( Appearance a )
{
var json = JsonSerializer.Serialize( a, Grains.RazorDesigner.Serialization.IR.DesignerIRJson.Options );
return JsonNode.Parse( json );
}
private static Appearance ReadAppearance( JsonObject o )
{
if ( o is null ) return Appearance.Default;
return JsonSerializer.Deserialize<Appearance>(
o.ToJsonString(),
Grains.RazorDesigner.Serialization.IR.DesignerIRJson.Options );
}
// ---------- Migrations ----------
private static void RunMigrations( ControlRecord rec )
{
// IconPanel glyph used to live in Content (pre-grd-zcq); route into IconName when empty.
if ( rec.Type == ControlType.IconPanel && string.IsNullOrEmpty( rec.IconName ) )
{
rec.IconName = rec.Content ?? "";
}
if ( rec.Type == ControlType.IconPanel )
rec.Content = "";
// TextEntry placeholder used to live in Content (pre-grd-3oa); route into Placeholder.
if ( rec.Type == ControlType.TextEntry && string.IsNullOrEmpty( rec.Placeholder ) )
{
rec.Placeholder = rec.Content ?? "";
}
// TextEntry never carries Content in v3+. Same belt-and-braces shape as IconPanel.
if ( rec.Type == ControlType.TextEntry )
rec.Content = "";
// FontWeight missing in pre-typography templates → default 400.
if ( rec.FontWeight == 0 ) rec.FontWeight = 400;
// Opacity missing in pre-Tier-2 templates → default 1 (fully opaque).
if ( rec.Opacity == 0f ) rec.Opacity = 1f;
}
// ---------- Static schema build ----------
private static FieldDescriptor[] BuildFieldDescriptors()
{
var props = typeof( ControlRecord )
.GetProperties( BindingFlags.Public | BindingFlags.Instance )
.Where( p => p.CanRead && p.CanWrite )
.Where( p => p.GetCustomAttribute<HideAttribute>() == null )
.Where( p => !typeof( Panel ).IsAssignableFrom( p.PropertyType ) )
.OrderBy( p => p.MetadataToken )
.ToArray();
var list = new List<FieldDescriptor>( props.Length );
foreach ( var p in props )
{
if ( !TryResolveConverter( p.PropertyType, out var conv ) )
{
throw new InvalidOperationException(
$"PaletteTemplateSerializer: no converter for ControlRecord.{p.Name} (type {p.PropertyType.FullName}). " +
$"Register one in TryResolveConverter." );
}
var prop = p; // closure capture
var jsonName = p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? JsonNamingPolicy.CamelCase.ConvertName( p.Name );
list.Add( new FieldDescriptor
{
JsonName = jsonName,
Get = rec => prop.GetValue( rec ),
Set = ( rec, val ) => prop.SetValue( rec, val ),
Write = conv.Write,
Read = conv.Read,
} );
}
Log.Info( $"{LogPrefix} PaletteTemplateSerializer: {list.Count} ControlRecord fields mirrored." );
return list.ToArray();
}
// ---------- Converters ----------
private readonly struct Converter
{
public Converter( Func<object, JsonNode> write, Func<JsonNode, object> read )
{
Write = write;
Read = read;
}
public Func<object, JsonNode> Write { get; }
public Func<JsonNode, object> Read { get; }
}
private static bool TryResolveConverter( Type t, out Converter conv )
{
if ( t == typeof( string ) ) { conv = ConvString; return true; }
if ( t == typeof( int ) ) { conv = ConvInt; return true; }
if ( t == typeof( float ) ) { conv = ConvFloat; return true; }
if ( t == typeof( bool ) ) { conv = ConvBool; return true; }
if ( t == typeof( Length ) ) { conv = ConvLength; return true; }
if ( t == typeof( Edges ) ) { conv = ConvEdges; return true; }
if ( t == typeof( Color ) ) { conv = ConvColor; return true; }
if ( t.IsEnum ) { conv = MakeEnumConverter( t ); return true; }
conv = default;
return false;
}
private static readonly Converter ConvString = new(
write: v => JsonValue.Create( (string)v ?? "" ),
read: n => TryReadString( n ) );
private static readonly Converter ConvInt = new(
write: v => JsonValue.Create( (int)v ),
read: n => (object)TryReadInt( n ) );
private static readonly Converter ConvFloat = new(
write: v => JsonValue.Create( (float)v ),
read: n => (object)TryReadFloat( n ) );
private static readonly Converter ConvBool = new(
write: v => JsonValue.Create( (bool)v ),
read: n => (object)TryReadBool( n ) );
private static readonly Converter ConvLength = new(
write: v => JsonValue.Create( ((Length)v).ToCss() ),
read: n => Length.TryParse( TryReadString( n ) ?? "", out var l ) ? (object)l : null );
private static readonly Converter ConvEdges = new(
write: v =>
{
var e = (Edges)v;
if ( e.IsUniform ) return JsonValue.Create( e.Top.ToCss() );
return new JsonObject
{
["top"] = e.Top.ToCss(),
["right"] = e.Right.ToCss(),
["bottom"] = e.Bottom.ToCss(),
["left"] = e.Left.ToCss(),
};
},
read: n =>
{
if ( n is null ) return null;
if ( n is JsonObject o )
{
var top = ParseEdgeSide( o["top"] );
var right = ParseEdgeSide( o["right"] );
var bottom = ParseEdgeSide( o["bottom"] );
var left = ParseEdgeSide( o["left"] );
return (object)new Edges( top, right, bottom, left );
}
var s = TryReadString( n ) ?? "";
return Length.TryParse( s, out var len ) ? (object)Edges.Uniform( len ) : null;
} );
private static Length ParseEdgeSide( JsonNode n )
{
if ( n is null ) return Length.Px( 0 );
var s = TryReadString( n ) ?? "";
return Length.TryParse( s, out var len ) ? len : Length.Px( 0 );
}
private static readonly Converter ConvColor = new(
write: v => JsonValue.Create( ((Color)v).Hex ),
read: n =>
{
var s = TryReadString( n );
if ( string.IsNullOrEmpty( s ) ) return null;
var parsed = Color.Parse( s );
return parsed.HasValue ? (object)parsed.Value : null;
} );
private static Converter MakeEnumConverter( Type enumType )
{
return new Converter(
write: v => JsonValue.Create( v?.ToString() ?? "" ),
read: n =>
{
var s = TryReadString( n );
if ( string.IsNullOrEmpty( s ) ) return null;
return Enum.TryParse( enumType, s, ignoreCase: true, out var v ) ? v : null;
} );
}
private static string TryReadString( JsonNode n )
{
if ( n is null ) return null;
try { return n.GetValue<string>(); } catch { return n.ToString(); }
}
private static int TryReadInt( JsonNode n )
{
if ( n is null ) return 0;
try { return n.GetValue<int>(); }
catch
{
try { return (int)n.GetValue<double>(); }
catch { return int.TryParse( n.ToString(), out var v ) ? v : 0; }
}
}
private static float TryReadFloat( JsonNode n )
{
if ( n is null ) return 0f;
try { return n.GetValue<float>(); }
catch
{
try { return (float)n.GetValue<double>(); }
catch { return float.TryParse( n.ToString(), out var v ) ? v : 0f; }
}
}
private static bool TryReadBool( JsonNode n )
{
if ( n is null ) return false;
try { return n.GetValue<bool>(); } catch { return false; }
}
private sealed class FieldDescriptor
{
public string JsonName;
public Func<ControlRecord, object> Get;
public Action<ControlRecord, object> Set;
public Func<object, JsonNode> Write;
public Func<JsonNode, object> Read;
}
}