Editor/Effects/SFXREffectTypeSelector.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SFXR.Editor;
/// <summary>
/// A popup dialog to select an entity type
/// </summary>
internal partial class SFXREffectTypeSelector : PopupWidget
{
public Action<TypeDescription> OnSelect { get; set; }
List<EffectSelection> Panels { get; set; } = new();
int CurrentPanelId { get; set; } = 0;
Widget Main { get; set; }
string searchString;
const string NoCategoryName = "Uncategorized";
internal LineEdit Search { get; init; }
public SFXREffectTypeSelector( Widget parent ) : base( parent )
{
Layout = Layout.Column();
var head = Layout.Row();
head.Margin = 6;
Layout.Add( head );
Main = new Widget( this );
Main.Layout = Layout.Row();
Layout.Add( Main );
FixedWidth = 200;
MaximumHeight = 300;
DeleteOnClose = true;
Search = new LineEdit( this );
Search.MinimumHeight = 22;
Search.PlaceholderText = "Search..";
Search.TextEdited += ( t ) =>
{
searchString = t;
ResetSelection();
};
head.Add( Search );
ResetSelection();
Search.Focus();
}
/// <summary>
/// Pushes a new selection to the selector
/// </summary>
/// <param name="selection"></param>
void PushSelection( EffectSelection selection )
{
CurrentPanelId++;
// Do we have something at our new index, if so, kill it
if ( Panels.Count > CurrentPanelId && Panels.ElementAt( CurrentPanelId ) is var existingObj ) existingObj.Destroy();
Panels.Insert( CurrentPanelId, selection );
Main.Layout.Add( selection, 1 );
if ( !selection.IsManual )
{
UpdateSelection( selection );
}
AnimateSelection( true, Panels[CurrentPanelId - 1], selection );
selection.Focus();
}
/// <summary>
/// Pops the current selection off
/// </summary>
internal void PopSelection()
{
// Don't pop while empty
if ( CurrentPanelId == 0 ) return;
var currentIdx = Panels[CurrentPanelId];
CurrentPanelId--;
AnimateSelection( false, currentIdx, Panels[CurrentPanelId] );
Panels[CurrentPanelId].Focus();
}
/// <summary>
/// Runs an animation on the last selection, and the current selection.
/// I kinda hate this. A lot. But it's pretty.
/// </summary>
/// <param name="forward"></param>
/// <param name="prev"></param>
/// <param name="selection"></param>
void AnimateSelection( bool forward, EffectSelection prev, EffectSelection selection )
{
const string easing = "ease-out";
const float speed = 0.3f;
var distance = Width;
var prevFrom = prev.Position.x;
var prevTo = forward ? prev.Position.x - distance : prev.Position.x + distance;
var selectionFrom = forward ? selection.Position.x + distance : selection.Position.x;
var selectionTo = forward ? selection.Position.x : selection.Position.x + distance;
var func = ( EffectSelection a, float x ) =>
{
a.Position = a.Position.WithX( x );
OnMoved();
};
Animate.Add( prev, speed, prevFrom, prevTo, x => func( prev, x ), easing );
Animate.Add( selection, speed, selectionFrom, selectionTo, x => func( selection, x ), easing );
}
/// <summary>
/// Resets the current selection, useful when setting up / searching
/// </summary>
protected void ResetSelection()
{
Main.Layout.Clear( true );
Panels.Clear();
var selection = new EffectSelection( this, this );
CurrentPanelId = 0;
UpdateSelection( selection );
Panels.Add( selection );
Main.Layout.Add( selection );
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.SetPen( Theme.WidgetBackground.Darken( 0.4f ), 1 );
Paint.SetBrush( Theme.WidgetBackground );
Paint.DrawRect( LocalRect.Shrink( 1 ), 3 );
}
/// <summary>
/// Called when a category is selected
/// </summary>
/// <param name="category"></param>
void OnCategorySelected( string category )
{
// Push this as a new selection
PushSelection( new EffectSelection( this, this, category ) );
}
/// <summary>
/// Called when an individual effect is selected
/// </summary>
/// <param name="type"></param>
void OnEffectSelected( TypeDescription type )
{
OnSelect( type );
Destroy();
}
protected override void OnKeyRelease( KeyEvent e )
{
if ( e.Key == KeyCode.Down )
{
var selection = Panels[CurrentPanelId];
if ( selection.ItemList.FirstOrDefault() != null )
{
selection.Focus();
selection.PostKeyEvent( KeyCode.Down );
e.Accepted = true;
}
}
}
/// <summary>
/// Updates any selection
/// </summary>
/// <param name="selection"></param>
void UpdateSelection( EffectSelection selection )
{
selection.Clear();
selection.ItemList.Add( selection.CategoryHeader );
// entity components
var types = EditorTypeLibrary.GetTypes<SFXREffect>().Where( x => !x.IsAbstract );
if ( !string.IsNullOrWhiteSpace( searchString ) )
{
var searchWords = searchString.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
var query = types.Where( x =>
searchWords.All( word => x.Title.Contains( word, StringComparison.OrdinalIgnoreCase ) )
);
foreach ( var type in query )
{
selection.AddEntry( new EffectEntry( selection, type ) { MouseClick = () => OnEffectSelected( type ) } );
}
selection.AddStretchCell();
return;
}
if ( selection.Category == null )
{
var categories = types.Select( x => string.IsNullOrWhiteSpace( x.Group ) ? NoCategoryName : x.Group ).Distinct().OrderBy( x => x ).ToArray();
if ( categories.Length > 1 )
{
foreach ( var category in categories )
{
selection.AddEntry( new EffectCategory( selection )
{
Category = category,
MouseClick = () => OnCategorySelected( category ),
} );
}
selection.AddStretchCell();
return;
}
}
else
{
types = types.Where( x => selection.Category == NoCategoryName ? x.Group == null : x.Group == selection.Category ).OrderBy( x => x.Title );
foreach ( var type in types )
{
selection.AddEntry( new EffectEntry( selection, type ) { MouseClick = () => OnEffectSelected( type ) } );
}
selection.AddStretchCell();
}
}
/// <summary>
/// A widget that contains a given selection - we hold this in a class because more than one can exist.
/// </summary>
partial class EffectSelection : Widget
{
internal string Category { get; init; }
internal Widget CategoryHeader { get; init; }
ScrollArea Scroller { get; init; }
SFXREffectTypeSelector Selector { get; set; }
internal List<Widget> ItemList { get; private set; } = new();
internal int CurrentItemId { get; private set; } = 0;
internal Widget CurrentItem { get; private set; }
internal bool IsManual { get; set; }
internal EffectSelection( Widget parent, SFXREffectTypeSelector selector, string categoryName = null ) : base( parent )
{
Selector = selector;
Category = categoryName;
FixedWidth = 200;
MinimumHeight = 220;
Layout = Layout.Column();
CategoryHeader = new Widget( this );
CategoryHeader.FixedHeight = 24;
CategoryHeader.OnPaintOverride = PaintHeader;
CategoryHeader.MouseClick = Selector.PopSelection;
Layout.Add( CategoryHeader );
Scroller = new ScrollArea( this );
Scroller.Layout = Layout.Column();
Scroller.FocusMode = FocusMode.None;
Layout.Add( Scroller, 1 );
Scroller.Canvas = new Widget( Scroller );
Scroller.Canvas.Layout = Layout.Column();
}
protected bool SelectMoveRow( int delta )
{
var selection = Selector.Panels[Selector.CurrentPanelId];
if ( delta == 1 && selection.ItemList.Count - 1 > selection.CurrentItemId )
{
selection.CurrentItem = selection.ItemList[++selection.CurrentItemId];
selection.Update();
if ( selection.CurrentItem != null )
{
Scroller.MakeVisible( selection.CurrentItem );
}
return true;
}
else if ( delta == -1 )
{
if ( selection.CurrentItemId > 0 )
{
selection.CurrentItem = selection.ItemList[--selection.CurrentItemId];
selection.Update();
if ( selection.CurrentItem != null )
{
Scroller.MakeVisible( selection.CurrentItem );
}
return true;
}
else
{
selection.Selector.Search.Focus();
selection.CurrentItem = null;
selection.Update();
return true;
}
}
return false;
}
protected bool Enter()
{
var selection = Selector.Panels[Selector.CurrentPanelId];
if ( selection.ItemList[selection.CurrentItemId] is Widget entry )
{
entry.MouseClick?.Invoke();
return true;
}
return false;
}
protected override void OnKeyRelease( KeyEvent e )
{
// Move down
if ( e.Key == KeyCode.Down )
{
e.Accepted = true;
SelectMoveRow( 1 );
return;
}
// Move up
if ( e.Key == KeyCode.Up )
{
e.Accepted = true;
SelectMoveRow( -1 );
return;
}
// Back button while in any selection, goes to previous selction.
if ( e.Key == KeyCode.Left )
{
e.Accepted = true;
Selector.PopSelection();
return;
}
// Moving right, or hitting the enter key assumes you're trying to select something
if ( (e.Key == KeyCode.Return || e.Key == KeyCode.Right) && Enter() )
{
e.Accepted = true;
return;
}
}
internal bool PaintHeader()
{
var c = CategoryHeader;
var selected = c.IsUnderMouse || CurrentItem == c;
Paint.ClearPen();
Paint.SetBrush( selected ? Theme.Selection : Theme.WidgetBackground.WithAlpha( selected ? 0.7f : 0.4f ) );
Paint.DrawRect( c.LocalRect );
var r = c.LocalRect.Shrink( 12, 2 );
Paint.SetPen( Theme.ControlText );
if ( Selector.CurrentPanelId > 0 )
{
Paint.DrawIcon( r, "arrow_back", 14, TextFlag.LeftCenter );
}
Paint.SetDefaultFont( 8 );
Paint.DrawText( r, string.IsNullOrEmpty( Category ) ? "Effect" : Category, TextFlag.Center );
return true;
}
/// <summary>
/// Adds a new entry to the current selection.
/// </summary>
/// <param name="entry"></param>
internal Widget AddEntry( Widget entry )
{
var layoutWidget = Scroller.Canvas.Layout.Add( entry );
ItemList.Add( entry );
if ( entry is EffectEntry e ) e.Selector = this;
return layoutWidget;
}
/// <summary>
/// Adds a stretch cell to the bottom of the selection - good to call this when you know you're done adding entries.
/// </summary>
internal void AddStretchCell()
{
Scroller.Canvas.Layout.AddStretchCell( 1 );
Update();
}
/// <summary>
/// Adds a separator cell.
/// </summary>
internal void AddSeparator()
{
Scroller.Canvas.Layout.AddSeparator( true );
Update();
}
/// <summary>
/// Clears the current selection
/// </summary>
internal void Clear()
{
Scroller.Canvas.Layout.Clear( true );
ItemList.Clear();
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.SetPen( Theme.WidgetBackground.Darken( 0.8f ), 1 );
Paint.SetBrush( Theme.WidgetBackground.Darken( 0.2f ) );
Paint.DrawRect( LocalRect.Shrink( 0 ), 3 );
}
}
/// <summary>
/// An effect entry
/// </summary>
class EffectEntry : Widget
{
public string Text { get; set; } = "My Effect";
public string Icon { get; set; } = "note_add";
public bool IsSelected { get; set; } = false;
internal EffectSelection Selector { get; set; }
public TypeDescription Type { get; init; }
internal EffectEntry( Widget parent, TypeDescription type = null ) : base( parent )
{
FixedHeight = 24;
Type = type;
if ( type is not null )
{
Text = type.Title;
Icon = type.Icon;
}
}
protected override void OnPaint()
{
var r = LocalRect.Shrink( 12, 2 );
var selected = IsUnderMouse || Selector.CurrentItem == this;
var opacity = selected ? 1.0f : 0.7f;
if ( selected )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Selection );
Paint.DrawRect( LocalRect );
}
if ( Type is not null && !string.IsNullOrEmpty( Type.Icon ) )
{
Type.PaintComponentIcon( new Rect( r.Position, r.Height ).Shrink( 2 ), opacity );
}
else
{
Paint.SetPen( Theme.Green.WithAlpha( opacity ) );
var icon = !string.IsNullOrEmpty( Icon ) ? Icon : "note_add";
Paint.DrawIcon( new Rect( r.Position, r.Height ).Shrink( 2 ), icon, r.Height, TextFlag.Center );
}
r.Left += r.Height + 6;
Paint.SetDefaultFont( 8 );
Paint.SetPen( Theme.ControlText.WithAlpha( selected ? 1.0f : 0.5f ) );
Paint.DrawText( r, Text, TextFlag.LeftCenter );
}
}
/// <summary>
/// A category effect entry
/// </summary>
class EffectCategory : EffectEntry
{
public string Category { get; set; }
public EffectCategory( Widget parent ) : base( parent ) { }
protected override void OnPaint()
{
var selected = IsUnderMouse || Selector.CurrentItem == this;
if ( selected )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Selection );
Paint.DrawRect( LocalRect );
}
var r = LocalRect.Shrink( 12, 2 );
Paint.SetPen( Theme.ControlText.WithAlpha( selected ? 1.0f : 0.5f ) );
Paint.SetDefaultFont( 8 );
Paint.DrawText( r, Category, TextFlag.LeftCenter );
Paint.DrawIcon( r, "arrow_forward", 14, TextFlag.RightCenter );
}
}
}