Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Silksong.ModMenu/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
<Version>0.4.6</Version>
<Version>0.5.0</Version>
</PropertyGroup>
</Project>
23 changes: 22 additions & 1 deletion Silksong.ModMenu/Elements/LocalizedText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Silksong.ModMenu.Elements;
/// </summary>
public class LocalizedText
{
private readonly TeamCherry.Localization.LocalisedString localisedString;
private readonly LocalisedString localisedString;
private readonly string rawText;

private LocalizedText(LocalisedString localisedString, string rawText)
Expand All @@ -24,6 +24,11 @@ private LocalizedText(LocalisedString localisedString, string rawText)
? rawText
: Language.Get(localisedString.Key, localisedString.Sheet);

/// <summary>
/// Get a canonical programmatic string for this LocalizedText that does not change when the language changes.
/// </summary>
public string Canonical => localisedString.IsEmpty ? rawText : localisedString.Key;

/// <summary>
/// Returns true if this object has localization support.
/// </summary>
Expand All @@ -49,6 +54,22 @@ private LocalizedText(LocalisedString localisedString, string rawText)
/// </summary>
public static LocalizedText Raw(string rawText) => new(new(), rawText);

/// <inheritdoc/>
public override int GetHashCode() =>
IsLocalized ? localisedString.GetHashCode() : rawText.GetHashCode();

/// <summary>
/// Equals. Ignores raw text if it does not apply.
/// </summary>
public override bool Equals(object obj) =>
obj is LocalizedText other
&& IsLocalized == other.IsLocalized
&& (
IsLocalized
? localisedString.Equals(other.localisedString)
: rawText.Equals(other.rawText)
);

/// <summary>
/// Implicit conversion for raw text to un-localized LocalizedText.
/// </summary>
Expand Down
5 changes: 3 additions & 2 deletions Silksong.ModMenu/Internal/MenuPrefabs.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);

Expand Down
54 changes: 54 additions & 0 deletions Silksong.ModMenu/Internal/TreeNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;

namespace Silksong.ModMenu.Internal;

