Editor/Projection/Applier.cs
using System;
using System.Collections.Generic;
using System.Text;
using Sandbox.UI;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Diagnostics;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection.Appearance;
namespace Grains.RazorDesigner.Projection;
public static class Applier
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public static void ApplyOp( Panel panel, PanelOp op )
{
switch ( op )
{
case SetClass sc:
if ( !string.IsNullOrEmpty( sc.Class ) )
panel.AddClass( sc.Class );
break;
case SetStyle ss:
panel.Style.Set( ss.Property, ss.Value );
break;
case SetAttribute:
break;
case SetInnerText st:
if ( panel is Label lbl ) lbl.Text = st.Text;
else if ( panel is Button btn ) btn.Text = st.Text;
else if ( panel is IconPanel icon ) icon.Text = st.Text;
break;
default:
throw new System.Diagnostics.UnreachableException(
$"Applier.ApplyOp: unhandled PanelOp variant '{op?.GetType().Name}'. " +
"Add the arm here AND add the variant to PanelOpExhaustivenessTest's inline " +
"array in the same commit — that is the forcing function." );
}
}
public static void ApplyOpToScratch( PanelOp op )
{
// Construct a throwaway Panel so the switch can run without a live scene.
var scratch = new Panel();
ApplyOp( scratch, op );
}
public static Panel BuildPreview(
IReadOnlyNode node,
Panel liveParent,
ProjectionContext ctx,
Action<IReadOnlyNode> onNodeBuilt )
{
if ( liveParent is null || !liveParent.IsValid )
{
Log.Warning( $"{LogPrefix} MirrorRecord: liveParent not valid; record {node.ClassName} has no live mirror" );
return null;
}
Panel live;
switch ( node.Kind )
{
case "Label":
live = liveParent.AddChild<Label>( node.ClassName );
break;
case "Button":
live = liveParent.AddChild<Button>( node.ClassName );
break;
case "Image":
live = liveParent.AddChild<Image>( node.ClassName );
break;
case "TextEntry":
live = liveParent.AddChild<TextEntry>( node.ClassName );
break;
case "Checkbox":
live = liveParent.AddChild<Checkbox>( node.ClassName );
break;
case "IconPanel":
live = liveParent.AddChild<IconPanel>( node.ClassName );
break;
case "Panel":
live = liveParent.AddChild<Panel>( node.ClassName );
break;
case "SplitContainer":
return BuildPreviewSplitContainer( node, liveParent, ctx, onNodeBuilt );
case "Form":
live = liveParent.AddChild<Form>( node.ClassName );
break;
case "Field":
live = liveParent.AddChild<Field>( node.ClassName );
break;
case "FieldControl":
live = liveParent.AddChild<FieldControl>( node.ClassName );
break;
default:
if ( !ProjectorRegistry.Has( node.Kind ) )
Log.Warning( $"{LogPrefix} BuildPreview: no projector registered for kind '{node.Kind}'; treating as placeholder" );
var fallback = liveParent.AddChild<Panel>( node.ClassName );
live = fallback;
break;
}
// --- Step 2: Apply projector ops ---
if ( ProjectorRegistry.Has( node.Kind ) )
{
var result = ProjectorRegistry.For( node.Kind ).Project(
node, node.Appearance, node.Payload, ctx );
foreach ( var op in result.PanelOps )
ApplyOp( live, op );
}
// --- Step 3: Typed special-cases (applied after op loop) ---
ApplyTypedSpecialCases( node, live, ctx );
if ( PreviewStrategies.For( node.Kind ) == PreviewStrategy.Placeholder )
{
var isContainer = false;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var parsedKind ) )
isContainer = ContractScanner.Table.Get( parsedKind ).IsContainer;
if ( !isContainer )
{
var inner = live.AddChild<Panel>();
inner.AddClass( "inner" );
Log.Info( $"{LogPrefix} MirrorRecord({node.ClassName}) added .inner child for non-container stand-in" );
}
}
// --- Step 5: Recurse non-slot children ---
foreach ( var child in node.Children )
BuildPreview( child, live, ctx, onNodeBuilt );
if ( node is RecordNode rn )
rn.Backing.LivePanel = live;
// --- Step 7: Fire chrome-label hook ---
onNodeBuilt?.Invoke( node );
// --- Step 8: Tail log (ported verbatim from MirrorRecord) ---
Log.Info( $"{LogPrefix} MirrorRecord({node.ClassName}) under {liveParent.ElementName ?? "root"} -> live {live.GetType().Name}" );
return live;
}
// SplitContainer case extracted to keep BuildPreview readable.
private static Panel BuildPreviewSplitContainer(
IReadOnlyNode node,
Panel liveParent,
ProjectionContext ctx,
Action<IReadOnlyNode> onNodeBuilt )
{
var split = liveParent.AddChild<SplitContainer>( node.ClassName );
// Apply projector ops to the SplitContainer.
if ( ProjectorRegistry.Has( node.Kind ) )
{
var result = ProjectorRegistry.For( node.Kind ).Project(
node, node.Appearance, node.Payload, ctx );
foreach ( var op in result.PanelOps )
ApplyOp( split, op );
}
// Write back LivePanel for the SplitContainer record immediately.
if ( node is RecordNode rnSplit )
rnSplit.Backing.LivePanel = split;
foreach ( var slotKv in node.Slots )
{
var slotName = slotKv.Key;
Panel slotPanel = slotName == "right" ? split.Right : split.Left;
// Find the slot Panel record for its ClassName.
ControlRecord slotRecord = null;
if ( node is RecordNode rnForSlot )
{
foreach ( var child in rnForSlot.Backing.Children )
{
if ( child.IsSlot && child.SlotName == slotName )
{
slotRecord = child;
break;
}
}
}
if ( slotRecord != null && !string.IsNullOrEmpty( slotRecord.ClassName ) )
slotPanel.AddClass( slotRecord.ClassName );
// Write LivePanel for the slot record itself.
if ( slotRecord != null )
slotRecord.LivePanel = slotPanel;
Log.Info( $"{LogPrefix} MirrorRecord: bound slot '{(slotRecord?.ClassName ?? slotName)}' (SlotName={slotName}) to engine .{(slotName == "right" ? "Right" : "Left")}" );
// Recurse grandchildren into the slot panel.
foreach ( var grandchild in slotKv.Value )
BuildPreview( grandchild, slotPanel, ctx, onNodeBuilt );
// Fire chrome-label hook for the slot record.
if ( slotRecord != null )
onNodeBuilt?.Invoke( new RecordNode( slotRecord ) );
}
// Chrome-label hook for the SplitContainer itself.
onNodeBuilt?.Invoke( node );
Log.Info( $"{LogPrefix} MirrorRecord({node.ClassName}) under {liveParent.ElementName ?? "root"} -> live {split.GetType().Name}" );
return split;
}
private static readonly System.Reflection.FieldInfo _panelYogaNodeField =
typeof( Panel ).GetField( "YogaNode",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic );
private static void MarkMeasureDirty( Panel panel )
{
if ( panel is null || !panel.IsValid || _panelYogaNodeField is null ) return;
try
{
var yoga = _panelYogaNodeField.GetValue( panel );
yoga?.GetType()
.GetMethod( "MarkDirty", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic )
?.Invoke( yoga, null );
}
catch ( System.Exception e )
{
Log.Warning( e, $"{LogPrefix} MarkMeasureDirty failed: {e.Message}" );
}
}
// Typed special-cases applied after the op loop (per-kind property sets that ops don't cover).
private static void ApplyTypedSpecialCases( IReadOnlyNode node, Panel live, ProjectionContext ctx )
{
switch ( node.Kind )
{
case "Checkbox":
// LabelText is set directly — projector doesn't emit SetInnerText for Checkbox.
if ( live is Checkbox check )
check.LabelText = node.Payload.Content;
break;
case "TextEntry":
if ( live is TextEntry entry )
entry.Placeholder = node.Payload.Placeholder;
break;
case "Image":
// Texture loading only in preview mode.
if ( ctx.ForPreview && live is Image img )
{
var source = node.Payload.Source ?? "";
if ( !string.IsNullOrEmpty( source ) )
{
var tex = ImageTextureLoader.Load( source );
if ( tex != null )
{
img.Texture = tex;
img.MarkRenderDirty();
MarkMeasureDirty( img );
}
else
{
img.AddClass( "preview-image-empty" );
}
Log.Info( $"{LogPrefix} ReapplyLiveContent.Image '{node.ClassName}' -> Source=\"{source}\" tex={(img.Texture is null ? "null" : "ok")}" );
}
}
break;
}
}
public static void ReapplyContent( IReadOnlyNode node, Panel existing, ProjectionContext ctx )
{
if ( existing is null || !existing.IsValid ) return;
switch ( node.Kind )
{
case "Label":
if ( existing is Label lbl ) lbl.Text = node.Payload.Content;
break;
case "Button":
if ( existing is Button btn ) btn.Text = node.Payload.Content;
break;
case "IconPanel":
if ( existing is IconPanel icon ) icon.Text = node.Payload.IconName;
break;
case "Checkbox":
if ( existing is Checkbox check ) check.LabelText = node.Payload.Content;
break;
case "TextEntry":
if ( existing is TextEntry entry ) entry.Placeholder = node.Payload.Placeholder;
break;
case "Image":
if ( existing is Image img )
{
var source = node.Payload.Source ?? "";
var tex = ImageTextureLoader.Load( source );
img.SetClass( "preview-image-empty", tex is null );
img.Texture = tex;
img.MarkRenderDirty();
MarkMeasureDirty( img );
Log.Info( $"{LogPrefix} ReapplyLiveContent.Image '{node.ClassName}' -> Source=\"{source}\" tex={(tex is null ? "null" : "ok")}" );
}
break;
}
}
public static (string razor, string scss) BuildSave(
IReadOnlyNode root,
string wrapperClassName,
ProjectionContext ctx )
{
var razorSb = new StringBuilder();
var scssSb = new StringBuilder();
var probeSw = PerfProbes.Enabled ? System.Diagnostics.Stopwatch.StartNew() : null;
foreach ( var child in root.Children )
EmitRazorNode( razorSb, child, " ", ctx );
var probeRazorMs = probeSw?.Elapsed.TotalMilliseconds ?? 0.0;
probeSw?.Restart();
// --- SCSS walk ---
bool saveMode = !string.IsNullOrEmpty( wrapperClassName );
if ( saveMode )
{
var rootScss = GetScssLines( root, ctx );
var inner = " ";
if ( rootScss.Count > 0 )
EmitScssLines( scssSb, rootScss, inner );
foreach ( var child in root.Children )
EmitRuleTree( scssSb, child, inner, ctx );
}
else
{
EmitRuleTree( scssSb, root, "", ctx );
}
if ( probeSw != null )
{
var probeScssMs = probeSw.Elapsed.TotalMilliseconds;
var mode = saveMode ? "save" : "preview";
Log.Info( $"{LogPrefix} Applier.BuildSave[{mode}]: razor={probeRazorMs:F2}ms scss={probeScssMs:F2}ms total={(probeRazorMs + probeScssMs):F2}ms" );
}
return (razorSb.ToString(), scssSb.ToString());
}
private static void EmitRazorNode( StringBuilder sb, IReadOnlyNode node, string indent, ProjectionContext ctx )
{
string tag;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var ct ) )
tag = ContractScanner.Table.Get( ct ).LibraryTag;
else
tag = node.Kind.ToLowerInvariant();
EmitRazorNodeCore( sb, node, tag, indent, "", ctx );
}
private static void EmitRazorNodeCore(
StringBuilder sb,
IReadOnlyNode node,
string tag,
string indent,
string slotAttr,
ProjectionContext ctx )
{
// Build class attribute first, then RazorAttributes (node-id etc.) space-joined.
var sb2 = new StringBuilder();
sb2.Append( $"class=\"{Escape.Html( node.ClassName )}\"" );
ProjectionResult result = ProjectionResult.Empty;
if ( ProjectorRegistry.Has( node.Kind ) )
{
result = ProjectorRegistry.For( node.Kind ).Project(
node, node.Appearance, node.Payload, ctx );
foreach ( var attr in result.RazorAttributes )
{
sb2.Append( ' ' );
sb2.Append( attr );
}
}
var attrStr = sb2.ToString();
if ( node.Kind == "SplitContainer" )
{
// Collect slot records.
var slotRecords = new List<ControlRecord>();
if ( node is RecordNode rnSc )
{
foreach ( var child in rnSc.Backing.Children )
{
if ( child.IsSlot )
slotRecords.Add( child );
}
}
if ( slotRecords.Count == 0 )
{
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr} />" );
return;
}
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr}>" );
foreach ( var slotRec in slotRecords )
{
// Slot record is a Panel with IsSlot=true; emit as <panel class="..." slot="...">
var slotNode = new RecordNode( slotRec );
var slotTagStr = ContractScanner.Table.Get( ControlType.Panel ).LibraryTag;
var slotAttrStr = !string.IsNullOrEmpty( slotRec.SlotName )
? $" slot=\"{Escape.Html( slotRec.SlotName )}\""
: "";
EmitRazorNodeCore( sb, slotNode, slotTagStr, indent + " ", slotAttrStr, ctx );
}
sb.AppendLine( $"{indent}</{tag}>" );
return;
}
// Determine isContainer.
bool isContainer = false;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var ctKind ) )
isContainer = ContractScanner.Table.Get( ctKind ).IsContainer;
if ( isContainer )
{
if ( node.Children.Count == 0 )
{
// Self-close empty containers (matches hand-authored razor convention).
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr} />" );
return;
}
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr}>" );
foreach ( var child in node.Children )
EmitRazorNode( sb, child, indent + " ", ctx );
sb.AppendLine( $"{indent}</{tag}>" );
return;
}
// Leaf: inner text or self-close.
var innerText = result.RazorInnerText;
if ( innerText != null )
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr}>{innerText}</{tag}>" );
else
sb.AppendLine( $"{indent}<{tag} {attrStr}{slotAttr} />" );
}
// Port of EmitRuleTree. Handles the .root flat-emit special case and nested children.
private static void EmitRuleTree( StringBuilder sb, IReadOnlyNode node, string indent, ProjectionContext ctx )
{
var inner = indent + " ";
var scssLines = GetScssLines( node, ctx );
// .root is compound with the wrapper class, not a descendant — emit flat.
bool isRoot = node.ClassName == DesignerDocument.RootClassName;
if ( isRoot )
{
if ( scssLines.Count > 0 )
{
sb.AppendLine( $"{indent}.{node.ClassName} {{" );
EmitScssLines( sb, scssLines, inner );
sb.AppendLine( $"{indent}}}" );
}
// Recurse children of root (they're peers at the same indent level).
bool isContainer = false;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var ctForRoot ) )
isContainer = ContractScanner.Table.Get( ctForRoot ).IsContainer;
if ( isContainer )
{
foreach ( var child in GetAllChildrenForScss( node ) )
EmitRuleTree( sb, child, indent, ctx );
}
return;
}
bool nodeIsContainer = false;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var ctNonRoot ) )
nodeIsContainer = ContractScanner.Table.Get( ctNonRoot ).IsContainer;
var childrenForScss = nodeIsContainer ? GetAllChildrenForScss( node ) : new List<IReadOnlyNode>();
bool hasChildren = childrenForScss.Count > 0;
if ( scssLines.Count == 0 && !hasChildren ) return;
sb.AppendLine( $"{indent}.{node.ClassName} {{" );
if ( scssLines.Count > 0 )
EmitScssLines( sb, scssLines, inner );
if ( hasChildren )
{
foreach ( var child in childrenForScss )
EmitRuleTree( sb, child, inner, ctx );
}
sb.AppendLine( $"{indent}}}" );
}
public static List<string> GetNodeScssLines( IReadOnlyNode node, ProjectionContext ctx )
{
if ( !ProjectorRegistry.Has( node.Kind ) )
return new List<string>();
var result = ProjectorRegistry.For( node.Kind ).Project(
node, node.Appearance, node.Payload, ctx );
var lines = new List<string>( result.ScssLines.Count );
foreach ( var l in result.ScssLines )
lines.Add( l );
if ( node.StateRules.Count == 0 )
return lines;
// Derive per-kind flags (same logic the projectors use for the base call).
bool isRoot = node.ClassName == DesignerDocument.RootClassName;
bool isContainer = false;
bool isLabel = node.Kind == "Label";
bool isCheckbox = node.Kind == "Checkbox";
int childCount = node.Children.Count;
if ( System.Enum.TryParse<ControlType>( node.Kind, out var ct ) )
isContainer = ContractScanner.Table.Get( ct ).IsContainer;
// Sort state rules in canonical order.
var sorted = new List<IReadOnlyStateRule>( node.StateRules );
sorted.Sort( IReadOnlyStateRule.CompareCanonical );
foreach ( var sr in sorted )
{
var deltaLines = AppearanceScss.Emit(
sr.Delta,
isRoot: isRoot,
isContainer: isContainer,
childCount: childCount,
isLabel: isLabel,
isCheckbox: isCheckbox,
checkboxSize: default, // Length.Auto — state Delta is Appearance only; Checkbox box size
baseForDiff: node.Appearance ); // Only emit lines where delta differs from base
if ( deltaLines.Count == 0 ) continue;
var sel = ScssEnums.PseudoSelector( sr.State, sr.NthChildMode, sr.NthChildArg );
lines.Add( $"&:{sel} {{" );
foreach ( var dl in deltaLines )
lines.Add( $" {dl}" );
lines.Add( "}" );
}
return lines;
}
// Private wrapper used by EmitRuleTree.
private static List<string> GetScssLines( IReadOnlyNode node, ProjectionContext ctx ) =>
GetNodeScssLines( node, ctx );
private static void EmitScssLines( StringBuilder sb, IReadOnlyList<string> lines, string innerIndent )
{
foreach ( var line in lines )
sb.AppendLine( $"{innerIndent}{line}" );
}
private static List<IReadOnlyNode> GetAllChildrenForScss( IReadOnlyNode node )
{
var result = new List<IReadOnlyNode>();
if ( node.Kind == "SplitContainer" && node is RecordNode rnSc )
{
// Slot Panel records own the user-facing CSS (e.g. .left1 { padding: ... }).
foreach ( var child in rnSc.Backing.Children )
{
if ( child.IsSlot )
result.Add( new RecordNode( child ) );
}
}
else
{
foreach ( var child in node.Children )
result.Add( child );
}
return result;
}
}