Editor/HotCodeEditor/EditorTabBar.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// A row of closable file tabs. Painted as one widget so tabs can be added and removed freely.
/// Left click selects, middle click or the × closes, double click pins a preview tab.
/// </summary>
public class EditorTabBar : Widget
{
	const float MaxTabWidth = 220;
	const float MinTabWidth = 90;
	const float CloseSize = 18;
	const float IconSize = 18;
	const float TextPadding = 12;

	public Action<EditorDocument> OnTabSelected;
	public Action<EditorDocument> OnTabCloseRequested;
	public Action<EditorDocument> OnTabPinned;

	/// <summary>
	/// Used for tooltips, so tabs with the same file name can be told apart.
	/// </summary>
	public Func<EditorDocument, string> GetTabTooltip;

	private readonly List<EditorDocument> _documents = new();
	public EditorDocument Active { get; private set; }

	public EditorTabBar( Widget parent ) : base( parent )
	{
		FixedHeight = HotCodeStyle.TabHeight;
		MouseTracking = true;
	}

	public void Add( EditorDocument doc )
	{
		_documents.Add( doc );
		Update();
	}

	public void Insert( int index, EditorDocument doc )
	{
		_documents.Insert( Math.Clamp( index, 0, _documents.Count ), doc );
		Update();
	}

	public void Remove( EditorDocument doc )
	{
		_documents.Remove( doc );
		if ( Active == doc ) Active = null;
		Update();
	}

	public void SetActive( EditorDocument doc )
	{
		Active = doc;
		Update();
	}

	private record struct TabRect( EditorDocument Doc, Rect Rect, Rect Close );

	private List<TabRect> ComputeTabs()
	{
		HotCodeStyle.SetTabFont();

		var widths = _documents.Select( d => Math.Clamp( Paint.MeasureText( d.Title ).x + TextPadding * 2 + IconSize + CloseSize, MinTabWidth, MaxTabWidth ) ).ToList();

		// Squash tabs evenly when they don't fit, down to MinTabWidth
		var total = widths.Sum();
		if ( total > Width && total > 0 )
		{
			var scale = Width / total;
			widths = widths.Select( w => MathF.Max( MinTabWidth, w * scale ) ).ToList();
		}

		var result = new List<TabRect>();
		float x = 4;
		for ( int i = 0; i < _documents.Count; i++ )
		{
			// Tabs sit 2px down from the top, like s&box's dock tabs
			var rect = new Rect( x, 2, widths[i], Height - 2 );
			var close = new Rect( rect.Right - CloseSize - 4, rect.Top + (rect.Height - CloseSize) / 2 + 1, CloseSize, CloseSize );
			result.Add( new TabRect( _documents[i], rect, close ) );
			x += widths[i] + 2;
		}

		return result;
	}

	private TabRect? HitTest( Vector2 pos ) => ComputeTabs().Where( t => t.Rect.IsInside( pos ) ).Cast<TabRect?>().FirstOrDefault();

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.TabBarBackground );
		Paint.DrawRect( LocalRect );

		var mouse = FromScreen( Editor.Application.CursorPosition );

		foreach ( var tab in ComputeTabs() )
		{
			var active = tab.Doc == Active;
			var hovered = tab.Rect.IsInside( mouse );
			var overClose = tab.Close.IsInside( mouse );

			var content = HotCodeStyle.DrawTab( tab.Rect, active, hovered );

			// File type icon
			Paint.SetPen( HotCodeStyle.FileIconColor( tab.Doc.Path ).WithAlpha( active || hovered ? 1f : 0.7f ) );
			Paint.DrawIcon( new Rect( content.Left, content.Top, IconSize, content.Height ), HotCodeStyle.FileIcon( tab.Doc.Path ), 13, TextFlag.LeftCenter );

			// Title (italic while it's a preview tab)
			HotCodeStyle.SetTabFont( italic: tab.Doc.IsPreview );
			Paint.SetPen( HotCodeStyle.TabTextColor( active, hovered ) );
			var textLeft = content.Left + IconSize;
			var textRect = new Rect( textLeft, content.Top, tab.Close.Left - textLeft - 2, content.Height );
			Paint.DrawText( textRect, Paint.GetElidedText( tab.Doc.Title, textRect.Width, ElideMode.Middle ), TextFlag.LeftCenter );

			// Close button / unsaved dot
			if ( overClose )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.SelectedBackground );
				Paint.DrawRect( tab.Close, Theme.ControlRadius );
			}

			if ( tab.Doc.IsDirty && !overClose )
			{
				Paint.ClearPen();
				Paint.SetBrush( active || hovered ? Theme.Text : Theme.TextLight );
				Paint.DrawCircle( tab.Close.Center, 7 );
			}
			else if ( active || hovered )
			{
				Paint.SetPen( overClose ? Theme.Text : Theme.TextLight );
				Paint.DrawIcon( tab.Close, "close", 13, TextFlag.Center );
			}
		}
	}

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

		var hit = HitTest( e.LocalPosition );
		ToolTip = hit is { } t ? GetTabTooltip?.Invoke( t.Doc ) ?? t.Doc.Path : null;
		Update();
	}

	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();
		Update();
	}

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

		if ( HitTest( e.LocalPosition ) is not { } tab )
			return;

		if ( e.MiddleMouseButton || (e.LeftMouseButton && tab.Close.IsInside( e.LocalPosition )) )
		{
			OnTabCloseRequested?.Invoke( tab.Doc );
		}
		else if ( e.LeftMouseButton )
		{
			OnTabSelected?.Invoke( tab.Doc );
		}

		e.Accepted = true;
	}

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

		if ( HitTest( e.LocalPosition ) is { } tab && !tab.Close.IsInside( e.LocalPosition ) )
			OnTabPinned?.Invoke( tab.Doc );
	}
}