Editor/Widgets/SuiTopBar.cs
using System;
using Editor;
using Sandbox;
namespace SboxUiDesigner.EditorUi.Widgets;
/// <summary>
/// Top toolbar — built from scratch as a pure <see cref="Widget"/> (no
/// <c>Editor.ToolBar</c> chrome, no <c>Editor.Button</c>, no <c>TabWidget</c>).
/// Background, buttons, dropdowns are all custom-painted and measure their
/// own text via <c>Paint.MeasureText</c> on first paint so labels never clip.
/// </summary>
public sealed class SuiTopBar : Widget
{
public SuiTopBar( Widget parent = null ) : base( parent )
{
FixedHeight = 52;
SetStyles( "background-color: rgb(30,30,29);" ); // #1e1e1d — hsl(60,2%,12%)
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 14, 8, 14, 8 );
Layout.Spacing = 4;
}
public SuiTopBarButton AddButton( string label, string icon, Action onClick, bool hasChevron = false, string tooltip = null )
{
var btn = new SuiTopBarButton( label, icon, hasChevron );
btn.ToolTip = tooltip ?? label;
btn.Clicked += onClick;
Layout.Add( btn );
return btn;
}
public SuiTopBarDropdown AddDropdown( string icon, string label, string value, Action<Widget> onOpen, string tooltip = null )
{
var dd = new SuiTopBarDropdown( icon, label, value );
dd.ToolTip = tooltip;
dd.Clicked += () => onOpen?.Invoke( dd );
Layout.Add( dd );
return dd;
}
public void AddGap( int px = 16 )
{
var spacer = new Widget( this );
spacer.FixedWidth = px;
spacer.SetStyles( "background-color: transparent;" );
Layout.Add( spacer );
}
/// <summary>Subtle vertical 1px line between toolbar groups (mockup spec).</summary>
public void AddSeparator()
{
var sep = new SuiTopBarSeparator();
Layout.Add( sep );
}
public void AddStretch() => Layout.AddStretchCell();
protected override void OnPaint()
{
// Solid dark background — #1e1e1d / hsl(60,2%,12%) — + subtle bottom border.
Paint.SetBrushAndPen( new Color( 30 / 255f, 30 / 255f, 29 / 255f ) );
Paint.DrawRect( LocalRect );
Paint.SetPen( Color.White.WithAlpha( 0.06f ) );
Paint.DrawLine( new Vector2( 0, Height - 1 ), new Vector2( Width, Height - 1 ) );
}
}
/// <summary>
/// Top-bar button — icon + label, optional chevron, hover/press/active states.
/// Width is calibrated on first paint via <c>Paint.MeasureText</c> so the
/// label never clips regardless of font metrics or DPI scaling.
/// </summary>
public sealed class SuiTopBarButton : Widget
{
public string Label { get; set; }
public string Icon { get; set; }
public bool HasChevron { get; set; }
public bool IsActive { get; set; }
public event Action Clicked;
private bool _hover;
private bool _pressed;
private bool _sized;
private const int FontSize = 12;
private const int PadL = 14;
private const int PadR = 14;
private const int IconSize = 16;
private const int IconLabelGap = 8;
private const int ChevronW = 14;
// Icon-only mode (label is empty): square, larger icon for tap target.
private const int IconOnlySize = 38;
private const int IconOnlyIconPx = 18;
public bool IsIconOnly => string.IsNullOrEmpty( Label );
public SuiTopBarButton( string label, string icon, bool hasChevron, Widget parent = null ) : base( parent )
{
Label = label ?? "";
Icon = icon;
HasChevron = hasChevron;
Cursor = CursorShape.Finger;
// Kill the default Qt widget background so the parent toolbar's color
// shows through wherever we don't paint our own tint.
SetStyles( "background-color: transparent;" );
if ( IsIconOnly )
{
FixedHeight = IconOnlySize;
FixedWidth = IconOnlySize;
}
else
{
FixedHeight = 36;
// Conservative initial width — replaced on first paint with real measurement.
var iconBlock = string.IsNullOrEmpty( icon ) ? 0 : IconSize + IconLabelGap;
var chev = hasChevron ? ChevronW : 0;
FixedWidth = PadL + iconBlock + (Label.Length * 12) + chev + PadR;
}
}
protected override void OnPaint()
{
Paint.SetDefaultFont( FontSize );
// Icon-only buttons are square — no text measurement needed.
if ( !IsIconOnly && !_sized )
{
var textW = string.IsNullOrEmpty( Label ) ? 0 : Paint.MeasureText( Label ).x;
var iconBlock = string.IsNullOrEmpty( Icon ) ? 0 : IconSize + IconLabelGap;
var chev = HasChevron ? ChevronW : 0;
int newW = (int)(PadL + iconBlock + textW + 2 + chev + PadR);
_sized = true;
if ( newW != FixedWidth )
{
FixedWidth = newW;
return; // re-layout; engine repaints again
}
}
var rect = LocalRect;
// Background tint.
if ( IsActive )
{
Paint.SetBrushAndPen( Color.White.WithAlpha( 0.10f ) );
Paint.DrawRect( rect, 4f );
}
else if ( _pressed )
{
Paint.SetBrushAndPen( Color.White.WithAlpha( 0.14f ) );
Paint.DrawRect( rect, 4f );
}
else if ( _hover )
{
Paint.SetBrushAndPen( Color.White.WithAlpha( 0.06f ) );
Paint.DrawRect( rect, 4f );
}
var textColor = new Color( 232 / 255f, 235 / 255f, 238 / 255f );
Paint.SetPen( textColor );
// Icon-only: center the icon in the square.
if ( IsIconOnly )
{
if ( !string.IsNullOrEmpty( Icon ) )
{
var iconRect = new Rect( (Width - IconOnlyIconPx) / 2f, (Height - IconOnlyIconPx) / 2f, IconOnlyIconPx, IconOnlyIconPx );
Paint.DrawIcon( iconRect, Icon, IconOnlyIconPx );
}
return;
}
// Normal: icon + label + optional chevron.
float x = PadL;
if ( !string.IsNullOrEmpty( Icon ) )
{
var iconRect = new Rect( x, (Height - IconSize) / 2f, IconSize, IconSize );
Paint.DrawIcon( iconRect, Icon, IconSize );
x += IconSize + IconLabelGap;
}
float labelW = 0;
if ( !string.IsNullOrEmpty( Label ) )
{
labelW = Paint.MeasureText( Label ).x;
var labelRect = new Rect( x, 0, labelW + 2, Height );
Paint.DrawText( labelRect, Label, TextFlag.LeftCenter );
x += labelW + 2;
}
if ( HasChevron )
{
Paint.SetPen( new Color( 165 / 255f, 172 / 255f, 182 / 255f ), 1.5f );
var cx = Width - PadR - 2f;
var cy = Height / 2f + 1f;
Paint.DrawLine( new Vector2( cx - 4, cy - 2 ), new Vector2( cx, cy + 2 ) );
Paint.DrawLine( new Vector2( cx, cy + 2 ), new Vector2( cx + 4, cy - 2 ) );
}
}
protected override void OnMouseEnter() { _hover = true; Update(); }
protected override void OnMouseLeave() { _hover = false; _pressed = false; Update(); }
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) { _pressed = true; Update(); }
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( _pressed && e.LeftMouseButton )
{
_pressed = false;
bool shouldFire = _hover;
// Callback may rebuild parent and destroy this. Don't touch Qt
// state afterwards unless we still exist.
if ( shouldFire ) Clicked?.Invoke();
if ( IsValid ) Update();
}
}
}
/// <summary>
/// Pill dropdown — icon + muted label + bright value + chevron. Click opens
/// a menu (host is responsible for that). Width measured on first paint.
/// </summary>
public sealed class SuiTopBarDropdown : Widget
{
public string Icon { get; set; }
private string _label;
private string _value;
public string Label
{
get => _label;
set { _label = value ?? ""; _sized = false; Update(); }
}
public string Value
{
get => _value;
set { _value = value ?? ""; _sized = false; Update(); }
}
public event Action Clicked;
private bool _hover;
private bool _sized;
private const int FontSize = 12;
private const int PadL = 6; // Outer left padding (whole widget)
private const int PadR = 6; // Outer right padding
private const int IconSize = 14;
private const int IconGap = 8; // Gap icon → label
private const int LabelToPillGap = 8;// Gap between flat label and value-pill
private const int PillPadL = 10; // Inner pill left padding (around value)
private const int PillPadR = 10; // Inner pill right padding (around chevron)
private const int ValueChevronGap = 6;
private const int ChevronW = 8;
private const int PillHeight = 26;
public SuiTopBarDropdown( string icon, string label, string value, Widget parent = null ) : base( parent )
{
Icon = icon;
_label = label ?? "";
_value = value ?? "";
Cursor = CursorShape.Finger;
FixedHeight = 36;
SetStyles( "background-color: transparent;" );
// Conservative initial width — gets calibrated on first paint.
var iconBlock = string.IsNullOrEmpty( icon ) ? 0 : IconSize + IconGap;
var labelBlock = _label.Length > 0 ? (_label.Length * 12) + LabelToPillGap : 0;
var valueBlock = _value.Length * 12;
FixedWidth = PadL + iconBlock + labelBlock + PillPadL + valueBlock + ValueChevronGap + ChevronW + PillPadR + PadR;
}
protected override void OnPaint()
{
Paint.SetDefaultFont( FontSize );
// First-paint calibration with real text widths.
if ( !_sized )
{
var labelW = string.IsNullOrEmpty( _label ) ? 0 : Paint.MeasureText( _label ).x;
var valueW = string.IsNullOrEmpty( _value ) ? 0 : Paint.MeasureText( _value ).x;
var iconBlock = string.IsNullOrEmpty( Icon ) ? 0 : IconSize + IconGap;
var labelBlock = labelW > 0 ? (int)labelW + LabelToPillGap : 0;
int newW = PadL + iconBlock + labelBlock + PillPadL + (int)valueW + 2 + ValueChevronGap + ChevronW + PillPadR + PadR;
_sized = true;
if ( newW != FixedWidth )
{
FixedWidth = newW;
return;
}
}
// 1. ICON + LABEL — flat, no background.
float x = PadL;
if ( !string.IsNullOrEmpty( Icon ) )
{
Paint.SetPen( new Color( 195 / 255f, 200 / 255f, 208 / 255f ) );
var ir = new Rect( x, (Height - IconSize) / 2f, IconSize, IconSize );
Paint.DrawIcon( ir, Icon, IconSize );
x += IconSize + IconGap;
}
float labelW2 = 0;
if ( !string.IsNullOrEmpty( _label ) )
{
Paint.SetPen( new Color( 220 / 255f, 224 / 255f, 230 / 255f ) );
labelW2 = Paint.MeasureText( _label ).x;
var lr = new Rect( x, 0, labelW2 + 2, Height );
Paint.DrawText( lr, _label, TextFlag.LeftCenter );
x += labelW2 + LabelToPillGap;
}
// 2. VALUE PILL — only around the value + chevron.
var pillStart = x;
var pillEnd = Width - PadR;
var pillRect = new Rect( pillStart, (Height - PillHeight) / 2f, pillEnd - pillStart, PillHeight );
// Pill harmonized with toolbar HSL(60,2%,*): bg=hsl(60,2%,15%) / hover=hsl(60,2%,18%).
var pillBg = _hover
? new Color( 46 / 255f, 46 / 255f, 45 / 255f ) // #2e2e2d
: new Color( 38 / 255f, 38 / 255f, 37 / 255f ); // #262625
Paint.SetBrush( pillBg );
Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( pillRect, 4f );
// 3. VALUE inside pill.
float vx = pillStart + PillPadL;
if ( !string.IsNullOrEmpty( _value ) )
{
Paint.SetPen( new Color( 232 / 255f, 235 / 255f, 238 / 255f ) );
var vw = Paint.MeasureText( _value ).x;
var vr = new Rect( vx, pillRect.Top, vw + 2, pillRect.Height );
Paint.DrawText( vr, _value, TextFlag.LeftCenter );
}
// 4. CHEVRON at right of pill.
Paint.SetPen( new Color( 165 / 255f, 172 / 255f, 182 / 255f ), 1.5f );
var cx = pillEnd - PillPadR;
var cy = pillRect.Top + pillRect.Height / 2f + 1f;
Paint.DrawLine( new Vector2( cx - 4, cy - 2 ), new Vector2( cx, cy + 2 ) );
Paint.DrawLine( new Vector2( cx, cy + 2 ), new Vector2( cx + 4, cy - 2 ) );
}
protected override void OnMouseEnter() { _hover = true; Update(); }
protected override void OnMouseLeave() { _hover = false; Update(); }
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) Clicked?.Invoke();
}
}
/// <summary>
/// Subtle vertical 1px line that visually separates groups in the top bar.
/// Centered vertically, doesn't span full toolbar height.
/// </summary>
public sealed class SuiTopBarSeparator : Widget
{
public SuiTopBarSeparator( Widget parent = null ) : base( parent )
{
FixedWidth = 13; // 1px line + 6px padding each side
FixedHeight = 36;
SetStyles( "background-color: transparent;" );
}
protected override void OnPaint()
{
// 1px vertical line, 60% of widget height, centered.
float lineHeight = Height * 0.55f;
float yTop = (Height - lineHeight) / 2f;
float xCenter = Width / 2f;
Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
Paint.DrawLine( new Vector2( xCenter, yTop ), new Vector2( xCenter, yTop + lineHeight ) );
}
}