Editor/UI/Widgets.cs

Editor UI widgets for the native-style MCP editor. Defines several lightweight UI components used in the editor: HeaderBar (title/subtitle with a status chip), CategoryChip, FlowRow (wrapping row layout), CodeSnippet (monospace block with copy), GroupBox, Card, and TabButton. Each class handles painting, layout and simple input like clicks.

File Access
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxMcp.Registry;

namespace SboxMcp.UI;

/// <summary>
/// Flat title bar in the native editor style, with a live status chip.
/// </summary>
public class HeaderBar : Widget
{
	public string Title = "s&box MCP";
	public string Subtitle = "Model Context Protocol server";

	public string StatusText = "stopped";
	public Color StatusColor = Palette.Stopped;
	public float Pulse; // 0..1, driven by the dock's frame tick

	public HeaderBar( Widget parent ) : base( parent )
	{
		FixedHeight = 44;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;
		Paint.ClearPen();

		Paint.SetBrush( Palette.CardBackground );
		Paint.DrawRect( LocalRect );

		var textRect = LocalRect.Shrink( 12, 6 );
		Paint.SetPen( Palette.TextBright );
		Paint.SetDefaultFont( 10, 700 );
		Paint.DrawText( textRect, Title, TextFlag.LeftTop );

		Paint.SetPen( Palette.TextDim );
		Paint.SetDefaultFont( 7 );
		Paint.DrawText( textRect, Subtitle, TextFlag.LeftBottom );

		// status chip: tone pill at 18% alpha with a glowing dot, tone text
		Paint.SetDefaultFont( 7, 600 );
		var label = StatusText;
		var width = Paint.MeasureText( label ).x + 30;
		var pill = new Rect( LocalRect.Right - width - 12, LocalRect.Center.y - 10, width, 20 );

		Paint.ClearPen();
		Paint.SetBrush( StatusColor.WithAlpha( 0.18f ) );
		Paint.DrawRect( pill, 10 );

		var dot = new Vector2( pill.Left + 11, pill.Center.y );
		Paint.SetBrush( StatusColor.WithAlpha( 0.3f ) );
		Paint.DrawCircle( dot, 9f + Pulse * 5f );
		Paint.SetBrush( StatusColor );
		Paint.DrawCircle( dot, 7f );

		Paint.SetPen( StatusColor );
		Paint.DrawText( new Rect( pill.Left + 20, pill.Top, pill.Width - 24, pill.Height ), label, TextFlag.LeftCenter );
	}
}

/// <summary>
/// Small rounded chip in a category's color (Tools page + activity feed).
/// </summary>
public class CategoryChip : Widget
{
	public ToolCategory Category;
	public bool Toggled = true;
	public Action OnToggled;
	public bool Clickable;

	public CategoryChip( ToolCategory category, Widget parent, bool clickable = false ) : base( parent )
	{
		Category = category;
		Clickable = clickable;
		FixedHeight = 20;
		FixedWidth = 12 + Category.ToString().Length * 6.5f;
		Cursor = clickable ? CursorShape.Finger : CursorShape.Arrow;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		var color = Palette.For( Category );
		var active = !Clickable || Toggled;

		Paint.ClearPen();
		Paint.SetBrush( color.WithAlpha( active ? (Paint.HasMouseOver ? 0.35f : 0.22f) : 0.07f ) );
		Paint.DrawRect( LocalRect, 10 );

		Paint.SetPen( active ? color : Palette.TextDim );
		Paint.SetDefaultFont( 7, 600 );
		Paint.DrawText( LocalRect, Category.ToString(), TextFlag.Center );
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );

		if ( !Clickable )
			return;

		Toggled = !Toggled;
		OnToggled?.Invoke();
		Update();
	}
}

/// <summary>
/// A row of fixed-size widgets that wraps to new lines instead of squashing
/// when the panel is narrow.
/// </summary>
public class FlowRow : Widget
{
	readonly List<Widget> _items = new();

	public float Spacing = 4;

	public FlowRow( Widget parent ) : base( parent )
	{
		HorizontalSizeMode = SizeMode.Flexible;
	}

	public T AddItem<T>( T widget ) where T : Widget
	{
		widget.Parent = this;
		_items.Add( widget );
		Arrange();
		return widget;
	}

	protected override void OnResize()
	{
		base.OnResize();
		Arrange();
	}

	void Arrange()
	{
		var available = MathF.Max( Width, 60 );
		float x = 0, y = 0, rowHeight = 0;

		foreach ( var item in _items )
		{
			var w = item.FixedWidth;
			var h = item.FixedHeight;

			if ( x > 0 && x + w > available )
			{
				x = 0;
				y += rowHeight + Spacing;
				rowHeight = 0;
			}

			item.Position = new Vector2( x, y );
			x += w + Spacing;
			rowHeight = MathF.Max( rowHeight, h );
		}

		FixedHeight = y + rowHeight;
	}
}

/// <summary>
/// Dark monospace code block with a one-click copy that flashes confirmation.
/// </summary>
public class CodeSnippet : Widget
{
	public string Code;
	public Color Accent;

	RealTimeSince _copiedFlash = 999;

