Editor/Canvas/SuiCanvasRenderer.cs
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi.Canvas;
/// <summary>
/// Paints a <see cref="SuiDocument"/> tree directly via the editor Paint API.
/// Operates in <b>logical pixel space</b> (1920x1080 default) — the caller is
/// expected to have already applied a Paint.Translate + Paint.Scale to transform
/// logical→widget pixels.
///
/// Reads element rects from a populated <see cref="SuiLayoutSolver"/>. Per
/// element type, emits the equivalent of what the Razor + SCSS would render.
/// Keeping these rules in sync with <c>SuiScssGenerator</c> is what makes the
/// canvas a 1:1 preview of the runtime output.
///
/// Render rules per element type — Phase 1 implementation:
///
/// | Type | Visual |
/// |-----------------|----------------------------------------------------------------|
/// | Canvas (root) | Faint outline only (document boundary) |
/// | Panel | Background-color fill + border (with optional radius) |
/// | Text | Text drawn with FontFamily/Size/Weight/Color/Align/Overflow |
/// | Image | Background fill + image via Paint.SetBrush(Pixmap) + tint |
/// | Button | Composed: Panel (bg + border) + centered Text label |
/// | ProgressBar | Outer Panel + inner filled bar at PreviewValue/(Max-Min) |
/// | HorizontalBox | No own visual (layout container) |
/// | VerticalBox | No own visual (layout container) |
/// | Grid | No own visual (layout container) — children handled in Phase 3 |
/// | Overlay | No own visual (z-stacking container) |
/// | ScrollPanel | Panel + clip-rect (V2 — for now just renders as Panel) |
/// | InventorySlot | Panel with subdued bg |
/// | InventoryGrid | No own visual |
/// | ItemIcon | Image rendering of <c>PreviewIconPath</c> |
/// | Tooltip | Hidden in canvas (runtime-only) |
/// | Hotbar | No own visual |
/// </summary>
public sealed class SuiCanvasRenderer
{
private readonly SuiLayoutSolver _solver;
private readonly string _projectAssetsRoot;
/// <summary>
/// Current canvas zoom factor — set by the viewport before each paint pass.
/// Used by image rendering to decide whether the native pixmap needs a
/// CPU pre-resize before the GPU draws it (avoids heavy aliasing when the
/// effective display size is much smaller than native).
/// </summary>
public float Zoom { get; set; } = 1.0f;
public SuiCanvasRenderer( SuiLayoutSolver solver, string projectAssetsRoot )
{
_solver = solver;
_projectAssetsRoot = projectAssetsRoot;
}
/// <summary>
/// Paint the entire document. Caller must have already pushed the
/// logical→widget transform onto Paint.
/// </summary>
public void Paint( SuiDocument document )
{
if ( document == null ) return;
var root = document.GetRoot();
if ( root == null ) return;
// Pre-pass: ensure antialiasing for smooth borders + text.
Editor.Paint.Antialiasing = true;
Editor.Paint.TextAntialiasing = true;
PaintElement( root );
}
private void PaintElement( SuiElement el )
{
if ( el == null ) return;
if ( el.Flags?.HiddenInDesigner == true ) return;
if ( el.Style?.Visibility == SuiVisibility.Collapsed ) return;
if ( !_solver.TryGetRect( el.Id, out var rect ) ) return;
var opacity = ResolveOpacity( el );
if ( opacity <= 0f ) { PaintChildren( el ); return; }
switch ( el.Type )
{
case SuiElementType.Canvas:
PaintCanvasRoot( el, rect );
break;
case SuiElementType.Panel:
case SuiElementType.Overlay:
case SuiElementType.HorizontalBox:
case SuiElementType.VerticalBox:
case SuiElementType.Grid:
case SuiElementType.ScrollPanel:
case SuiElementType.InventoryGrid:
case SuiElementType.Hotbar:
PaintPanelLike( el, rect, opacity );
break;
case SuiElementType.InventorySlot:
// Slot frame + optional preview icon + count overlay (so designer
// sees what an occupied slot will look like at runtime).
PaintPanelLike( el, rect, opacity );
PaintItemIcon( el, rect, opacity );
break;
case SuiElementType.Text:
PaintPanelLike( el, rect, opacity ); // bg if any
PaintText( el, rect, opacity );
break;
case SuiElementType.Image:
PaintPanelLike( el, rect, opacity ); // bg if any
PaintImage( el, rect, opacity );
break;
case SuiElementType.Button:
PaintPanelLike( el, rect, opacity );
PaintButtonLabel( el, rect, opacity );
break;
case SuiElementType.ProgressBar:
PaintPanelLike( el, rect, opacity );
PaintProgressFill( el, rect, opacity );
break;
case SuiElementType.ItemIcon:
PaintPanelLike( el, rect, opacity );
PaintItemIcon( el, rect, opacity );
break;
case SuiElementType.Tooltip:
// Runtime-only. Skip rendering.
break;
}
PaintChildren( el );
}
private void PaintChildren( SuiElement el )
{
// Render in ZIndex order (ascending) so high-Z elements end up on top.
// Stable on hierarchy order — elements with equal ZIndex keep authoring sequence.
var ordered = SuiLayoutSolver.GetRenderOrderedChildren( el, _solver.ById );
foreach ( var child in ordered )
PaintElement( child );
}
// ─────────────────────────────────────────────────────────────────────
// Element renderers
// ─────────────────────────────────────────────────────────────────────
private void PaintCanvasRoot( SuiElement el, Rect rect )
{
// Faint outline so the user sees where the document edges are even
// when nothing else is rendered. Not a real visual element.
Editor.Paint.SetPen( Color.White.WithAlpha( 0.08f ), 1f );
Editor.Paint.ClearBrush();
Editor.Paint.DrawRect( rect );
// If the canvas has its own background-color (Panel-like behavior), honor it.
var bg = ParseColor( el.Style?.BackgroundColor );
if ( bg.HasValue && bg.Value.a > 0 )
{
Editor.Paint.SetBrush( bg.Value );
Editor.Paint.ClearPen();
Editor.Paint.DrawRect( rect );
}
}
private void PaintPanelLike( SuiElement el, Rect rect, float opacity )
{
var s = el.Style;
if ( s == null ) return;
var bg = ParseColor( s.BackgroundColor );
var border = ParseColor( s.BorderColor );
var hasBg = bg.HasValue && bg.Value.a > 0;
var hasBorder = border.HasValue && border.Value.a > 0 && s.BorderWidth > 0;
if ( !hasBg && !hasBorder ) return;
var radius = MathF.Max( 0, s.BorderRadius );
if ( hasBg )
{
Editor.Paint.SetBrush( bg.Value.WithAlpha( bg.Value.a * opacity ) );
Editor.Paint.ClearPen();
DrawRect( rect, radius );
}
if ( hasBorder )
{
Editor.Paint.SetPen( border.Value.WithAlpha( border.Value.a * opacity ), s.BorderWidth );
Editor.Paint.ClearBrush();
DrawRect( rect, radius );
}
}
private static void DrawRect( Rect rect, float radius )
{
if ( radius > 0 ) Editor.Paint.DrawRect( rect, radius );
else Editor.Paint.DrawRect( rect );
}
private void PaintText( SuiElement el, Rect rect, float opacity )
{
var p = el.Props;
if ( p == null || string.IsNullOrEmpty( p.Text ) ) return;
var color = ParseColor( p.Color ) ?? Color.White;
var weight = MapFontWeight( p.FontWeight );
var fontName = string.IsNullOrEmpty( p.FontFamily ) ? Theme.DefaultFont : p.FontFamily;
Editor.Paint.SetFont( fontName, p.FontSize, weight );
Editor.Paint.SetPen( color.WithAlpha( color.a * opacity ) );
Editor.Paint.ClearBrush();
// 2D alignment — horizontal from TextAlign, vertical from VerticalAlign
// (only meaningful when TextSizeMode is Fixed or AutoHeightWrap).
// In Auto mode the rect IS the text size, so all flags collapse to LeftTop.
var flag = ResolveTextFlag( p );
var displayText = p.Text;
if ( p.TextOverflow == SuiTextOverflow.Ellipsis )
displayText = Editor.Paint.GetElidedText( p.Text, rect.Width, ElideMode.Right, flag );
Editor.Paint.DrawText( rect, displayText, flag );
}
private static TextFlag ResolveTextFlag( SuiElementProps p )
{
// Auto mode: rect == text size → align top-left so text fills the box.
if ( p.TextSizeMode == SuiTextSizeMode.Auto ) return TextFlag.LeftTop;
// AutoHeightWrap: width fixed, height auto → top is the natural anchor.
if ( p.TextSizeMode == SuiTextSizeMode.AutoHeightWrap )
{
return p.TextAlign switch
{
SuiTextAlign.Center => TextFlag.CenterTop,
SuiTextAlign.Right => TextFlag.RightTop,
_ => TextFlag.LeftTop,
};
}
// Fixed: full 2D matrix from TextAlign × VerticalAlign.
return (p.TextAlign, p.VerticalAlign) switch
{
(SuiTextAlign.Left, SuiVerticalAlign.Top) => TextFlag.LeftTop,
(SuiTextAlign.Left, SuiVerticalAlign.Center) => TextFlag.LeftCenter,
(SuiTextAlign.Left, SuiVerticalAlign.Bottom) => TextFlag.LeftBottom,
(SuiTextAlign.Center, SuiVerticalAlign.Top) => TextFlag.CenterTop,
(SuiTextAlign.Center, SuiVerticalAlign.Center) => TextFlag.Center,
(SuiTextAlign.Center, SuiVerticalAlign.Bottom) => TextFlag.CenterBottom,
(SuiTextAlign.Right, SuiVerticalAlign.Top) => TextFlag.RightTop,
(SuiTextAlign.Right, SuiVerticalAlign.Center) => TextFlag.RightCenter,
(SuiTextAlign.Right, SuiVerticalAlign.Bottom) => TextFlag.RightBottom,
(SuiTextAlign.Justify, _) => TextFlag.LeftCenter, // Paint API has no justify
_ => TextFlag.LeftTop,
};
}
private void PaintImage( SuiElement el, Rect rect, float opacity )
{
var p = el.Props;
if ( p == null || string.IsNullOrEmpty( p.ImagePath ) ) return;
var abs = ResolveProjectPath( p.ImagePath );
if ( string.IsNullOrEmpty( abs ) ) return;
// Load at NATIVE resolution. Pre-resizing via LoadImage(path, w, h) does a
// CPU-side downsample whose quality (especially for big shrink ratios like
// 512→120) loses sharpness compared to the runtime GPU sampler. By keeping
// the pixmap native-size and letting Paint.Scale handle the fit, the GPU's
// bilinear filter does the resample — same path the runtime preview uses.
Pixmap nativePixmap;
try { nativePixmap = Editor.Paint.LoadImage( abs ); }
catch { return; }
if ( nativePixmap == null || nativePixmap.Width <= 1 || nativePixmap.Height <= 1 ) return;
var fitRect = ApplyFitMode( rect, new Vector2( nativePixmap.Width, nativePixmap.Height ), p.FitMode, p.BackgroundPosition );
if ( fitRect.Width < 1 || fitRect.Height < 1 ) return;
// Honor border-radius so the canvas clips the image the same way SCSS does
// at runtime (s&box's Panel applies border-radius as an alpha mask).
var borderRadius = MathF.Max( 0, el.Style?.BorderRadius ?? 0 );
DrawPixmapInRect( nativePixmap, abs, fitRect, opacity, borderRadius );
// Tint approximation: overlay a colored rect when tint != white.
// Runtime CSS background-image-tint uses a shader multiply; this is
// visually close enough for strong tints.
var tint = ParseColor( p.Tint );
if ( tint.HasValue && (tint.Value.r < 0.99f || tint.Value.g < 0.99f || tint.Value.b < 0.99f) )
{
var tintColor = tint.Value.WithAlpha( opacity * 0.5f );
Editor.Paint.SetBrush( tintColor );
Editor.Paint.ClearPen();
Editor.Paint.DrawRect( fitRect );
}
}
/// <summary>
/// Draws a pixmap into <paramref name="targetRect"/> using
/// <c>Editor.Paint.Draw(Rect, Pixmap, float alpha, float borderRadius)</c>
/// — the same Qt-backed GPU path Facepunch's editor uses internally. Qt's
/// <c>drawPixmap</c> stretches source→target with bilinear smoothing in one
/// pass, no tiling/brush dance.
///
/// We still pre-resize for heavy downsamples. Qt's drawPixmap is bilinear
/// without mipmaps, so when source is much larger than display
/// (e.g. native 512px → 60px on screen at zoom 0.5×), one sample per
/// output pixel misses detail = aliasing. We pre-resize via
/// <c>LoadImage(path, w, h)</c> at 2× display to give the GPU a
/// well-conditioned source for its final bilinear sample.
/// </summary>
private void DrawPixmapInRect( Pixmap pixmap, string absPath, Rect targetRect, float alpha = 1.0f, float borderRadius = 0f )
{
if ( pixmap == null || pixmap.Width < 1 || pixmap.Height < 1 ) return;
if ( targetRect.Width < 1 || targetRect.Height < 1 ) return;
// Heavy-downsample guard.
var displayW = MathF.Max( 1, targetRect.Width * Zoom );
var displayH = MathF.Max( 1, targetRect.Height * Zoom );
var oversampleW = (int)MathF.Round( displayW * 2f );
var oversampleH = (int)MathF.Round( displayH * 2f );
var toUse = pixmap;
if ( oversampleW >= 1 && oversampleH >= 1
&& (pixmap.Width > oversampleW * 2 || pixmap.Height > oversampleH * 2)
&& !string.IsNullOrEmpty( absPath ) )
{
try { toUse = Editor.Paint.LoadImage( absPath, oversampleW, oversampleH ); }
catch { toUse = pixmap; }
if ( toUse == null || toUse.Width < 1 || toUse.Height < 1 ) toUse = pixmap;
}
// Editor.Paint.Draw( Rect, Pixmap, alpha, borderRadius ) — Qt's drawPixmap
// with rounded clip mask. See reference_sbox_paint_image_api memory note.
Editor.Paint.Draw( targetRect, toUse, alpha, borderRadius );
}
private void PaintButtonLabel( SuiElement el, Rect rect, float opacity )
{
var p = el.Props;
if ( p == null || string.IsNullOrEmpty( p.ButtonText ) ) return;
var color = ParseColor( p.Color ) ?? Color.White;
var fontName = string.IsNullOrEmpty( p.FontFamily ) ? Theme.DefaultFont : p.FontFamily;
Editor.Paint.SetFont( fontName, p.FontSize > 0 ? p.FontSize : 14, MapFontWeight( p.FontWeight ) );
Editor.Paint.SetPen( color.WithAlpha( color.a * opacity ) );
Editor.Paint.ClearBrush();
Editor.Paint.DrawText( rect, p.ButtonText, TextFlag.Center );
}
private void PaintProgressFill( SuiElement el, Rect rect, float opacity )
{
var p = el.Props;
if ( p == null ) return;
var range = p.ProgressMax - p.ProgressMin;
if ( range <= 0 ) return;
var t = MathF.Max( 0, MathF.Min( 1, (p.ProgressPreviewValue - p.ProgressMin) / range ) );
var fillColor = ParseColor( p.ProgressFillColor ) ?? new Color( 0.29f, 0.87f, 0.5f );
var fillRect = new Rect( rect.Left, rect.Top, rect.Width * t, rect.Height );
Editor.Paint.SetBrush( fillColor.WithAlpha( fillColor.a * opacity ) );
Editor.Paint.ClearPen();
var radius = MathF.Max( 0, el.Style?.BorderRadius ?? 0 );
DrawRect( fillRect, radius );
}
private void PaintItemIcon( SuiElement el, Rect rect, float opacity )
{
var p = el.Props;
if ( p == null || string.IsNullOrEmpty( p.PreviewIconPath ) ) return;
var abs = ResolveProjectPath( p.PreviewIconPath );
if ( string.IsNullOrEmpty( abs ) ) return;
Pixmap nativePixmap;
try { nativePixmap = Editor.Paint.LoadImage( abs ); }
catch { return; }
if ( nativePixmap == null || nativePixmap.Width <= 1 || nativePixmap.Height <= 1 ) return;
var fitRect = ApplyFitMode( rect, new Vector2( nativePixmap.Width, nativePixmap.Height ),
SuiImageFitMode.Contain, SuiBackgroundPosition.Center );
if ( fitRect.Width < 1 || fitRect.Height < 1 ) return;
var iconRadius = MathF.Max( 0, el.Style?.BorderRadius ?? 0 );
DrawPixmapInRect( nativePixmap, abs, fitRect, opacity, iconRadius );
// Stack count overlay
if ( p.PreviewCount > 1 )
{
Editor.Paint.SetFont( Theme.DefaultFont, 11, 700 );
Editor.Paint.SetPen( Color.White );
Editor.Paint.ClearBrush();
Editor.Paint.DrawText( rect.Shrink( 4 ), p.PreviewCount.ToString(), TextFlag.RightBottom );
}
}
// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────
private float ResolveOpacity( SuiElement el )
{
var s = el.Style;
if ( s == null ) return 1f;
var op = s.Opacity;
if ( s.Visibility == SuiVisibility.Hidden ) op = 0f;
// Walk up — element opacity multiplies through ancestors. We don't
// recurse here because the caller renders top-down; ancestor's opacity
// is already baked into Paint.SetBrush at the parent's draw step. For
// simplicity we use just the element's own opacity for now (matches
// CSS opacity per element semantics).
return MathF.Max( 0, MathF.Min( 1, op ) );
}
private static Color? ParseColor( string raw )
{
if ( string.IsNullOrEmpty( raw ) ) return null;
var s = raw.Trim();
// rgba(r,g,b,a) / rgb(r,g,b) — same format the runtime Sandbox.UI
// parser accepts (see ui-razor reference). Lenient on spacing. Without
// this branch, canvas falls back to "no bg painted" for any rgba value
// while the runtime renders it correctly, causing a canvas/preview gap.
if ( s.StartsWith( "rgb", StringComparison.OrdinalIgnoreCase ) )
{
var open = s.IndexOf( '(' );
var close = s.IndexOf( ')', open + 1 );
if ( open > 0 && close > open )
{
var inside = s.Substring( open + 1, close - open - 1 );
var parts = inside.Split( ',' );
if ( parts.Length >= 3 )
{
try
{
int r = int.Parse( parts[0].Trim(), System.Globalization.CultureInfo.InvariantCulture );
int g = int.Parse( parts[1].Trim(), System.Globalization.CultureInfo.InvariantCulture );
int b = int.Parse( parts[2].Trim(), System.Globalization.CultureInfo.InvariantCulture );
float a = 1f;
if ( parts.Length >= 4 )
a = float.Parse( parts[3].Trim(), System.Globalization.CultureInfo.InvariantCulture );
r = System.Math.Clamp( r, 0, 255 );
g = System.Math.Clamp( g, 0, 255 );
b = System.Math.Clamp( b, 0, 255 );
a = System.Math.Clamp( a, 0f, 1f );
return new Color( r / 255f, g / 255f, b / 255f, a );
}
catch { return null; }
}
}
return null;
}
// #hex (3/6/8 digits).
var hex = s.StartsWith( "#" ) ? s.Substring( 1 ) : s;
try
{
byte r, g, b, ab = 255;
if ( hex.Length == 6 )
{
r = (byte)Convert.ToInt32( hex.Substring( 0, 2 ), 16 );
g = (byte)Convert.ToInt32( hex.Substring( 2, 2 ), 16 );
b = (byte)Convert.ToInt32( hex.Substring( 4, 2 ), 16 );
}
else if ( hex.Length == 8 )
{
r = (byte)Convert.ToInt32( hex.Substring( 0, 2 ), 16 );
g = (byte)Convert.ToInt32( hex.Substring( 2, 2 ), 16 );
b = (byte)Convert.ToInt32( hex.Substring( 4, 2 ), 16 );
ab = (byte)Convert.ToInt32( hex.Substring( 6, 2 ), 16 );
}
else if ( hex.Length == 3 )
{
r = (byte)(Convert.ToInt32( hex.Substring( 0, 1 ), 16 ) * 17);
g = (byte)(Convert.ToInt32( hex.Substring( 1, 1 ), 16 ) * 17);
b = (byte)(Convert.ToInt32( hex.Substring( 2, 1 ), 16 ) * 17);
}
else return null;
return new Color( r / 255f, g / 255f, b / 255f, ab / 255f );
}
catch
{
return null;
}
}
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,
};
private static TextFlag MapTextAlign( SuiTextAlign a ) => a switch
{
SuiTextAlign.Left => TextFlag.LeftCenter,
SuiTextAlign.Center => TextFlag.Center,
SuiTextAlign.Right => TextFlag.RightCenter,
SuiTextAlign.Justify => TextFlag.LeftCenter, // Paint API has no justify; fall back to left
_ => TextFlag.LeftCenter,
};
private string ResolveProjectPath( string projectRelativePath )
{
if ( string.IsNullOrEmpty( projectRelativePath ) ) return null;
if ( string.IsNullOrEmpty( _projectAssetsRoot ) ) return null;
var rel = projectRelativePath.Replace( '\\', '/' ).TrimStart( '/' );
return System.IO.Path.Combine( _projectAssetsRoot, rel ).Replace( '\\', '/' );
}
/// <summary>
/// Compute the rect into which an image should be drawn based on its native
/// pixel size and the FitMode/BackgroundPosition. Mirrors how CSS
/// background-size + background-position would lay out the same image.
/// </summary>
private static Rect ApplyFitMode( Rect container, Vector2 imageSize, SuiImageFitMode mode, SuiBackgroundPosition pos )
{
if ( mode == SuiImageFitMode.Stretch || mode == SuiImageFitMode.None )
return container;
var imageAspect = imageSize.x / imageSize.y;
var containerAspect = container.Width / container.Height;
float w, h;
if ( mode == SuiImageFitMode.Contain )
{
if ( imageAspect > containerAspect ) { w = container.Width; h = w / imageAspect; }
else { h = container.Height; w = h * imageAspect; }
}
else // Cover
{
if ( imageAspect > containerAspect ) { h = container.Height; w = h * imageAspect; }
else { w = container.Width; h = w / imageAspect; }
}
// Position the fitted image within the container.
float x, y;
switch ( pos )
{
case SuiBackgroundPosition.TopLeft: x = container.Left; y = container.Top; break;
case SuiBackgroundPosition.Top: x = container.Left + (container.Width - w) * 0.5f; y = container.Top; break;
case SuiBackgroundPosition.TopRight: x = container.Right - w; y = container.Top; break;
case SuiBackgroundPosition.Left: x = container.Left; y = container.Top + (container.Height - h) * 0.5f; break;
case SuiBackgroundPosition.Right: x = container.Right - w; y = container.Top + (container.Height - h) * 0.5f; break;
case SuiBackgroundPosition.BottomLeft: x = container.Left; y = container.Bottom - h; break;
case SuiBackgroundPosition.Bottom: x = container.Left + (container.Width - w) * 0.5f; y = container.Bottom - h; break;
case SuiBackgroundPosition.BottomRight: x = container.Right - w; y = container.Bottom - h; break;
case SuiBackgroundPosition.Center:
default: x = container.Left + (container.Width - w) * 0.5f; y = container.Top + (container.Height - h) * 0.5f; break;
}
return new Rect( x, y, w, h );
}
}