Editor/Inspector/StateSectionWidget.cs
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Sandbox;
namespace Grains.RazorDesigner.Inspector;
public sealed class StateSectionWidget : CollapsibleSection
{
private const string LogPrefix = "[Grains.RazorDesigner]";
public Action<StateRule> RuleChanged; // Mode/arg/order change — caller re-sorts + refreshes.
public Action RemoveRequested; // × clicked (UI-spec §Decision 5).
public StateRule Rule { get; private set; }
public ControlRecord Target { get; }
private static readonly IReadOnlyList<AppearanceGroupBuilder.GroupSpec> StylingGroups =
new[]
{
new AppearanceGroupBuilder.GroupSpec( "Typography", false, "format_size" ),
new AppearanceGroupBuilder.GroupSpec( "Background", false, "palette" ),
new AppearanceGroupBuilder.GroupSpec( "Border", false, "border_style" ),
new AppearanceGroupBuilder.GroupSpec( "Effects", false, "auto_awesome" ),
new AppearanceGroupBuilder.GroupSpec( "Constraints", false, "fit_screen" ),
new AppearanceGroupBuilder.GroupSpec( "Interaction", false, "touch_app" ),
};
private AppearanceGroupBuilder _builder;
private StateRuleAppearanceProxy _proxy;
// Stored to prevent GC of the SerializedObject (engine pattern — keep the reference live).
private SerializedObject _serialized;
private static readonly HashSet<ControlType> FocusableTypes = new()
{
ControlType.TextEntry,
ControlType.Button,
ControlType.Checkbox,
ControlType.DropDown,
};
private ComboBox _nthMode;
private LineEdit _nthArg;
public StateSectionWidget( Widget parent, ControlRecord target, StateRule rule )
: base( parent, BuildTitle( rule ), BuildIcon( rule.State ) )
{
Target = target;
Rule = rule;
HeaderColor = ControlPresentation.PseudoclassTint;
Log.Info( $"{LogPrefix} StateSectionWidget ctor: target={target.ClassName} state={rule.State}" );
if ( rule.State == PseudoKind.NthChild )
{
Title = ""; // we paint our own text via the header-right widgets.
BuildNthChildHeaderWidgets();
}
MaybeAddFocusableWarning();
AddRemoveButton();
}
public void BuildBody( DesignerDocument document, Action onValueChanged )
{
Log.Info( $"{LogPrefix} StateSectionWidget.BuildBody: target={Target.ClassName} state={Rule.State}" );
var keyState = Rule.State;
var keyMode = Rule.NthChildMode;
var keyArg = Rule.NthChildArg;
_proxy = new StateRuleAppearanceProxy( Target, () =>
{
for ( int i = 0; i < Target.StateRules.Count; i++ )
{
var r = Target.StateRules[i];
if ( r.State == keyState && r.NthChildMode == keyMode && r.NthChildArg == keyArg )
return i;
}
return -1;
} );
_proxy.Changed += () => onValueChanged?.Invoke();
_serialized = EditorTypeLibrary.GetSerializedObject( _proxy );
_builder = new AppearanceGroupBuilder( StylingGroups, store: null );
_builder.Build( BodyLayout, _serialized, filterText: "" );
_proxy.Changed += () => _builder?.ApplyReadOnlyStates( _proxy, ContractScanner.Table, Target.Type );
_builder.ApplyReadOnlyStates( _proxy, ContractScanner.Table, Target.Type );
}
private void AddRemoveButton()
{
var remove = new IconButton( "close", OnRemoveClicked )
{
ToolTip = "Remove this state rule",
Background = Color.Transparent,
FixedSize = new Vector2( Theme.RowHeight, Theme.RowHeight ),
};
HeaderRightLayout.Add( remove );
HeaderRightReservedWidth += 24f;
Log.Info( $"{LogPrefix} StateSectionWidget: × remove button added for state={Rule.State}" );
}
private void OnRemoveClicked()
{
// Empty Delta? Remove silently. Non-empty? Confirm.
if ( IsDeltaEmpty( Rule.Delta ) )
{
Log.Info( $"{LogPrefix} StateSectionWidget remove (empty delta): state={Rule.State}" );
RemoveRequested?.Invoke();
return;
}
ShowRemoveConfirmDialog();
}
private void ShowRemoveConfirmDialog()
{
var stateLabel = Rule.State switch
{
PseudoKind.Hover => ":hover",
PseudoKind.Active => ":active",
PseudoKind.Focus => ":focus",
PseudoKind.Empty => ":empty",
PseudoKind.FirstChild => ":first-child",
PseudoKind.LastChild => ":last-child",
PseudoKind.OnlyChild => ":only-child",
PseudoKind.NthChild => ":nth-child(...)",
_ => Rule.State.ToString()
};
var dialog = new Editor.Dialog( this );
dialog.Window.WindowTitle = "Remove state rule?";
dialog.Window.SetWindowIcon( "warning" );
dialog.Window.SetModal( true, true );
dialog.Window.MinimumWidth = 360;
dialog.Layout = Layout.Column();
dialog.Layout.Margin = 16;
dialog.Layout.Spacing = 12;
var msg = new Editor.Label( dialog )
{
Text = $"Remove {stateLabel} rule with edits?",
WordWrap = true,
};
dialog.Layout.Add( msg );
var buttonRow = dialog.Layout.Add( Layout.Row() );
buttonRow.Spacing = 8;
buttonRow.AddStretchCell();
var cancelBtn = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
cancelBtn.MouseLeftPress += () => dialog.Close();
buttonRow.Add( cancelBtn );
var removeBtn = new Editor.Button( dialog ) { Text = "Remove", MinimumWidth = 72 };
removeBtn.MouseLeftPress += () =>
{
Log.Info( $"{LogPrefix} StateSectionWidget remove (confirmed): state={Rule.State}" );
dialog.Close();
RemoveRequested?.Invoke();
};
buttonRow.Add( removeBtn );
dialog.Window.AdjustSize();
dialog.Show();
Log.Info( $"{LogPrefix} StateSectionWidget ShowRemoveConfirmDialog: state={Rule.State} stateLabel={stateLabel}" );
}
private static bool IsDeltaEmpty( Appearance delta )
{
return !delta.OverrideTypography
&& !delta.OverrideBackground
&& !delta.OverrideBorder
&& !delta.OverrideEffects
&& !delta.OverrideConstraints
&& !delta.OverrideInteraction;
}
private void MaybeAddFocusableWarning()
{
if ( Rule.State != PseudoKind.Focus && Rule.State != PseudoKind.Active ) return;
if ( FocusableTypes.Contains( Target.Type ) ) return;
var warn = new IconButton( "warning", () => { /* no-op; tooltip is the affordance */ } )
{
ToolTip = "This state usually only fires on focusable controls (TextEntry, Button, Checkbox, DropDown). The rule will save, but may not trigger at runtime.",
Background = Color.Transparent,
FixedSize = new Vector2( Theme.RowHeight, Theme.RowHeight ),
};
HeaderRightLayout.Add( warn );
HeaderRightReservedWidth += 24f;
Log.Info( $"{LogPrefix} StateSectionWidget: ⚠ added for state={Rule.State} on non-focusable type={Target.Type}" );
}
private void BuildNthChildHeaderWidgets()
{
var openLabel = new Label( ":nth-child(", null );
HeaderRightLayout.Add( openLabel );
_nthMode = new ComboBox( null );
_nthMode.AddItem( "Literal", null, onSelected: () => OnNthModeChanged( NthChildMode.Literal ) );
_nthMode.AddItem( "Odd", null, onSelected: () => OnNthModeChanged( NthChildMode.Odd ) );
_nthMode.AddItem( "Even", null, onSelected: () => OnNthModeChanged( NthChildMode.Even ) );
_nthMode.CurrentIndex = (int)Rule.NthChildMode;
HeaderRightLayout.Add( _nthMode );
_nthArg = new LineEdit( null );
_nthArg.Text = Rule.NthChildArg.ToString();
_nthArg.MaximumWidth = 48;
_nthArg.TextEdited += OnNthArgEdited;
_nthArg.ReadOnly = Rule.NthChildMode != NthChildMode.Literal;
HeaderRightLayout.Add( _nthArg );
var closeLabel = new Label( ")", null );
HeaderRightLayout.Add( closeLabel );
// Reserve enough header width for the inline widgets so the title-paint doesn't overlap.
HeaderRightReservedWidth = 240f;
}
private void OnNthModeChanged( NthChildMode mode )
{
var oldRule = Rule; // capture BEFORE mutating field
Rule = Rule with { NthChildMode = mode };
_nthArg.ReadOnly = mode != NthChildMode.Literal;
ReplaceRuleInDocument( oldRule, Rule ); // pass old explicitly
RuleChanged?.Invoke( Rule );
}
private void OnNthArgEdited( string text )
{
if ( !int.TryParse( text, out var n ) || n < 1 ) return;
var oldRule = Rule;
Rule = Rule with { NthChildArg = n };
ReplaceRuleInDocument( oldRule, Rule );
RuleChanged?.Invoke( Rule );
}
private void ReplaceRuleInDocument( StateRule oldRule, StateRule newRule )
{
var i = Target.StateRules.IndexOf( oldRule );
if ( i < 0 )
{
Log.Warning( $"{LogPrefix} ReplaceRuleInDocument: rule no longer in list, skipping" );
return;
}
Target.StateRules[i] = newRule;
Log.Info( $"{LogPrefix} ReplaceRuleInDocument: i={i} state={newRule.State} mode={newRule.NthChildMode} arg={newRule.NthChildArg}" );
}
private static string BuildTitle( StateRule rule )
{
return rule.State switch
{
PseudoKind.Hover => ":hover",
PseudoKind.Active => ":active",
PseudoKind.Focus => ":focus",
PseudoKind.Empty => ":empty",
PseudoKind.FirstChild => ":first-child",
PseudoKind.LastChild => ":last-child",
PseudoKind.OnlyChild => ":only-child",
PseudoKind.NthChild => ":nth-child", // overridden by inline widgets in F3.
_ => rule.State.ToString()
};
}
private static string BuildIcon( PseudoKind state )
{
return state switch
{
PseudoKind.Hover => "mouse",
PseudoKind.Active => "bolt",
PseudoKind.Focus => "center_focus_strong",
PseudoKind.Empty => "unfold_less",
PseudoKind.FirstChild => "first_page",
PseudoKind.LastChild => "last_page",
PseudoKind.OnlyChild => "looks_one",
PseudoKind.NthChild => "filter_9_plus",
_ => "category"
};
}
}