	public CodeSnippet( string code, Color accent, Widget parent ) : base( parent )
	{
		Code = code;
		Accent = accent;

		var lines = code.Split( '\n' ).Length;
		FixedHeight = lines * 14 + 18; // 9px padding above and below the text
		Cursor = CursorShape.Finger;
		ToolTip = "Click to copy";
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();

		Paint.SetBrush( Palette.SnippetBackground );
		Paint.DrawRect( LocalRect, 4 );

		// accent edge
		Paint.SetBrush( Accent );
		Paint.DrawRect( new Rect( LocalRect.Left, LocalRect.Top + 6, 3, LocalRect.Height - 12 ), 1.5f );

		// code text
		Paint.SetPen( Paint.HasMouseOver ? Palette.TextBright : Palette.TextDim );
		Paint.SetFont( "Consolas", 8 );

		var y = LocalRect.Top + 9;
		foreach ( var line in Code.Split( '\n' ) )
		{
			Paint.DrawText( new Rect( LocalRect.Left + 14, y, LocalRect.Width - 50, 14 ), line, TextFlag.LeftCenter | TextFlag.SingleLine );
			y += 14;
		}

		// copy affordance; keep repainting while the flash is visible so it
		// actually expires (nothing else schedules a repaint)
		var justCopied = _copiedFlash < 1.2f;
		if ( justCopied )
			Update();

		Paint.SetPen( justCopied ? Palette.Running : (Paint.HasMouseOver ? Palette.TextBright : Palette.TextDim) );

		if ( justCopied )
		{
			Paint.SetDefaultFont( 7, 600 );
			Paint.DrawText( new Rect( LocalRect.Right - 70, LocalRect.Top + 6, 60, 16 ), "Copied ✓", TextFlag.RightCenter );
		}
		else
		{
			Paint.DrawIcon( new Rect( LocalRect.Right - 28, LocalRect.Top + 6, 18, 18 ), "content_copy", 13, TextFlag.Center );
		}
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );
		EditorUtility.Clipboard.Copy( Code );
		_copiedFlash = 0;
		Update();
	}
}

/// <summary>
/// Titled section box matching the editor's native Group widget (which lives
/// in the tools addon and is replicated here so the offline compile gate
/// keeps working): subtle rounded background, icon + title header.
/// </summary>
public class GroupBox : Widget
{
	public string Title { get; set; } = "";
	public string Icon { get; set; }

	public GroupBox( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 14, 30, 14, 12 ); // top clears the header
		Layout.Spacing = 6;
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ButtonBackground.WithAlpha( 0.1f ) );
		Paint.DrawRect( LocalRect.Shrink( 0, 1 ), 4.0f );
		Paint.ClearBrush();

		var headerRect = new Rect( 0, 0, Width, 28 );
		var left = 14f;

		if ( !string.IsNullOrWhiteSpace( Icon ) )
		{
			Paint.SetPen( Theme.Text.WithAlpha( 0.8f ) );
			Paint.DrawIcon( headerRect.Shrink( left, 0, 0, 0 ), Icon, 18, TextFlag.LeftCenter );
			left += 24;
		}

		Paint.SetDefaultFont( 8, 400 );
		Paint.SetPen( Theme.Text );
		Paint.DrawText( headerRect.Shrink( left, 0, 0, 0 ), Title, TextFlag.LeftCenter );
	}
}

/// <summary>
/// Rounded container in the editor's control background, optionally with a
/// tone-colored edge (used for approval cards).
/// </summary>
public class Card : Widget
{
	public Color? EdgeAccent;

	public Card( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 6;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();

		Paint.SetBrush( Palette.CardBackground );
		Paint.DrawRect( LocalRect, 4 );

		if ( EdgeAccent is not null )
		{
			Paint.SetBrush( EdgeAccent.Value );
			Paint.DrawRect( new Rect( LocalRect.Left, LocalRect.Top + 8, 3, LocalRect.Height - 16 ), 1.5f );
		}
	}
}

/// <summary>
/// One tab in the dock's tab bar - neutral editor styling, primary-color
/// underline when active.
/// </summary>
public class TabButton : Widget
{
	public string Text;
	public string Icon;
	public bool Active;
	public Action Clicked;
	public int Badge;

	public TabButton( string text, string icon, Widget parent ) : base( parent )
	{
		Text = text;
		Icon = icon;
		FixedHeight = 32;
		FixedWidth = 46 + text.Length * 7f;
		Cursor = CursorShape.Finger;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();

		if ( Paint.HasMouseOver && !Active )
		{
			Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
			Paint.DrawRect( LocalRect, 4 );
		}

		var fg = Active ? Palette.TextBright : Palette.TextDim;

		Paint.SetPen( fg );
		Paint.DrawIcon( new Rect( LocalRect.Left + 8, LocalRect.Top, 16, LocalRect.Height ), Icon, 14, TextFlag.Center );

		Paint.SetDefaultFont( 8, Active ? 700 : 400 );
		Paint.DrawText( new Rect( LocalRect.Left + 28, LocalRect.Top, LocalRect.Width - 30, LocalRect.Height ), Text, TextFlag.LeftCenter );

		if ( Badge > 0 )
		{
			var b = new Rect( LocalRect.Right - 16, LocalRect.Top + 6, 14, 14 );
			Paint.ClearPen();
			Paint.SetBrush( Palette.Warning );
			Paint.DrawCircle( b.Center, 14 );
			Paint.SetPen( Color.Black );
			Paint.SetDefaultFont( 7, 700 );
			Paint.DrawText( b, Badge > 9 ? "9" : Badge.ToString(), TextFlag.Center );
		}

		if ( Active )
		{
			Paint.ClearPen();
			Paint.SetBrush( Palette.Accent );
			Paint.DrawRect( new Rect( LocalRect.Left + 6, LocalRect.Bottom - 3, LocalRect.Width - 12, 3 ), 1.5f );
		}
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );
		Clicked?.Invoke();
	}
}