Editor/Inspector/StateStylingWindow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Selection;
using Sandbox;
using Margin = Sandbox.UI.Margin;
namespace Grains.RazorDesigner.Inspector;
public sealed class StateStylingWindow : BaseWindow
{
private const string LogPrefix = "[Grains.RazorDesigner]";
private const string GeometryCookie = "razordesigner.statestyling.geometry";
private readonly DesignerDocument _document;
private readonly SelectionController _selection;
private readonly Action _onValueChanged;
private readonly Action<ControlRecord, PseudoKind?> _onPinChanged;
public event Action Closed;
private ControlRecord _target;
private Widget _toolbar;
private ScrollArea _scroll;
private Widget _footer;
private Label _emptyStateLabel;
private ComboBox _previewPicker;
private Button _addStateButton;
public StateStylingWindow(
DesignerDocument document,
SelectionController selection,
Action onValueChanged,
Action<ControlRecord, PseudoKind?> onPinChanged )
{
_document = document ?? throw new ArgumentNullException( nameof(document) );
_selection = selection ?? throw new ArgumentNullException( nameof(selection) );
_onValueChanged = onValueChanged ?? throw new ArgumentNullException( nameof(onValueChanged) );
_onPinChanged = onPinChanged ?? throw new ArgumentNullException( nameof(onPinChanged) );
// Always-on-top so the window can't fall behind an undocked DesignerWindow.
// Tool flag was tried first but suppressed the taskbar entry, so when the window did go
// behind there was no way to bring it back. Window | StaysOnTop keeps it in the taskbar
// AND pinned above other windows. Trade-off: it pins above every app, not just sbox.
WindowFlags = WindowFlags.Window | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.MinMaxButtons | WindowFlags.WindowStaysOnTopHint;
WindowTitle = "State Styling";
MinimumSize = new Vector2( 360, 400 );
var raw = EditorCookie.Get<string>( GeometryCookie, null );
if ( !string.IsNullOrEmpty( raw ) )
{
var parts = raw.Split( ',' );
if ( parts.Length == 4
&& float.TryParse( parts[0], out var x )
&& float.TryParse( parts[1], out var y )
&& float.TryParse( parts[2], out var w )
&& float.TryParse( parts[3], out var h ) )
{
Position = new Vector2( x, y );
Size = new Vector2( w, h );
Log.Info( $"{LogPrefix} StateStylingWindow geometry restored: {x},{y} {w}x{h}" );
}
}
else
{
Size = new Vector2( 480, 640 );
}
Log.Info( $"{LogPrefix} StateStylingWindow ctor" );
// Body layout: toolbar (E1-E3) | scroll | footer hint (F6). For now just the scroll + empty-state.
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
// Toolbar — Preview state picker + Add state button (E1).
_toolbar = new Widget( this );
_toolbar.Layout = Layout.Row();
_toolbar.Layout.Margin = new Margin( 8, 4 );
_toolbar.Layout.Spacing = 6;
Layout.Add( _toolbar );
var previewLabel = new Label( "Preview state:", _toolbar );
previewLabel.SetStyles( "color: " + Theme.TextLight.Hex + ";" );
_toolbar.Layout.Add( previewLabel );
_previewPicker = new ComboBox( _toolbar );
_previewPicker.AddItem( "None", null, onSelected: () => SetPreviewPin( null ) );
_previewPicker.AddItem( ":hover", null, onSelected: () => SetPreviewPin( PseudoKind.Hover ) );
_previewPicker.AddItem( ":active", null, onSelected: () => SetPreviewPin( PseudoKind.Active ) );
_previewPicker.AddItem( ":focus", null, onSelected: () => SetPreviewPin( PseudoKind.Focus ) );
_toolbar.Layout.Add( _previewPicker );
_toolbar.Layout.AddStretchCell();
_addStateButton = new Button( "+ Add state ▾", _toolbar );
_addStateButton.Clicked = OnAddStateClicked;
_toolbar.Layout.Add( _addStateButton );
Layout.AddSeparator();
_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;
Layout.Add( _scroll, 1 );
// Footer hint — populated in F6.
_footer = new Widget( this );
_footer.Layout = Layout.Row();
_footer.Layout.Margin = new Margin( 8, 6 );
Layout.Add( _footer );
// Footer hint — UI-spec §Window footer. Permanent (so it's findable), subtle.
_footer.Layout.AddStretchCell();
var footerLabel = new Label( "ⓘ State rules without a transition apply instantly. Transitions land with grd-pd3.", _footer );
footerLabel.SetStyles( "color: " + Theme.TextLight.Hex + "; font-size: 11px;" );
_footer.Layout.Add( footerLabel );
_footer.Layout.AddStretchCell();
Log.Info( $"{LogPrefix} Footer transition-hint populated" );
// Selection binding: rebuild body on every change.
_selection.Changed += OnSelectionChanged;
Bind( _selection.Selected );
}
protected override void OnClosed()
{
var x = Position.x; var y = Position.y; var w = Size.x; var h = Size.y;
EditorCookie.Set( GeometryCookie, $"{x},{y},{w},{h}" );
Log.Info( $"{LogPrefix} StateStylingWindow geometry saved: {x},{y} {w}x{h}" );
_selection.Changed -= OnSelectionChanged;
if ( _target is not null ) _onPinChanged?.Invoke( _target, null );
Closed?.Invoke();
base.OnClosed();
}
private void OnSelectionChanged()
{
// Pin clears on the OLD target before re-binding (UI-spec §Pin clearing rules).
if ( _target is not null ) _onPinChanged?.Invoke( _target, null );
Bind( _selection.Selected );
}
private void Bind( ControlRecord target )
{
_target = target;
Log.Info( $"{LogPrefix} StateStylingWindow.Bind target={target?.ClassName ?? "<none>"}" );
WindowTitle = target is null ? "State Styling" : $"State Styling — {target.ClassName}";
if ( _previewPicker is not null ) _previewPicker.CurrentIndex = 0; // Reset to None on every target change.
_addStateButton.Enabled = target is not null;
RebuildBody();
}
private void SetPreviewPin( PseudoKind? state )
{
if ( _target is null ) return;
Log.Info( $"{LogPrefix} SetPreviewPin: target={_target?.ClassName} state={state}" );
_onPinChanged?.Invoke( _target, state );
}
private void OnAddStateClicked()
{
if ( _target is null ) return;
var menu = new Menu( _addStateButton );
// Interactive states — already-present ones are skipped (except :nth-child which is repeatable).
var present = new HashSet<PseudoKind>( _target.StateRules.Select( r => r.State ) );
if ( !present.Contains( PseudoKind.Hover ) )
menu.AddOption( ":hover", null, () => AppendStateRule( PseudoKind.Hover ) );
if ( !present.Contains( PseudoKind.Active ) )
menu.AddOption( ":active", null, () => AppendStateRule( PseudoKind.Active ) );
if ( !present.Contains( PseudoKind.Focus ) )
menu.AddOption( ":focus", null, () => AppendStateRule( PseudoKind.Focus ) );
menu.AddSeparator();
if ( !present.Contains( PseudoKind.Empty ) )
menu.AddOption( ":empty", null, () => AppendStateRule( PseudoKind.Empty ) );
if ( !present.Contains( PseudoKind.FirstChild ) )
menu.AddOption( ":first-child", null, () => AppendStateRule( PseudoKind.FirstChild ) );
if ( !present.Contains( PseudoKind.LastChild ) )
menu.AddOption( ":last-child", null, () => AppendStateRule( PseudoKind.LastChild ) );
if ( !present.Contains( PseudoKind.OnlyChild ) )
menu.AddOption( ":only-child", null, () => AppendStateRule( PseudoKind.OnlyChild ) );
// :nth-child is always offered — it's repeatable (multiple sections allowed for different args).
menu.AddOption( ":nth-child…", null, () => AppendStateRule( PseudoKind.NthChild ) );
menu.OpenAtCursor();
}
private void AppendStateRule( PseudoKind state )
{
if ( _target is null ) return;
var rule = state == PseudoKind.NthChild
? new StateRule { State = state, NthChildMode = NthChildMode.Literal, NthChildArg = 1, Delta = Appearance.Default }
: new StateRule { State = state, Delta = Appearance.Default };
_target.StateRules.Add( rule );
Log.Info( $"{LogPrefix} AppendStateRule: target={_target.ClassName} state={state}" );
_onValueChanged?.Invoke(); // dirties doc + refreshes preview.
RebuildBody();
}
private void RebuildBody()
{
_scroll.Canvas.Layout.Clear( true );
if ( _target is null )
{
Log.Info( $"{LogPrefix} StateStylingWindow.RebuildBody: no target — showing empty-state" );
_emptyStateLabel = new Label( "No control selected.", _scroll.Canvas );
_emptyStateLabel.SetStyles( "color: " + Theme.TextLight.Hex + "; padding: 16px; text-align: center;" );
_scroll.Canvas.Layout.Add( _emptyStateLabel );
_scroll.Canvas.Layout.AddStretchCell();
return;
}
if ( _target.StateRules.Count == 0 )
{
Log.Info( $"{LogPrefix} StateStylingWindow.RebuildBody: target={_target.ClassName} has no state rules — showing hint" );
_emptyStateLabel = new Label( "No state rules yet — click + Add state to add one.", _scroll.Canvas );
_emptyStateLabel.SetStyles( "color: " + Theme.TextLight.Hex + "; padding: 16px; text-align: center;" );
_scroll.Canvas.Layout.Add( _emptyStateLabel );
_scroll.Canvas.Layout.AddStretchCell();
return;
}
Log.Info( $"{LogPrefix} StateStylingWindow.RebuildBody: target={_target.ClassName}, {_target.StateRules.Count} state rule(s)" );
// Render one StateSectionWidget per StateRule, in CompareCanonical order.
var ordered = _target.StateRules.OrderBy( r => r, StateRuleCanonicalComparer.Instance ).ToList();
for ( int i = 0; i < ordered.Count; i++ )
{
var rule = ordered[i];
var section = new StateSectionWidget( _scroll.Canvas, _target, rule );
section.BuildBody( _document, _onValueChanged );
section.RuleChanged = _ => { _onValueChanged?.Invoke(); RebuildBody(); };
section.RemoveRequested = () =>
{
_target.StateRules.Remove( rule );
_onValueChanged?.Invoke();
RebuildBody();
};
_scroll.Canvas.Layout.Add( section );
}
_scroll.Canvas.Layout.AddStretchCell();
}
}