diff --git a/Silksong.ModMenu/Directory.Build.props b/Silksong.ModMenu/Directory.Build.props index 04fadb4..9078789 100644 --- a/Silksong.ModMenu/Directory.Build.props +++ b/Silksong.ModMenu/Directory.Build.props @@ -11,6 +11,6 @@ It should follow the format major.minor.patch (semantic versioning). If you publish your mod as a library to NuGet, this version will also be used as the package version. --> - 0.4.6 + 0.5.0 diff --git a/Silksong.ModMenu/Elements/LocalizedText.cs b/Silksong.ModMenu/Elements/LocalizedText.cs index 1dae199..8a2db84 100644 --- a/Silksong.ModMenu/Elements/LocalizedText.cs +++ b/Silksong.ModMenu/Elements/LocalizedText.cs @@ -7,7 +7,7 @@ namespace Silksong.ModMenu.Elements; /// public class LocalizedText { - private readonly TeamCherry.Localization.LocalisedString localisedString; + private readonly LocalisedString localisedString; private readonly string rawText; private LocalizedText(LocalisedString localisedString, string rawText) @@ -24,6 +24,11 @@ private LocalizedText(LocalisedString localisedString, string rawText) ? rawText : Language.Get(localisedString.Key, localisedString.Sheet); + /// + /// Get a canonical programmatic string for this LocalizedText that does not change when the language changes. + /// + public string Canonical => localisedString.IsEmpty ? rawText : localisedString.Key; + /// /// Returns true if this object has localization support. /// @@ -49,6 +54,22 @@ private LocalizedText(LocalisedString localisedString, string rawText) /// public static LocalizedText Raw(string rawText) => new(new(), rawText); + /// + public override int GetHashCode() => + IsLocalized ? localisedString.GetHashCode() : rawText.GetHashCode(); + + /// + /// Equals. Ignores raw text if it does not apply. + /// + public override bool Equals(object obj) => + obj is LocalizedText other + && IsLocalized == other.IsLocalized + && ( + IsLocalized + ? localisedString.Equals(other.localisedString) + : rawText.Equals(other.rawText) + ); + /// /// Implicit conversion for raw text to un-localized LocalizedText. /// diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index b43404c..546abf1 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -1,6 +1,7 @@ using MonoDetour; using MonoDetour.DetourTypes; using MonoDetour.HookGen; +using Silksong.ModMenu.Elements; using Silksong.UnityHelper.Extensions; using UnityEngine; using UnityEngine.EventSystems; @@ -152,10 +153,10 @@ private MenuPrefabs(UIManager uiManager) sliderChild.FindChild("MasterVolValue")!.name = "Value"; } - internal GameObject NewCustomMenu(string title) + internal GameObject NewCustomMenu(LocalizedText title) { var obj = Object.Instantiate(menuTemplate); - obj.name = $"ModMenuScreen-{title}"; + obj.name = $"ModMenuScreen-{title.Canonical}"; obj.transform.SetParent(canvas.transform, false); obj.transform.localPosition = new(0, 10, 0); diff --git a/Silksong.ModMenu/Internal/TreeNode.cs b/Silksong.ModMenu/Internal/TreeNode.cs new file mode 100644 index 0000000..ed78e96 --- /dev/null +++ b/Silksong.ModMenu/Internal/TreeNode.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace Silksong.ModMenu.Internal; + +/// +/// A recursive tree structure which supports lazy node creation and postfix traversal. +/// +internal class TreeNode + where V : new() +{ + private readonly Dictionary> subtrees = []; + + public V Value = new(); + + public IReadOnlyDictionary> Subtrees => subtrees; + + public TreeNode this[IEnumerable keys] + { + get + { + TreeNode tree = this; + foreach (var key in keys) + { + if (tree.subtrees.TryGetValue(key, out var subtree)) + tree = subtree; + else + { + subtree = new(); + tree.subtrees[key] = subtree; + tree = subtree; + } + } + return tree; + } + } + + private void ForEachPostfixRecursive( + List keys, + Action, TreeNode> action + ) + { + foreach (var e in subtrees) + { + keys.Add(e.Key); + e.Value.ForEachPostfixRecursive(keys, action); + keys.RemoveAt(keys.Count - 1); + } + action(keys, this); + } + + public void ForEachPostfix(Action, TreeNode> action) => + ForEachPostfixRecursive([], action); +} diff --git a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs index 1276139..1e5cd52 100644 --- a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs +++ b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using BepInEx; using BepInEx.Configuration; using Silksong.ModMenu.Elements; @@ -48,32 +49,133 @@ public static void AddDefaultGenerator(MenuElementGenerator generator) => /// public readonly List Generators = [.. defaultGenerators]; + /// + /// If true, organize elements heirarchically by the period-delimeted names of their config definitions. + /// + public bool GenerateSubgroups = false; + + /// + /// The minimum number of elements required to generate a subgroup. Has no effect if GenerateSubgroups is false. + /// + public int MinSubgroupSize = 2; + + private static IEnumerable SubgroupsFromConfig(ConfigDefinition config) => + config.Section.Split('.').Concat(config.Key.Split('.')).Select(LocalizedText.Raw); + + /// + /// The hierarchical subgroup names to use for each config entry. + /// + protected virtual IEnumerable GetSubgroupNames( + ConfigEntryBase config, + MenuElement menuElement + ) + { + var subgroups = config.Description.Tags.OfType().FirstOrDefault(); + return subgroups?.Subgroups + ?? (GenerateSubgroups ? SubgroupsFromConfig(config.Definition) : []); + } + + private static void FindFirstNonEmptyChild( + ref TreeNode tree, + List keys + ) + { + while (tree.Value.TotalElements <= 1 && tree.Subtrees.Count == 1) + { + var (key, subtree) = tree.Subtrees.First(); + keys.Add(key); + tree = subtree; + } + } + + private List<(string, MenuElement)> BuildSubtreeElements( + LocalizedText menuName, + List subpageNames, + TreeNode tree + ) + { + // Return elements directly if there is not enough of them. + if (tree.Value.TotalElements < MinSubgroupSize) + { + List<(string, MenuElement)> list = []; + tree.ForEachPostfix((keys, t) => list.AddRange(t.Value.Elements)); + list.Sort((a, b) => a.Item1.CompareTo(b.Item1)); + return list; + } + + // Otherwise, build a sub-page and return a button that navigates to it. + var name = string.Join(".", subpageNames.Select(n => n.Canonical)); + var screen = BuildSubtreeScreen(menuName, subpageNames, tree); + TextButton button = new(subpageNames.Last()) + { + OnSubmit = () => MenuScreenNavigation.Show(screen), + }; + return [(name, button)]; + } + + private AbstractMenuScreen BuildSubtreeScreen( + LocalizedText menuName, + List subpageNames, + TreeNode tree + ) + { + List<(string path, MenuElement element)> elements = [.. tree.Value.Elements]; + + int origSize = subpageNames.Count; + foreach (var entry in tree.Subtrees) + { + var subtree = entry.Value; + FindFirstNonEmptyChild(ref subtree, subpageNames); + elements.AddRange(BuildSubtreeElements(menuName, subpageNames, subtree)); + subpageNames.RemoveRange(origSize, subpageNames.Count - origSize); + } + + PaginatedMenuScreenBuilder builder = new(subpageNames.LastOrDefault() ?? menuName); + builder.AddRange(elements.OrderBy(e => e.path).Select(e => e.element)); + return builder.Build(); + } + /// /// Generate a button for this plugin which opens a sub-menu for its ConfigFile. /// public virtual bool GenerateEntryButton( - string name, + LocalizedText name, BaseUnityPlugin plugin, [MaybeNullWhen(false)] out SelectableElement selectableElement ) { - List elements = []; - foreach (var entry in plugin.Config) + TreeNode elementsTree = new(); + foreach (var entry in plugin.Config.OrderBy(e => e.Key.Key)) { if (GenerateMenuElement(entry.Value, out var element)) - elements.Add(element); + { + var subgroupNames = GetSubgroupNames(entry.Value, element); + elementsTree[subgroupNames].Value.Elements.Add((entry.Key.Key, element)); + } } - if (elements.Count == 0) + // Count the total number of elements in each subtree. + // The number of elements counted for each subtree is 1 if it gets its own menu button, N otherwise. + elementsTree.ForEachPostfix( + (_, tree) => + tree.Value.TotalElements = + tree.Value.Elements.Count + + tree.Subtrees.Values.Select(t => + t.Value.TotalElements >= MinSubgroupSize ? 1 : t.Value.TotalElements + ) + .Sum() + ); + if (elementsTree.Value.TotalElements == 0) { selectableElement = default; return false; } - PaginatedMenuScreenBuilder builder = new(name); - builder.AddRange(elements); - var menu = builder.Build(); + // Skip past any universal prefix. + List subpageNames = []; + FindFirstNonEmptyChild(ref elementsTree, subpageNames); + var menu = BuildSubtreeScreen(name, subpageNames, elementsTree); selectableElement = new TextButton(name) { OnSubmit = () => MenuScreenNavigation.Show(menu), @@ -315,6 +417,12 @@ public static bool GenerateStringElement( menuElement = text; return true; } + + private record ElementTreeNode + { + public readonly List<(string path, MenuElement element)> Elements = []; + public int TotalElements; + } } /// diff --git a/Silksong.ModMenu/Plugin/ConfigEntrySubgroup.cs b/Silksong.ModMenu/Plugin/ConfigEntrySubgroup.cs new file mode 100644 index 0000000..adf3bf4 --- /dev/null +++ b/Silksong.ModMenu/Plugin/ConfigEntrySubgroup.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Silksong.ModMenu.Elements; + +namespace Silksong.ModMenu.Plugin; + +/// +/// A config entry attribute that designates how this element should be organized into subpages hierarchically. +/// If present, this overrides the default hierarchy names generated (or not generated) by ConfigEntryFactory. +/// +public record ConfigEntrySubgroup +{ + /// + /// Construct subgroup names from a list. + /// + public ConfigEntrySubgroup(IEnumerable subgroups) => Subgroups = [.. subgroups]; + + /// + /// Construct subgroup names from explicit parameters. + /// + public ConfigEntrySubgroup(LocalizedText name1, params LocalizedText[] otherNames) => + Subgroups = [name1, .. otherNames]; + + /// + /// The subgroup names that designate this config element's place in the subpage hierarchy. An empty list designates the root page. + /// + /// This can describe the page this entry belogs to, or the full path to the element itself explicitly. + /// Both will work for grouping purposes, but the choice must be consistent throughout the plugin. The default implementation generates full paths to each individual element. + /// + public readonly IReadOnlyList Subgroups; +} diff --git a/Silksong.ModMenu/Plugin/IModMenuInterface.cs b/Silksong.ModMenu/Plugin/IModMenuInterface.cs index 6941546..749fa92 100644 --- a/Silksong.ModMenu/Plugin/IModMenuInterface.cs +++ b/Silksong.ModMenu/Plugin/IModMenuInterface.cs @@ -1,4 +1,8 @@ -namespace Silksong.ModMenu.Plugin; +using System.Reflection; +using BepInEx; +using Silksong.ModMenu.Elements; + +namespace Silksong.ModMenu.Plugin; /// /// Marker interface for all plugin interfaces intended to generate mod menus. @@ -8,5 +12,6 @@ public interface IModMenuInterface /// /// A unique identifier for this mod menu, used as a case-insensitive sort key. /// - public string ModMenuName(); + public LocalizedText ModMenuName() => + GetType().GetCustomAttribute()?.Name ?? "###UNKNOWN###"; } diff --git a/Silksong.ModMenu/Plugin/IModMenuNestedMenu.cs b/Silksong.ModMenu/Plugin/IModMenuNestedMenu.cs new file mode 100644 index 0000000..99eec17 --- /dev/null +++ b/Silksong.ModMenu/Plugin/IModMenuNestedMenu.cs @@ -0,0 +1,13 @@ +namespace Silksong.ModMenu.Plugin; + +/// +/// Marker interface for a mod menu that generates hierarchical submenus, based on config keys. +/// +public interface IModMenuNestedMenu : IModMenuInterface +{ + /// + /// The minimum number of elements required in a subgroup to generate a screen for it. + /// If a sub-page has too few elements, it is flattened into its containing page. + /// + int MinSubgroupSize() => 2; +} diff --git a/Silksong.ModMenu/Plugin/MenuElementGenerators.cs b/Silksong.ModMenu/Plugin/MenuElementGenerators.cs index 65eee6a..9b1b8e8 100644 --- a/Silksong.ModMenu/Plugin/MenuElementGenerators.cs +++ b/Silksong.ModMenu/Plugin/MenuElementGenerators.cs @@ -91,7 +91,7 @@ out ListChoiceModel? model /// public static ConfigEntryFactory.MenuElementGenerator CreateIntSliderGenerator() { - bool gen(ConfigEntryBase entry, [MaybeNullWhen(false)] out MenuElement menuElement) + static bool gen(ConfigEntryBase entry, [MaybeNullWhen(false)] out MenuElement menuElement) { menuElement = default; diff --git a/Silksong.ModMenu/Plugin/PluginRegistry.cs b/Silksong.ModMenu/Plugin/PluginRegistry.cs index 2a9408d..c0817eb 100644 --- a/Silksong.ModMenu/Plugin/PluginRegistry.cs +++ b/Silksong.ModMenu/Plugin/PluginRegistry.cs @@ -26,6 +26,7 @@ public static class PluginRegistry [ HandleType(GenerateCustomElement), HandleType(GenerateCustomMenu), + GenerateNestedMenu, HandleType(GenerateToggle), ]; @@ -60,7 +61,7 @@ bool Handler( internal static bool GenerateMenuElement( BaseUnityPlugin plugin, - out string name, + out LocalizedText name, [MaybeNullWhen(false)] out SelectableElement menuElement ) { @@ -99,6 +100,29 @@ private static SelectableElement GenerateCustomMenu(IModMenuCustomMenu plugin) }; } + private static bool GenerateNestedMenu( + IModMenuInterface plugin, + [MaybeNullWhen(false)] out SelectableElement menuElement + ) + { + if (plugin is not IModMenuNestedMenu typed) + { + menuElement = default; + return false; + } + + ConfigEntryFactory factory = new() + { + GenerateSubgroups = true, + MinSubgroupSize = typed.MinSubgroupSize(), + }; + return factory.GenerateEntryButton( + plugin.ModMenuName(), + (BaseUnityPlugin)plugin, + out menuElement + ); + } + private static SelectableElement GenerateToggle(IModMenuToggle plugin) { ChoiceElement element = new( diff --git a/Silksong.ModMenu/Registry.cs b/Silksong.ModMenu/Registry.cs index 5957203..02ab5d6 100644 --- a/Silksong.ModMenu/Registry.cs +++ b/Silksong.ModMenu/Registry.cs @@ -33,7 +33,7 @@ public static void AddModMenu(string name, MenuElementGenerator generator) => internal static IEnumerable GenerateAllMenuElements() { - List<(string, SelectableElement)> allElements = []; + List<(LocalizedText, SelectableElement)> allElements = []; foreach (var (name, gen) in modMenuGenerators) { ExceptionUtil.Try( @@ -62,6 +62,6 @@ internal static IEnumerable GenerateAllMenuElements() ); } - return [.. allElements.OrderBy(p => p.Item1.ToUpper()).Select(p => p.Item2)]; + return [.. allElements.OrderBy(p => p.Item1.Text).Select(p => p.Item2)]; } } diff --git a/Silksong.ModMenu/Screens/AbstractMenuScreen.cs b/Silksong.ModMenu/Screens/AbstractMenuScreen.cs index 56b8c08..886e519 100644 --- a/Silksong.ModMenu/Screens/AbstractMenuScreen.cs +++ b/Silksong.ModMenu/Screens/AbstractMenuScreen.cs @@ -22,7 +22,7 @@ public abstract class AbstractMenuScreen : MenuDisposable /// /// Construct a menu screen with the given title. /// - protected AbstractMenuScreen(string title) + protected AbstractMenuScreen(LocalizedText title) { Container = MenuPrefabs.Get().NewCustomMenu(title); MenuScreen = Container.GetComponent(); @@ -40,7 +40,7 @@ protected AbstractMenuScreen(string title) lateUpdate.OnLateUpdate += UpdateLayout; lateUpdate.OnLateUpdate += UpdateLastSelected; - TitleText.text = title; + TitleText.LocalizedText = title; } #endregion diff --git a/Silksong.ModMenu/Screens/BasicMenuScreen.cs b/Silksong.ModMenu/Screens/BasicMenuScreen.cs index 76e1c74..4a3bcf0 100644 --- a/Silksong.ModMenu/Screens/BasicMenuScreen.cs +++ b/Silksong.ModMenu/Screens/BasicMenuScreen.cs @@ -14,7 +14,7 @@ public class BasicMenuScreen : AbstractMenuScreen /// /// Construct a basic menu screen with a single content entity. /// - public BasicMenuScreen(string title, INavigableMenuEntity content) + public BasicMenuScreen(LocalizedText title, INavigableMenuEntity content) : base(title) => Content = content; /// diff --git a/Silksong.ModMenu/Screens/PaginatedMenuScreen.cs b/Silksong.ModMenu/Screens/PaginatedMenuScreen.cs index fd41f47..cd37588 100644 --- a/Silksong.ModMenu/Screens/PaginatedMenuScreen.cs +++ b/Silksong.ModMenu/Screens/PaginatedMenuScreen.cs @@ -20,7 +20,7 @@ public class PaginatedMenuScreen : AbstractMenuScreen /// /// Construct a PaginatedMenuScreen with the given title. /// - public PaginatedMenuScreen(string title) + public PaginatedMenuScreen(LocalizedText title) : base(title) { pageNumberModel = new(0, 0, 0) { Circular = true, DisplayFn = i => $"{i + 1}" }; diff --git a/Silksong.ModMenu/Screens/PaginatedMenuScreenBuilder.cs b/Silksong.ModMenu/Screens/PaginatedMenuScreenBuilder.cs index 17b69bb..1389a9a 100644 --- a/Silksong.ModMenu/Screens/PaginatedMenuScreenBuilder.cs +++ b/Silksong.ModMenu/Screens/PaginatedMenuScreenBuilder.cs @@ -8,9 +8,9 @@ namespace Silksong.ModMenu.Screens; /// /// A convience class for building a paginated menu screen from a stream of elements, grouping them into VerticalGroups. /// -public class PaginatedMenuScreenBuilder(string title, int pageSize = 8) +public class PaginatedMenuScreenBuilder(LocalizedText title, int pageSize = 8) { - private readonly string title = title; + private readonly LocalizedText title = title; private readonly int pageSize = pageSize; private readonly List menuElements = []; diff --git a/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs new file mode 100644 index 0000000..b57eb68 --- /dev/null +++ b/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs @@ -0,0 +1,34 @@ +using BepInEx; +using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Plugin; + +namespace Silksong.ModMenuTesting; + +// This test requires its own ConfigFile so it's not part of the general testing framework. +[BepInAutoPlugin(id: "org.silksong_modding.modmenunestedtesting")] +public partial class ModMenuNestedTestingPlugin : BaseUnityPlugin, IModMenuNestedMenu +{ + private void Awake() + { + Config.Bind(new("Main", "Int1"), 1, new("Root Integer 1")); + Config.Bind(new("Main", "Int2"), 2, new("Root Integer 2")); + // Place this in Main instead of its config path. + Config.Bind( + new("Other.Group", "Int3"), + 3, + new("Other Integer 3", tags: new ConfigEntrySubgroup(["Main", "Int3"])) + ); + + // We should generte a single 'Sub' button for the three below + Config.Bind(new("Main.Sub", "Int4"), 4, new("Sub Integer 4")); + Config.Bind(new("Main.Sub", "Int5"), 5, new("Sub Integer 5")); + // This gets flattened into the 'Sub' menu because there are not enough 'Sub.Sub' elements. + Config.Bind(new("Main.Sub.Sub", "Int6"), 6, new("Sub Sub Integer 6")); + + // Test + Config.Bind(new("Main.B.C", "Int7"), 7, new("ABC Integer 7")); + Config.Bind(new("Main.B.C", "Int8"), 8, new("ABC Integer 8")); + } + + public LocalizedText ModMenuName() => "Mod Menu Nested Testing"; +} diff --git a/Silksong.ModMenuTesting/ModMenuTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuTestingPlugin.cs index 12a656c..7d83523 100644 --- a/Silksong.ModMenuTesting/ModMenuTestingPlugin.cs +++ b/Silksong.ModMenuTesting/ModMenuTestingPlugin.cs @@ -68,5 +68,5 @@ public AbstractMenuScreen BuildCustomMenu() return builder.Build(); } - public string ModMenuName() => "Mod Menu Testing"; + public LocalizedText ModMenuName() => "Mod Menu Testing"; } diff --git a/Silksong.ModMenuTesting/Tests/ElementSizesTest.cs b/Silksong.ModMenuTesting/Tests/ElementSizesTest.cs index df0358c..61f3607 100644 --- a/Silksong.ModMenuTesting/Tests/ElementSizesTest.cs +++ b/Silksong.ModMenuTesting/Tests/ElementSizesTest.cs @@ -1,7 +1,7 @@ -using Silksong.ModMenu.Elements; -using Silksong.ModMenu.Screens; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Screens; namespace Silksong.ModMenuTesting.Tests; diff --git a/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs b/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs index 9c8df1a..302e5ea 100644 --- a/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs +++ b/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs @@ -1,9 +1,9 @@ -using Silksong.ModMenu.Elements; -using Silksong.ModMenu.Models; -using Silksong.ModMenu.Screens; -using System; +using System; using System.Collections.Generic; using System.Text; +using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Models; +using Silksong.ModMenu.Screens; namespace Silksong.ModMenuTesting.Tests; @@ -34,7 +34,11 @@ internal static IEnumerable CreateUnboundElements() { ListChoiceModel listChoiceModel = new(["First", "Second", "Third"]); - ChoiceElement choiceElement = new("The List Choice", listChoiceModel, "Here is where to choose option(s)"); + ChoiceElement choiceElement = new( + "The List Choice", + listChoiceModel, + "Here is where to choose option(s)" + ); listChoiceModel.OnValueChanged += v => Log($"List choice -> {v}"); yield return choiceElement; } @@ -53,7 +57,11 @@ internal static IEnumerable CreateUnboundElements() { ITextModel textModel = TextModels.ForStrings(); - TextInput stringInput = new("The Text Input", textModel, "Here is where to input text"); + TextInput stringInput = new( + "The Text Input", + textModel, + "Here is where to input text" + ); textModel.OnValueChanged += s => Log($"Text model -> {s}"); yield return stringInput; }