Editor/Widgets/SuiPaletteWidget.cs
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi.Widgets;
/// <summary>
/// Palette panel — list of element types the user can add to the document
/// (click to add, drag to drop on a container in the canvas).
///
/// 100% custom paint except for the search <see cref="LineEdit"/>, which is
/// the native Editor input (text editing / cursor / IME would be a huge
/// rewrite for marginal visual gain — only its background is restyled).
///
/// Layout:
/// Search input (#141413 fill)
/// Category COMMON — collapsible header + flat items
/// ─── separator ───
/// Category LAYOUT
/// ─── separator ───
/// Category GAME UI (V1)
///
/// Items are flat: icon + label, no background, hover tint only.
/// </summary>
public class SuiPaletteWidget : Widget
{
private LineEdit _search;
private string _filter = "";
private readonly List<SuiPaletteCategory> _categories = new();
/// <summary>Raised when the user clicks a palette item.</summary>
public event Action<SuiElementType> ElementRequested;
public SuiPaletteWidget( Widget parent = null ) : base( parent )
{
WindowTitle = "Palette";
Name = "SuiPalette";
MinimumSize = new Vector2( 200, 200 );
SetStyles( "background-color: transparent; border: none;" );
Layout = Layout.Column();
Layout.Margin = new Sandbox.UI.Margin( 8, 8, 8, 8 );
Layout.Spacing = 0;
// Search input — native LineEdit with custom styling.
_search = new LineEdit( this );
_search.PlaceholderText = "Search Palette";
_search.FixedHeight = 28;
_search.SetStyles(
"background-color: rgb(20,20,19);" +
"border: 1px solid rgba(255,255,255,0.06);" +
"border-radius: 3px;" +
"color: rgb(220,224,230);" +
"padding: 0 8px;" +
"font-size: 11px;" );
_search.TextEdited += s =>
{
_filter = (s ?? "").Trim().ToLowerInvariant();
ApplyFilter();
};
Layout.Add( _search );
// Scrollable content area below the (fixed) search input.
var scroll = new ScrollArea( this );
SuiScrollStyle.ApplyTo( scroll );
_scrollContent = new Widget( scroll );
_scrollContent.SetStyles( "background-color: transparent; border: none;" );
_scrollContent.Layout = Layout.Column();
_scrollContent.Layout.Margin = 0;
_scrollContent.Layout.Spacing = 0;
scroll.Canvas = _scrollContent;
Layout.Add( scroll, 1 );
// 8px gap between search and first category header.
AddSpacer( 8 );
AddCategory( "COMMON", new[]
{
SuiElementType.Panel,
SuiElementType.Text,
SuiElementType.Image,
SuiElementType.Button,
}, separatorBefore: false );
AddCategory( "LAYOUT", new[]
{
SuiElementType.HorizontalBox,
SuiElementType.VerticalBox,
SuiElementType.Grid,
SuiElementType.Overlay,
}, separatorBefore: true );
AddCategory( "GAME UI (V1)", new[]
{
SuiElementType.ProgressBar,
SuiElementType.ScrollPanel,
SuiElementType.InventoryGrid,
SuiElementType.InventorySlot,
SuiElementType.ItemIcon,
SuiElementType.Tooltip,
SuiElementType.Hotbar,
}, separatorBefore: true );
_scrollContent.Layout.AddStretchCell();
}
private Widget _scrollContent;
private void AddCategory( string title, SuiElementType[] types, bool separatorBefore )
{
if ( separatorBefore )
{
var sep = new SuiPaletteCategorySeparator( _scrollContent );
_scrollContent.Layout.Add( sep );
}
var cat = new SuiPaletteCategory( title, types, _scrollContent );
cat.ElementClicked += t => ElementRequested?.Invoke( t );
_categories.Add( cat );
_scrollContent.Layout.Add( cat );
}
private void AddSpacer( int px )
{
var spc = new Widget( _scrollContent );
spc.FixedHeight = px;
spc.SetStyles( "background-color: transparent; border: none;" );
_scrollContent.Layout.Add( spc );
}
private void ApplyFilter()
{
foreach ( var cat in _categories )
cat.ApplyFilter( _filter );
}
internal static string GetIconFor( SuiElementType type ) => IconFor( type );
internal static string IconFor( SuiElementType type ) => type switch
{
SuiElementType.Canvas => "crop_free",
SuiElementType.Panel => "crop_square",
SuiElementType.Overlay => "layers",
SuiElementType.Text => "title",
SuiElementType.Image => "image",
SuiElementType.Button => "smart_button",
SuiElementType.HorizontalBox => "view_week",
SuiElementType.VerticalBox => "view_agenda",
SuiElementType.Grid => "grid_on",
SuiElementType.ScrollPanel => "swap_vert",
SuiElementType.ProgressBar => "linear_scale",
SuiElementType.InventoryGrid => "grid_view",
SuiElementType.InventorySlot => "check_box_outline_blank",
SuiElementType.ItemIcon => "category",
SuiElementType.Tooltip => "info",
SuiElementType.Hotbar => "view_carousel",
_ => "extension",
};
/// <summary>
/// Per-type tooltip describing what the element does and when to use it.
/// Shown on hover over a palette item.
/// </summary>
internal static string TooltipFor( SuiElementType type ) => type switch
{
SuiElementType.Panel =>
"Panel — generic container with background + padding.\nUse to group children, add a frame, or apply a single background.",
SuiElementType.Text =>
"Text — static label.\nUse for titles, descriptions, status readouts (HP value, score).",
SuiElementType.Image =>
"Image — static picture from your project assets.\nUse for icons, backgrounds, decorative art. PNG/JPG/SVG.",
SuiElementType.Button =>
"Button — clickable element with hover/press states.\nUse for actions (Save, Cancel, Submit, Open Menu).",
SuiElementType.HorizontalBox =>
"HorizontalBox — arranges children left-to-right in a row.\nUse for toolbars, button rows, status lines.",
SuiElementType.VerticalBox =>
"VerticalBox — arranges children top-to-bottom in a column.\nUse for menus, lists, sidebar sections.",
SuiElementType.Grid =>
"Grid — arranges children in rows × columns.\nUse for inventory grids, calendar layouts, image galleries.",
SuiElementType.Overlay =>
"Overlay — stacks children on top of each other (z-ordered).\nUse for modal dialogs, tooltips, badges over icons.",
SuiElementType.ProgressBar =>
"ProgressBar — filled bar showing a 0..1 value.\nUse for Health, Mana, XP, cooldowns, loading indicators. Bind Value to a property.",
SuiElementType.ScrollPanel =>
"ScrollPanel — container with scroll when content overflows.\nUse for long lists, chat windows, oversized text.",
SuiElementType.InventoryGrid =>
"InventoryGrid — grid of inventory slots.\nUse for backpacks, loot windows, equipment screens. Wire Items to a player inventory.",
SuiElementType.InventorySlot =>
"InventorySlot — single slot accepting an item + count.\nUsually a child of InventoryGrid; can also stand alone (e.g. ammo slot).",
SuiElementType.ItemIcon =>
"ItemIcon — visual representation of an item.\nUsually inside an InventorySlot. Bind to an item resource for sprite + tint.",
SuiElementType.Tooltip =>
"Tooltip — floating description shown on hover.\nAnchor to another element. Use for stat explanations, ability details.",
SuiElementType.Hotbar =>
"Hotbar — horizontal row of slots bound to number keys (1-9, 0).\nUse for action bars: weapons, abilities, consumables.",
_ => $"{type} — drag onto the canvas to drop on a container, or click to add at root.",
};
}
/// <summary>
/// Collapsible category — header with chevron + container with flat items.
/// Click on header toggles expanded state.
/// </summary>
internal sealed class SuiPaletteCategory : Widget
{
public string Title;
private bool _expanded = true;
private SuiPaletteCategoryHeader _header;
private Widget _itemsContainer;
private readonly List<SuiPaletteItem> _items = new();
public event Action<SuiElementType> ElementClicked;
public SuiPaletteCategory( string title, SuiElementType[] types, Widget parent ) : base( parent )
{
Title = title;
SetStyles( "background-color: transparent; border: none;" );
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_header = new SuiPaletteCategoryHeader( title, this );
_header.ToggledExpanded += () =>
{
_expanded = !_expanded;
UpdateExpanded();
};
Layout.Add( _header );
_itemsContainer = new Widget( this );
_itemsContainer.SetStyles( "background-color: transparent; border: none;" );
_itemsContainer.Layout = Layout.Column();
_itemsContainer.Layout.Margin = 0;
_itemsContainer.Layout.Spacing = 0;
foreach ( var type in types )
{
var item = new SuiPaletteItem( type, _itemsContainer );
item.Clicked += t => ElementClicked?.Invoke( t );
_items.Add( item );
_itemsContainer.Layout.Add( item );
}
Layout.Add( _itemsContainer );
UpdateExpanded();
}
private void UpdateExpanded()
{
if ( _itemsContainer.IsValid() ) _itemsContainer.Visible = _expanded;
_header.IsExpanded = _expanded;
_header.Update();
}
public void ApplyFilter( string filter )
{
bool anyVisible = false;
foreach ( var item in _items )
{
var match = string.IsNullOrEmpty( filter ) || item.SearchKey.Contains( filter );
item.Visible = match;
if ( match ) anyVisible = true;
}
Visible = anyVisible;
}
}
/// <summary>
/// Category header — small uppercase label + chevron, no background, hover tint.
/// Click anywhere on it toggles the expanded state of the parent category.
/// </summary>
internal sealed class SuiPaletteCategoryHeader : Widget
{
public string Title;
public bool IsExpanded = true;
public event Action ToggledExpanded;
public SuiPaletteCategoryHeader( string title, Widget parent ) : base( parent )
{
Title = title ?? "";
FixedHeight = 26;
Cursor = CursorShape.Finger;
SetStyles( "background-color: transparent; border: none;" );
}
protected override void OnPaint()
{
// Header is fully flat — no hover background, no border.
// Chevron — ▾ when expanded, ▸ when collapsed.
var chevColor = new Color( 150 / 255f, 156 / 255f, 165 / 255f );
Paint.SetPen( chevColor, 1.5f );
var cy = Height / 2f;
if ( IsExpanded )
{
// ▾ down chevron at x=6
Paint.DrawLine( new Vector2( 6, cy - 2 ), new Vector2( 10, cy + 2 ) );
Paint.DrawLine( new Vector2( 10, cy + 2 ), new Vector2( 14, cy - 2 ) );
}
else
{
// ▸ right chevron
Paint.DrawLine( new Vector2( 7, cy - 4 ), new Vector2( 11, cy ) );
Paint.DrawLine( new Vector2( 11, cy ), new Vector2( 7, cy + 4 ) );
}
// Title — small caps, muted color.
Paint.SetPen( new Color( 150 / 255f, 156 / 255f, 165 / 255f ) );
Paint.SetDefaultFont( 10 );
var titleRect = new Rect( 22, 0, Width - 24, Height );
Paint.DrawText( titleRect, Title, TextFlag.LeftCenter );
}
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) ToggledExpanded?.Invoke();
}
}
/// <summary>
/// Flat palette item — icon + label, no background. Hover/press tints.
/// Click adds the element at root via the parent's <c>ElementRequested</c>.
/// Drag carries the element type for drop-on-container in the canvas.
/// </summary>
internal sealed class SuiPaletteItem : Widget
{
public SuiElementType ElementType { get; }
public string Label { get; }
public string Icon { get; }
public string SearchKey { get; }
public event Action<SuiElementType> Clicked;
private bool _hover;
private bool _pressed;
public SuiPaletteItem( SuiElementType type, Widget parent ) : base( parent )
{
ElementType = type;
Label = type.ToString();
Icon = SuiPaletteWidget.IconFor( type );
SearchKey = Label.ToLowerInvariant();
FixedHeight = 24;
Cursor = CursorShape.Finger;
IsDraggable = true;
SetStyles( "background-color: transparent; border: none;" );
ToolTip = SuiPaletteWidget.TooltipFor( type );
}
protected override void OnPaint()
{
// Icon + label — left-aligned, indented past the chevron column.
// NO background fill, NO border. Hover state is a thin blue underline only.
Paint.SetPen( new Color( 220 / 255f, 224 / 255f, 230 / 255f ) );
Paint.SetDefaultFont( 11 );
float x = 22f;
if ( !string.IsNullOrEmpty( Icon ) )
{
var iconRect = new Rect( x, (Height - 14) / 2f, 14, 14 );
Paint.DrawIcon( iconRect, Icon, 14 );
x += 22f;
}
var labelRect = new Rect( x, 0, Width - x - 4, Height );
Paint.DrawText( labelRect, Label, TextFlag.LeftCenter );
// Hover/press indicator — 2px blue underline at the bottom (#0F3F79).
if ( _hover || _pressed )
{
var alpha = _pressed ? 1.0f : 0.85f;
Paint.SetBrushAndPen( new Color( 15 / 255f, 63 / 255f, 121 / 255f, alpha ) );
Paint.DrawRect( new Rect( 0, Height - 2, Width, 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 widget. Avoid
// touching Qt state afterwards unless we survived.
if ( shouldFire ) Clicked?.Invoke( ElementType );
if ( IsValid ) Update();
}
}
protected override void OnDragStart()
{
var drag = new Drag( this );
drag.Data.Object = ElementType;
drag.Execute();
}
}
/// <summary>
/// 1px horizontal line that visually separates categories in the palette.
/// </summary>
internal sealed class SuiPaletteCategorySeparator : Widget
{
public SuiPaletteCategorySeparator( Widget parent = null ) : base( parent )
{
FixedHeight = 13;
SetStyles( "background-color: transparent; border: none;" );
}
protected override void OnPaint()
{
// 1px horizontal line, full width, centered vertically.
float yCenter = Height / 2f;
Paint.SetPen( Color.White.WithAlpha( 0.06f ) );
Paint.DrawLine( new Vector2( 0, yCenter ), new Vector2( Width, yCenter ) );
}
}