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 );
}
}