#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 { 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 aPath, IReadOnlyList 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( @"(?#)?(?[^:/@]+)(?::(?[^/@]+))?(?:@(?-?[0-9]+))?(?:/|$)" ); /// /// Splits a path as a list of /-delimited elements, each with the form "[#]name[:icon][@order]". /// /// Path to split. 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; } /// /// Combines the (if exists) and , then splits it with . /// 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 { public PathElement PathElement; public List>? 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( List> list, ReadOnlySpan path, T value ) { while ( true ) { var head = path[0]; if ( path.Length == 1 ) { list.Add( new MenuItem { 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 { PathElement = head, SubItems = new List>() }; list.Add( match ); } list = match.SubItems!; path = path[1..]; } } private static void Reduce( List> 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( Menu menu, List> list, Action? 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 ); } } /// /// Adds a bunch of options, creating sub-menus based on their paths. /// /// Items to create options for. /// Gets the path of an item as a list of /-delimited elements, each with the form "[#]name[:icon][@order]". /// Action to call on a clicked element. /// If true, flatten each path after sorting. /// If true, collapse sub-menus with single items. /// Use this icon for any sub-menus without an icon specified. public void AddOptions( IEnumerable items, Func getPath, Action? 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 ); } /// /// Adds a bunch of options, creating sub-menus based on their paths. /// /// Items to create options for. /// Action to call on a clicked element. /// If true, flatten each path after sorting. /// If true, collapse sub-menus with single items. /// Use this icon for any sub-menus without an icon specified. public void AddOptions( IEnumerable items, Action? 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 ); } /// /// Adds a bunch of options, creating sub-menus based on their paths. /// /// Items to create options for. /// Gets the path of an item. /// Action to call on a clicked element. /// If true, flatten each path after sorting. /// If true, collapse sub-menus with single items. /// Use this icon for any sub-menus without an icon specified. public void AddOptions( IEnumerable items, Func getPath, Action? 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.Create( PathElement.Compare ) ); var list = new List>(); foreach ( var (item, path) in itemPaths ) { Add( list, path, item ); } if ( reduce ) { Reduce( list ); } BuildMenu( this, list, action, defaultSubMenuIcon ); } }