Editor/Canvas/SuiLayoutSolver.cs
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi.Canvas;
/// <summary>
/// The single source of truth for "where is each element on the document".
/// Given a <see cref="SuiDocument"/>, produces a flat
/// <c>Dictionary<elementId, Rect></c> in <b>logical pixels</b> (the document's
/// own coord space — typically 1920x1080).
///
/// Renderer reads it to draw. HitTester reads it to pick. Selection chrome
/// reads it to position handles. <b>One math, three consumers.</b> Render fidelity
/// vs. hit-test fidelity drift is impossible by construction — they share this dict.
///
/// Phase 1: absolute layout only (anchor + pivot). The rules mirror
/// <c>SuiScssGenerator.EmitAnchorRules</c> exactly so the canvas paint matches
/// what the runtime SCSS would render.
///
/// Phase 3 will add flex layout (HorizontalBox / VerticalBox / Grid) via the
/// 3-pass Yoga subset algorithm. Until then flex children fall back to absolute
/// (rect.X/Y treated as TopLeft offsets in the parent).
/// </summary>
public sealed class SuiLayoutSolver
{
public Vector2 PanelSize { get; set; }
public Dictionary<string, Rect> Rects { get; } = new();
public Dictionary<string, SuiElement> ById { get; } = new();
/// <summary>
/// Measured size for Text elements in <see cref="SuiTextSizeMode.Auto"/>.
/// Populated at the start of <see cref="Solve"/> by calling
/// <c>Editor.Paint.MeasureText</c> per element. Other modes don't appear here.
/// </summary>
private readonly Dictionary<string, Vector2> _autoTextSizes = new();
public SuiLayoutSolver( Vector2 panelSize )
{
PanelSize = panelSize;
}
/// <summary>
/// Run the solver on the document. Populates <see cref="Rects"/> and
/// <see cref="ById"/>. Subsequent calls clear and rebuild.
/// </summary>
public void Solve( SuiDocument document )
{
Rects.Clear();
ById.Clear();
_autoTextSizes.Clear();
if ( document == null ) return;
foreach ( var el in document.Elements )
if ( !string.IsNullOrEmpty( el.Id ) ) ById[el.Id] = el;
// Measure all Auto-mode Text elements once before walking the tree.
// Paint context is active here (we're called from a viewport's OnPaint),
// so MeasureText returns valid sizes.
MeasureAutoTexts( document );
var root = document.GetRoot();
if ( root == null ) return;
// Root always fills the panel.
Rects[root.Id] = new Rect( 0, 0, PanelSize.x, PanelSize.y );
// Recurse — each container picks absolute or flex based on Layout.Mode.
SolveChildren( root, Rects[root.Id] );
}
private void SolveChildren( SuiElement parent, Rect parentRect )
{
if ( parent?.Children == null || parent.Children.Count == 0 ) return;
// Grid-like containers (InventoryGrid, Grid) use a dedicated regular-tile
// pass driven by parent.Props (Columns/CellWidth/CellHeight/GridGap).
// This works regardless of Layout.Mode so legacy docs (pre-ApplyTypeDefaults
// fix where InventoryGrid defaulted to Absolute) still tile correctly.
// Hotbar is intentionally NOT in this list — it's a single row of slots
// laid out by regular flex (Row + NoWrap), which honors each slot's own
// Width/Height and the parent's Gap directly. Routing it through SolveGrid
// would force a Columns×Rows grid and (with default Columns=1) collapse it
// into a vertical stack.
var isGrid = parent.Type == SuiElementType.InventoryGrid
|| parent.Type == SuiElementType.Grid;
if ( isGrid )
{
var laidGrid = SuiFlexLayout.SolveGrid( parent, parentRect, ById );
foreach ( var item in laidGrid )
{
Rects[item.Id] = item.Rect;
if ( ById.TryGetValue( item.Id, out var c ) )
SolveChildren( c, item.Rect );
}
return;
}
// Flex container? Run the flex layout pass; children's rects come from there.
// Hotbar always falls through here (Mode=Flex set by ApplyTypeDefaults).
if ( parent.Layout?.Mode == SuiLayoutMode.Flex || parent.Type == SuiElementType.Hotbar )
{
// Capture _autoTextSizes via closure so flex children that are Auto-Text
// use measured size as their intrinsic size.
Vector2 IntrinsicWithOverride( SuiElement el )
{
if ( _autoTextSizes.TryGetValue( el.Id, out var measured ) ) return measured;
return IntrinsicSizeOf( el );
}
var laid = SuiFlexLayout.Solve( parent, parentRect, ById, IntrinsicWithOverride );
foreach ( var item in laid )
{
Rects[item.Id] = item.Rect;
if ( ById.TryGetValue( item.Id, out var c ) )
SolveChildren( c, item.Rect );
}
return;
}
// Absolute container — each child gets ResolveAbsoluteRectWithOverride
// (which applies the Auto-Text size measurement override transparently).
foreach ( var childId in parent.Children )
{
if ( !ById.TryGetValue( childId, out var child ) ) continue;
var rect = ResolveAbsoluteRectWithOverride( child, parentRect );
Rects[child.Id] = rect;
SolveChildren( child, rect );
}
}
/// <summary>
/// Return <paramref name="parent"/>'s children in paint order: ascending
/// <c>Layout.ZIndex</c>, with hierarchy order as a stable tie-break (so
/// elements with equal ZIndex preserve their authoring order). Render
/// pass calls this so high-Z elements end up drawn LAST = visually on top.
/// Hit-test consumes the same order so visual ↔ click parity holds.
/// </summary>
public static List<SuiElement> GetRenderOrderedChildren(
SuiElement parent,
IReadOnlyDictionary<string, SuiElement> byId )
{
var list = new List<SuiElement>();
if ( parent?.Children == null ) return list;
for ( int i = 0; i < parent.Children.Count; i++ )
{
if ( byId.TryGetValue( parent.Children[i], out var c ) )
list.Add( c );
}
// Sort with stable insertion-order tie-break.
var indexed = new List<(SuiElement el, int idx)>( list.Count );
for ( int i = 0; i < list.Count; i++ ) indexed.Add( (list[i], i) );
indexed.Sort( ( a, b ) =>
{
var az = a.el.Layout?.ZIndex ?? 0;
var bz = b.el.Layout?.ZIndex ?? 0;
if ( az != bz ) return az.CompareTo( bz );
return a.idx.CompareTo( b.idx );
} );
var result = new List<SuiElement>( indexed.Count );
foreach ( var (el, _) in indexed ) result.Add( el );
return result;
}
/// <summary>
/// Measure Text elements that are in Auto mode. Used by absolute layout pass
/// to override Layout.Width/Height with the actual rendered text size.
/// </summary>
private void MeasureAutoTexts( SuiDocument doc )
{
foreach ( var el in doc.Elements )
{
if ( el.Type != SuiElementType.Text ) continue;
if ( el.Props == null ) continue;
if ( el.Props.TextSizeMode != SuiTextSizeMode.Auto ) continue;
if ( string.IsNullOrEmpty( el.Props.Text ) ) continue;
var fontName = string.IsNullOrEmpty( el.Props.FontFamily ) ? Theme.DefaultFont : el.Props.FontFamily;
var fontSize = el.Props.FontSize > 0 ? el.Props.FontSize : 14f;
var weight = MapFontWeight( el.Props.FontWeight );
Editor.Paint.SetFont( fontName, fontSize, weight );
var size = Editor.Paint.MeasureText( el.Props.Text );
_autoTextSizes[el.Id] = size;
}
}
private static int MapFontWeight( SuiFontWeight w ) => w switch
{
SuiFontWeight.Light => 300,
SuiFontWeight.Normal => 400,
SuiFontWeight.Medium => 500,
SuiFontWeight.SemiBold => 600,
SuiFontWeight.Bold => 700,
SuiFontWeight.ExtraBold => 800,
_ => 400,
};
/// <summary>
/// Resolve absolute rect for a child, applying Text-Auto size override
/// if the element is a Text in Auto mode (uses measured size as W/H).
/// </summary>
private Rect ResolveAbsoluteRectWithOverride( SuiElement el, Rect parentRect )
{
if ( _autoTextSizes.TryGetValue( el.Id, out var measured ) )
{
// Temporarily swap layout W/H for the static math, restore after.
var origW = el.Layout.Width;
var origH = el.Layout.Height;
el.Layout.Width = measured.x;
el.Layout.Height = measured.y;
try { return ResolveAbsoluteRect( el, parentRect ); }
finally
{
el.Layout.Width = origW;
el.Layout.Height = origH;
}
}
return ResolveAbsoluteRect( el, parentRect );
}
/// <summary>
/// Intrinsic size for flex children. Uses the layout's W/H if > 0, else
/// per-type defaults from <see cref="DefaultWidthFor"/> / <see cref="DefaultHeightFor"/>.
/// </summary>
private static Vector2 IntrinsicSizeOf( SuiElement el )
{
var l = el.Layout;
var w = l != null && l.Width > 0 ? l.Width : DefaultWidthFor( el );
var h = l != null && l.Height > 0 ? l.Height : DefaultHeightFor( el );
return new Vector2( w, h );
}
/// <summary>
/// True if the solver has computed a rect for the given element.
/// </summary>
public bool TryGetRect( string elementId, out Rect rect )
{
if ( !string.IsNullOrEmpty( elementId ) && Rects.TryGetValue( elementId, out rect ) )
return true;
rect = default;
return false;
}
/// <summary>
/// Compute an element's rect inside its parent's rect, applying anchor +
/// pivot the same way <c>SuiScssGenerator.EmitAnchorRules</c> does. The
/// implicit pivot per anchor matches the SCSS transform: TopLeft→(0,0),
/// MiddleCenter→(0.5,0.5), BottomRight→(1,1), etc.
///
/// Returns the rect in the SAME coord space as <paramref name="parentRect"/>
/// (i.e. relative to the panel root, not the parent's local space).
/// </summary>
public static Rect ResolveAbsoluteRect( SuiElement el, Rect parentRect )
{
var l = el.Layout;
var w = l.Width > 0 ? l.Width : DefaultWidthFor( el );
var h = l.Height > 0 ? l.Height : DefaultHeightFor( el );
var pw = parentRect.Width;
var ph = parentRect.Height;
// Anchor → reference point in parent + implicit pivot on element + axis sign.
// signX/signY flip when the anchor is on a "right"/"bottom" edge, because
// in our schema X/Y on those anchors mean "offset INWARD" (matches CSS
// `right: Xpx` / `bottom: Ypx`).
float refX, refY, pivotX, pivotY, signX, signY;
switch ( l.Anchor )
{
case SuiAnchor.TopLeft: refX = 0; refY = 0; pivotX = 0; pivotY = 0; signX = 1; signY = 1; break;
case SuiAnchor.TopCenter: refX = pw*0.5f; refY = 0; pivotX = 0.5f;pivotY = 0; signX = 1; signY = 1; break;
case SuiAnchor.TopRight: refX = pw; refY = 0; pivotX = 1; pivotY = 0; signX = -1; signY = 1; break;
case SuiAnchor.MiddleLeft: refX = 0; refY = ph*0.5f; pivotX = 0; pivotY = 0.5f;signX = 1; signY = 1; break;
case SuiAnchor.MiddleCenter: refX = pw*0.5f; refY = ph*0.5f; pivotX = 0.5f;pivotY = 0.5f;signX = 1; signY = 1; break;
case SuiAnchor.MiddleRight: refX = pw; refY = ph*0.5f; pivotX = 1; pivotY = 0.5f;signX = -1; signY = 1; break;
case SuiAnchor.BottomLeft: refX = 0; refY = ph; pivotX = 0; pivotY = 1; signX = 1; signY = -1; break;
case SuiAnchor.BottomCenter: refX = pw*0.5f; refY = ph; pivotX = 0.5f;pivotY = 1; signX = 1; signY = -1; break;
case SuiAnchor.BottomRight: refX = pw; refY = ph; pivotX = 1; pivotY = 1; signX = -1; signY = -1; break;
case SuiAnchor.Stretch:
// X = left margin, Y = top margin, W = right margin, H = bottom margin.
// Element fills the parent minus those four offsets.
return new Rect(
parentRect.Left + l.X,
parentRect.Top + l.Y,
System.Math.Max( 0, pw - l.X - l.Width ),
System.Math.Max( 0, ph - l.Y - l.Height ) );
case SuiAnchor.StretchHorizontal:
// Horizontally stretched between X (left margin) and W (right margin).
// Y = top offset (from parent.Top), H = element height.
return new Rect(
parentRect.Left + l.X,
parentRect.Top + l.Y,
System.Math.Max( 0, pw - l.X - l.Width ),
h );
case SuiAnchor.StretchVertical:
// Vertically stretched between Y (top margin) and H (bottom margin).
// X = left offset, W = element width.
return new Rect(
parentRect.Left + l.X,
parentRect.Top + l.Y,
w,
System.Math.Max( 0, ph - l.Y - l.Height ) );
default: refX = 0; refY = 0; pivotX = 0; pivotY = 0; signX = 1; signY = 1; break;
}
var posX = refX + l.X * signX;
var posY = refY + l.Y * signY;
var topLeftX = posX - pivotX * w;
var topLeftY = posY - pivotY * h;
return new Rect( parentRect.Left + topLeftX, parentRect.Top + topLeftY, w, h );
}
/// <summary>
/// Inverse of the layout pass — converts a target visual rect (logical-pixel
/// space) back into the X/Y/W/H values that, given the same anchor/pivot,
/// would produce that rect. Used by drag-to-move + resize handles to write
/// values back into the document.
/// </summary>
public static (float x, float y, float w, float h) RectToLayoutValues(
Rect targetRect, SuiAnchor anchor, Rect parentRect )
{
var pw = parentRect.Width;
var ph = parentRect.Height;
var w = targetRect.Width;
var h = targetRect.Height;
// Stretch variants — X/Y/W/H are margins, not absolute positions.
// (See solver Layout pass for the forward mapping.)
switch ( anchor )
{
case SuiAnchor.Stretch:
{
var leftMargin = targetRect.Left - parentRect.Left;
var topMargin = targetRect.Top - parentRect.Top;
var rightMargin = parentRect.Right - targetRect.Right;
var bottomMargin = parentRect.Bottom - targetRect.Bottom;
return (leftMargin, topMargin, rightMargin, bottomMargin);
}
case SuiAnchor.StretchHorizontal:
{
var leftMargin = targetRect.Left - parentRect.Left;
var topOffset = targetRect.Top - parentRect.Top;
var rightMargin = parentRect.Right - targetRect.Right;
return (leftMargin, topOffset, rightMargin, h);
}
case SuiAnchor.StretchVertical:
{
var leftOffset = targetRect.Left - parentRect.Left;
var topMargin = targetRect.Top - parentRect.Top;
var bottomMargin = parentRect.Bottom - targetRect.Bottom;
return (leftOffset, topMargin, w, bottomMargin);
}
}
float refX, refY, pivotX, pivotY, signX, signY;
switch ( anchor )
{
case SuiAnchor.TopLeft: refX = 0; refY = 0; pivotX = 0; pivotY = 0; signX = 1; signY = 1; break;
case SuiAnchor.TopCenter: refX = pw*0.5f; refY = 0; pivotX = 0.5f;pivotY = 0; signX = 1; signY = 1; break;
case SuiAnchor.TopRight: refX = pw; refY = 0; pivotX = 1; pivotY = 0; signX = -1; signY = 1; break;
case SuiAnchor.MiddleLeft: refX = 0; refY = ph*0.5f; pivotX = 0; pivotY = 0.5f;signX = 1; signY = 1; break;
case SuiAnchor.MiddleCenter: refX = pw*0.5f; refY = ph*0.5f; pivotX = 0.5f;pivotY = 0.5f;signX = 1; signY = 1; break;
case SuiAnchor.MiddleRight: refX = pw; refY = ph*0.5f; pivotX = 1; pivotY = 0.5f;signX = -1; signY = 1; break;
case SuiAnchor.BottomLeft: refX = 0; refY = ph; pivotX = 0; pivotY = 1; signX = 1; signY = -1; break;
case SuiAnchor.BottomCenter: refX = pw*0.5f; refY = ph; pivotX = 0.5f;pivotY = 1; signX = 1; signY = -1; break;
case SuiAnchor.BottomRight: refX = pw; refY = ph; pivotX = 1; pivotY = 1; signX = -1; signY = -1; break;
default: refX = 0; refY = 0; pivotX = 0; pivotY = 0; signX = 1; signY = 1; break;
}
// Target top-left in parent-local coords (subtract parentRect origin).
var topLeftX = targetRect.Left - parentRect.Left;
var topLeftY = targetRect.Top - parentRect.Top;
// Reverse: posX = topLeftX + pivotX * w; X = (posX - refX) * signX (since signX in {-1,+1})
var posX = topLeftX + pivotX * w;
var posY = topLeftY + pivotY * h;
var x = (posX - refX) * signX;
var y = (posY - refY) * signY;
return (x, y, w, h);
}
private static float DefaultWidthFor( SuiElement el ) => el.Type switch
{
SuiElementType.Text => 200,
SuiElementType.Button => 120,
SuiElementType.Image => 100,
SuiElementType.ItemIcon => 64,
SuiElementType.InventorySlot => 64,
_ => 100,
};
private static float DefaultHeightFor( SuiElement el ) => el.Type switch
{
SuiElementType.Text => 32,
SuiElementType.Button => 36,
SuiElementType.Image => 100,
SuiElementType.ItemIcon => 64,
SuiElementType.InventorySlot => 64,
_ => 32,
};
}