/// <summary>
/// A recursive tree structure which supports lazy node creation and postfix traversal.
/// </summary>
internal class TreeNode<K, V>
where V : new()
{
private readonly Dictionary<K, TreeNode<K, V>> subtrees = [];

public V Value = new();

public IReadOnlyDictionary<K, TreeNode<K, V>> Subtrees => subtrees;

public TreeNode<K, V> this[IEnumerable<K> keys]
{
get
{
TreeNode<K, V> 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<K> keys,
Action<IReadOnlyList<K>, TreeNode<K, V>> 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<IReadOnlyList<K>, TreeNode<K, V>> action) =>
ForEachPostfixRecursive([], action);
}
124 changes: 116 additions & 8 deletions Silksong.ModMenu/Plugin/ConfigEntryFactory.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -48,32 +49,133 @@ public static void AddDefaultGenerator(MenuElementGenerator generator) =>
/// </summary>
public readonly List<MenuElementGenerator> Generators = [.. defaultGenerators];

/// <summary>
/// If true, organize elements heirarchically by the period-delimeted names of their config definitions.
/// </summary>
public bool GenerateSubgroups = false;

/// <summary>
/// The minimum number of elements required to generate a subgroup. Has no effect if GenerateSubgroups is false.
/// </summary>
public int MinSubgroupSize = 2;

private static IEnumerable<LocalizedText> SubgroupsFromConfig(ConfigDefinition config) =>
config.Section.Split('.').Concat(config.Key.Split('.')).Select(LocalizedText.Raw);

/// <summary>
/// The hierarchical subgroup names to use for each config entry.
/// </summary>
protected virtual IEnumerable<LocalizedText> GetSubgroupNames(
ConfigEntryBase config,
MenuElement menuElement
)
{
var subgroups = config.Description.Tags.OfType<ConfigEntrySubgroup>().FirstOrDefault();
return subgroups?.Subgroups
?? (GenerateSubgroups ? SubgroupsFromConfig(config.Definition) : []);
}

private static void FindFirstNonEmptyChild(
ref TreeNode<LocalizedText, ElementTreeNode> tree,
List<LocalizedText> 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<LocalizedText> subpageNames,
TreeNode<LocalizedText, ElementTreeNode> 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<LocalizedText> subpageNames,
TreeNode<LocalizedText, ElementTreeNode> 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();
}

/// <summary>
/// Generate a button for this plugin which opens a sub-menu for its ConfigFile.
/// </summary>
public virtual bool GenerateEntryButton(
string name,
LocalizedText name,
BaseUnityPlugin plugin,
[MaybeNullWhen(false)] out SelectableElement selectableElement
)
{
List<MenuElement> elements = [];
foreach (var entry in plugin.Config)
TreeNode<LocalizedText, ElementTreeNode> 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<LocalizedText> subpageNames = [];
FindFirstNonEmptyChild(ref elementsTree, subpageNames);

var menu = BuildSubtreeScreen(name, subpageNames, elementsTree);
selectableElement = new TextButton(name)
{
OnSubmit = () => MenuScreenNavigation.Show(menu),
Expand Down Expand Up @@ -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;
}
}

/// <summary>
Expand Down
31 changes: 31 additions & 0 deletions Silksong.ModMenu/Plugin/ConfigEntrySubgroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using Silksong.ModMenu.Elements;

namespace Silksong.ModMenu.Plugin;

/// <summary>
/// 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.
/// </summary>
public record ConfigEntrySubgroup
{
/// <summary>
/// Construct subgroup names from a list.
/// </summary>
public ConfigEntrySubgroup(IEnumerable<LocalizedText> subgroups) => Subgroups = [.. subgroups];

/// <summary>
/// Construct subgroup names from explicit parameters.
/// </summary>
public ConfigEntrySubgroup(LocalizedText name1, params LocalizedText[] otherNames) =>
Subgroups = [name1, .. otherNames];

/// <summary>
/// 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.
/// </summary>
public readonly IReadOnlyList<LocalizedText> Subgroups;
Comment thread
dplochcoder marked this conversation as resolved.
}
9 changes: 7 additions & 2 deletions Silksong.ModMenu/Plugin/IModMenuInterface.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
namespace Silksong.ModMenu.Plugin;
using System.Reflection;
using BepInEx;
using Silksong.ModMenu.Elements;

namespace Silksong.ModMenu.Plugin;

/// <summary>
/// Marker interface for all plugin interfaces intended to generate mod menus.
Expand All @@ -8,5 +12,6 @@ public interface IModMenuInterface
/// <summary>
/// A unique identifier for this mod menu, used as a case-insensitive sort key.
/// </summary>
public string ModMenuName();
public LocalizedText ModMenuName() =>
GetType().GetCustomAttribute<BepInPlugin>()?.Name ?? "###UNKNOWN###";
}
13 changes: 13 additions & 0 deletions Silksong.ModMenu/Plugin/IModMenuNestedMenu.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Silksong.ModMenu.Plugin;

/// <summary>
/// Marker interface for a mod menu that generates hierarchical submenus, based on config keys.
/// </summary>
public interface IModMenuNestedMenu : IModMenuInterface
{
/// <summary>
/// The minimum number of elements required in a subgroup to generate a screen for it.
Comment thread
dplochcoder marked this conversation as resolved.
/// If a sub-page has too few elements, it is flattened into its containing page.
/// </summary>
int MinSubgroupSize() => 2;
}
Comment thread
dplochcoder marked this conversation as resolved.
2 changes: 1 addition & 1 deletion Silksong.ModMenu/Plugin/MenuElementGenerators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ out ListChoiceModel<object>? model
/// </summary>
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;

Expand Down
Loading