Files
sbox-public/engine/Sandbox.Tools/Qt/Menu.AddOptions.cs
s&box team 71f266059a Open source release
This commit imports the C# engine code and game files, excluding C++ source code.

[Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
2025-11-24 09:05:18 +00:00

340 lines
9.4 KiB
C#

#nullable enable
using System;
using System.Text.RegularExpressions;
using Sandbox.Internal;
namespace Editor;
partial class Menu
{
public record struct PathElement( string Name, string? Icon = null, string? Description = null, int? Order = null, bool IsHeading = false ) : IComparable<PathElement>
{
public int CompareTo( PathElement other )
{
var orderComparison = Nullable.Compare( Order, other.Order );
if ( orderComparison != 0 )
{
return orderComparison;
}
return string.Compare( Name, other.Name, StringComparison.Ordinal );
}
public static int Compare( IReadOnlyList<PathElement> aPath, IReadOnlyList<PathElement> bPath )
{
for ( var i = 0; ; ++i )
{
if ( aPath.Count == i && bPath.Count == i )
{
return 0;
}
if ( aPath.Count == i )
{
return 1;
}
if ( bPath.Count == i )
{
return -1;
}
var aHead = aPath[i];
var bHead = bPath[i];
var isHeadingCompare = aHead.IsHeading.CompareTo( bHead.IsHeading );
if ( isHeadingCompare != 0 ) return isHeadingCompare;
var orderCompare = (aHead.Order ?? 0).CompareTo( bHead.Order ?? 0 );
if ( orderCompare != 0 ) return orderCompare;
var nameCompare = string.Compare( aHead.Name, bHead.Name, StringComparison.Ordinal );
if ( nameCompare != 0 ) return nameCompare;
}
}
public bool Matches( PathElement other )
{
return Name == other.Name && IsHeading == other.IsHeading;
}
public PathElement Merge( PathElement other )
{
return this with { Icon = Icon ?? other.Icon, Description = Description ?? other.Description, Order = Order ?? other.Order };
}
internal static void CleanUp( PathElement[] path )
{
if ( path[^1].IsHeading )
{
path[^1] = path[^1] with { IsHeading = false };
}
}
internal static PathElement[] Flatten( PathElement[] path )
{
if ( path[0].IsHeading )
{
return new[]
{
path[0],
path[^1] with { Name = string.Join( " → ", path.Skip( 1 ).Where( x => !x.IsHeading ).Select( x => x.Name ) ) }
};
}
return new[] { path[^1] with { Name = string.Join( " → ", path.Where( x => !x.IsHeading ).Select( x => x.Name ) ) } };
}
}
private static Regex PathPartRegex { get; } = new( @"(?<heading>#)?(?<part>[^:/@]+)(?::(?<icon>[^/@]+))?(?:@(?<order>-?[0-9]+))?(?:/|$)" );
/// <summary>
/// Splits a path as a list of <c>/</c>-delimited elements, each with the form <c>"[#]name[:icon][@order]"</c>.
/// </summary>
/// <param name="path">Path to split.</param>
public static PathElement[] GetSplitPath( string path )
{
var pathPartMatches = PathPartRegex.Matches( path );
var splitPath = pathPartMatches.Count == 0
? new[] { new PathElement( path ) }
: pathPartMatches.Select( x => new PathElement( x.Groups["part"].Value,
Icon: x.Groups["icon"] is { Success: true, Value: { } icon } ? icon : null,
Order: x.Groups["order"] is { Success: true, Value: { } orderStr } && int.TryParse( orderStr, out var order ) ? order : null,
IsHeading: x.Groups["heading"] is { Success: true } ) )
.ToArray();
return splitPath;
}
/// <summary>
/// Combines the <see cref="ICategoryProvider.Value"/> (if exists) and <see cref="ITitleProvider.Value"/>, then splits it with <see cref="GetSplitPath(string)"/>.
/// </summary>
public static PathElement[] GetSplitPath( ITitleProvider item )
{
var title = item.Value;
var group = (item as ICategoryProvider)?.Value;
var icon = (item as IIconProvider)?.Value;
var desc = (item as IDescriptionProvider)?.Value;
var order = (item as IOrderProvider)?.Value;
var path = string.IsNullOrEmpty( group ) ? title : $"{group}/{title}";
var split = GetSplitPath( path );
split[^1] = split[^1] with { Icon = icon, Description = desc, Order = order };
return split;
}
private class MenuItem<T>
{
public PathElement PathElement;
public List<MenuItem<T>>? SubItems;
public T? Value;
public bool IsReduced;
public string Name => PathElement.Name;
public string? Icon => PathElement.Icon;
public string? Description => PathElement.Description;
public bool IsHeading => PathElement.IsHeading;
}
private static void Add<T>( List<MenuItem<T>> list, ReadOnlySpan<PathElement> path, T value )
{
while ( true )
{
var head = path[0];
if ( path.Length == 1 )
{
list.Add( new MenuItem<T> { PathElement = head, Value = value } );
return;
}
if ( list.FirstOrDefault( x => x.SubItems != null && x.PathElement.Matches( head ) ) is { } match )
{
match.PathElement = match.PathElement.Merge( head );
}
else
{
match = new MenuItem<T> { PathElement = head, SubItems = new List<MenuItem<T>>() };
list.Add( match );
}
list = match.SubItems!;
path = path[1..];
}
}
private static void Reduce<T>( List<MenuItem<T>> list )
{
foreach ( var item in list )
{
while ( item.SubItems?.Count == 1 && (!item.IsHeading || item.IsReduced) )
{
var head = item.SubItems[0];
if ( !head.IsHeading )
{
item.PathElement = head.PathElement with
{
IsHeading = false,
Name = $"{item.Name} → {head.Name}",
Icon = head.Icon,
Description = head.Description
};
}
item.Value = head.Value;
item.SubItems = head.SubItems;
item.IsReduced = true;
}
if ( item.SubItems != null )
{
Reduce( item.SubItems );
}
}
}
private static void BuildMenu<T>( Menu menu, List<MenuItem<T>> list, Action<T>? action, string defaultMenuIcon )
{
foreach ( var item in list )
{
if ( item.SubItems is null )
{
var option = menu.AddOption( item.Name, item.Icon );
if ( !string.IsNullOrEmpty( item.Description ) )
{
menu.ToolTipsVisible = true;
option.ToolTip = item.Description;
}
if ( action is not null )
{
option.Triggered += () => action( item.Value! );
}
else
{
option.Enabled = false;
}
continue;
}
var subMenu = menu;
if ( !string.IsNullOrEmpty( item.Name ) )
{
if ( item.IsHeading )
{
menu.AddHeading( item.Name.ToTitleCase() );
}
else
{
subMenu = menu.AddMenu( item.Name, item.Icon ?? defaultMenuIcon );
if ( !string.IsNullOrEmpty( item.Description ) )
{
menu.ToolTipsVisible = true;
subMenu.ToolTip = item.Description;
}
}
}
BuildMenu( subMenu, item.SubItems!, action, defaultMenuIcon );
}
}
/// <summary>
/// Adds a bunch of options, creating sub-menus based on their paths.
/// </summary>
/// <param name="items">Items to create options for.</param>
/// <param name="getPath">Gets the path of an item as a list of <c>/</c>-delimited elements, each with the form <c>"[#]name[:icon][@order]"</c>.</param>
/// <param name="action">Action to call on a clicked element.</param>
/// <param name="flat">If true, flatten each path after sorting.</param>
/// <param name="reduce">If true, collapse sub-menus with single items.</param>
/// <param name="defaultSubMenuIcon">Use this icon for any sub-menus without an icon specified.</param>
public void AddOptions<T>( IEnumerable<T> items, Func<T, string> getPath,
Action<T>? action = null,
bool flat = false,
bool reduce = true,
string defaultSubMenuIcon = "folder" )
{
AddOptions( items, x => GetSplitPath( getPath( x ) ),
action: action,
flat: flat,
reduce: reduce,
defaultSubMenuIcon: defaultSubMenuIcon );
}
/// <summary>
/// Adds a bunch of options, creating sub-menus based on their paths.
/// </summary>
/// <param name="items">Items to create options for.</param>
/// <param name="action">Action to call on a clicked element.</param>
/// <param name="flat">If true, flatten each path after sorting.</param>
/// <param name="reduce">If true, collapse sub-menus with single items.</param>
/// <param name="defaultSubMenuIcon">Use this icon for any sub-menus without an icon specified.</param>
public void AddOptions<T>( IEnumerable<T> items,
Action<T>? action = null,
bool flat = false,
bool reduce = true,
string defaultSubMenuIcon = "folder" )
where T : ITitleProvider
{
AddOptions( items, x => GetSplitPath( x ),
action: action,
flat: flat,
reduce: reduce,
defaultSubMenuIcon: defaultSubMenuIcon );
}
/// <summary>
/// Adds a bunch of options, creating sub-menus based on their paths.
/// </summary>
/// <param name="items">Items to create options for.</param>
/// <param name="getPath">Gets the path of an item.</param>
/// <param name="action">Action to call on a clicked element.</param>
/// <param name="flat">If true, flatten each path after sorting.</param>
/// <param name="reduce">If true, collapse sub-menus with single items.</param>
/// <param name="defaultSubMenuIcon">Use this icon for any sub-menus without an icon specified.</param>
public void AddOptions<T>( IEnumerable<T> items, Func<T, PathElement[]> getPath,
Action<T>? action = null,
bool flat = false,
bool reduce = true,
string defaultSubMenuIcon = "folder" )
{
var itemPaths = items.Select( x =>
{
var path = getPath( x );
PathElement.CleanUp( path );
return (Item: x, Path: path);
} );
if ( flat )
{
itemPaths = itemPaths.Select( x => (x.Item, PathElement.Flatten( x.Path )) );
}
itemPaths = itemPaths.OrderBy( x => x.Path,
Comparer<PathElement[]>.Create( PathElement.Compare ) );
var list = new List<MenuItem<T>>();
foreach ( var (item, path) in itemPaths )
{
Add( list, path, item );
}
if ( reduce )
{
Reduce( list );
}
BuildMenu( this, list, action, defaultSubMenuIcon );
}
}