From d4da4ce681e880a812506d6174b224d322576ae3 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Mon, 27 Apr 2026 15:15:56 -0400 Subject: [PATCH 01/11] add TextModels.ForHexColors() and ColorInput element --- Silksong.ModMenu/Elements/ColorInput.cs | 112 ++++++++++++++++++ Silksong.ModMenu/Internal/CustomInputField.cs | 13 +- Silksong.ModMenu/Internal/GameObjectUtil.cs | 21 +++- Silksong.ModMenu/Internal/MenuPrefabs.cs | 52 ++++++++ Silksong.ModMenu/Internal/StringUtil.cs | 6 +- Silksong.ModMenu/Models/TextModels.cs | 54 +++++++++ .../Tests/StandardElementsTest.cs | 12 +- 7 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 Silksong.ModMenu/Elements/ColorInput.cs diff --git a/Silksong.ModMenu/Elements/ColorInput.cs b/Silksong.ModMenu/Elements/ColorInput.cs new file mode 100644 index 0000000..ec1b97b --- /dev/null +++ b/Silksong.ModMenu/Elements/ColorInput.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Linq; +using Silksong.ModMenu.Internal; +using Silksong.ModMenu.Models; +using Silksong.UnityHelper.Extensions; +using UnityEngine; +using UnityEngine.UI; + +namespace Silksong.ModMenu.Elements; + +/// +/// Selectable element that accepts input in 3, 6, or 8 character hex strings. +/// Includes a preview swatch beside the hex code. +/// +public class ColorInput : TextInput +{ + /// + /// Construct a color input with no description. + /// + public ColorInput(LocalizedText label) + : this(label, (LocalizedText)"") { } + + /// + /// Construct a color input with a description. + /// + public ColorInput(LocalizedText label, LocalizedText description) + : base(label, TextModels.ForHexColors(), description) + { + Container.name = $"{label.Canonical} Color Input"; + InputField.contentType = InputField.ContentType.Custom; + InputField.onValidateInput = HexValidation; + ApplyDefaultColors = true; + Format = InputFormat.RGBA; + + Swatch = MenuPrefabs.Get().NewColorSwatch(); + Swatch.transform.SetParent(InputField.textComponent.transform, false); + Swatch.SetActive(true); + SwatchFill = Swatch.FindChild("Fill")!.GetComponent(); + SwatchOutline = Swatch.FindChild("Outline")!.GetComponent(); + InvalidValueIndicator = Swatch.FindChild("Invalid Indicator")!.GetComponent(); + + OnTextValueChanged += _ => + { + SwatchFill.color = Value; + State = TextModel.IsTextValid ? ElementState.DEFAULT : ElementState.INVALID; + InvalidValueIndicator.enabled = !TextModel.IsTextValid; + }; + + Value = Color.clear; + } + + /// + /// Whether or not this input accepts 8-character RGBA strings. + /// + public InputFormat Format + { + get => field; + set + { + field = value; + InputField.characterLimit = (int)field; + + // force the field to re-clamp the length of the current text value + string val = InputField.text; + InputField.SetTextWithoutNotify(""); + InputField.text = val; + } + } + + /// + /// Semantic input formats for s. + /// + public enum InputFormat + { + /// + /// The input accepts at most 6-character RGB hex codes. + /// + RGB = 6, + + /// + /// The input accepts at most 8-character RGBA hex codes. + /// + RGBA = 8, + } + + /// + /// The GameObject that controls the size and position of the preview swatch. + /// + public readonly GameObject Swatch; + + /// + /// The unity component for the filled area of the preview swatch. + /// + public readonly Image SwatchFill; + + /// + /// The unity component for the outline around the preview swatch. + /// + public readonly Image SwatchOutline; + + /// + /// The unity component for the symbol that appears in the preview swatch + /// to visually indicate an invalid value. + /// + public readonly Text InvalidValueIndicator; + + /// + /// validation for hex codes; only accepts characters a-fA-F0-7. + /// + static char HexValidation(string input, int index, char addedChar) => + $"{addedChar}".TryParseHex(out _) ? char.ToUpper(addedChar) : '0'; +} diff --git a/Silksong.ModMenu/Internal/CustomInputField.cs b/Silksong.ModMenu/Internal/CustomInputField.cs index f55a599..fd08d75 100644 --- a/Silksong.ModMenu/Internal/CustomInputField.cs +++ b/Silksong.ModMenu/Internal/CustomInputField.cs @@ -23,16 +23,17 @@ internal class CustomInputField : InputField { base.Awake(); textRect = textComponent.gameObject.GetComponent(); + textComponent.alignment = TextAnchor.MiddleRight; + textRect.anchorMin = textRect.anchorMin with { x = 1 }; + textRect.anchorMax = textRect.anchorMax with { x = 1 }; + textRect.pivot = textRect.pivot with { x = 1 }; + textRect.anchoredPosition = Vector2.zero; } private void Update() { - if (textRect == null) - return; - - var width = Mathf.Max(200, preferredWidth); - textRect.offsetMin = new(-width, 0); - textRect.sizeDelta = new(width, 0); + if (textRect) + textRect.sizeDelta = new(Mathf.Max(200, preferredWidth), 0); } private bool AllSelected() => diff --git a/Silksong.ModMenu/Internal/GameObjectUtil.cs b/Silksong.ModMenu/Internal/GameObjectUtil.cs index 3ea20d9..72aedb4 100644 --- a/Silksong.ModMenu/Internal/GameObjectUtil.cs +++ b/Silksong.ModMenu/Internal/GameObjectUtil.cs @@ -4,14 +4,25 @@ namespace Silksong.ModMenu.Internal; internal static class GameObjectUtil { - internal static void DestroyAllChildren(this GameObject self) + extension(GameObject self) { - for (int i = self.transform.childCount - 1; i >= 0; i--) + internal void DestroyAllChildren() { - var obj = self.transform.GetChild(i).gameObject; - obj.transform.SetParent(null); - Object.Destroy(obj); + for (int i = self.transform.childCount - 1; i >= 0; i--) + { + var obj = self.transform.GetChild(i).gameObject; + obj.transform.SetParent(null); + Object.Destroy(obj); + } } + + /// + /// Casts the object's transform to a . + /// + /// + /// If the object doesn't actually have a RectTransform. + /// + internal RectTransform RectTransform => (RectTransform)self.transform; } private class InactiveScope : System.IDisposable diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 7312708..5d4cb2b 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -30,6 +30,7 @@ internal static MenuPrefabs Get() => private readonly GameObject textInputTemplate; private readonly GameObject sliderTemplate; private readonly GameObject scrollPaneTemplate; + private readonly GameObject colorSwatchTemplate; private MenuPrefabs(UIManager uiManager) { @@ -163,6 +164,55 @@ private MenuPrefabs(UIManager uiManager) sliderChild.FindChild("MasterVolValue")!.name = "Value"; scrollPaneTemplate = ConstructScrollPanePrefab(uiManager); + + { + colorSwatchTemplate = new GameObject("Color Swatch") { layer = (int)PhysLayers.UI }; + colorSwatchTemplate.SetActive(false); + Object.DontDestroyOnLoad(colorSwatchTemplate); + var swatchRT = colorSwatchTemplate.AddComponent(); + swatchRT.sizeDelta = Vector2.one * 70; + swatchRT.anchorMax = swatchRT.anchorMin = new Vector2(0, 0.5f); + swatchRT.pivot = new Vector2(1, 0.5f); + swatchRT.anchoredPosition = new Vector2(-35, 0); + + Transform journalIcon = GameCameras.instance.hudCamera.transform.Find( + "In-game/Inventory/Journal/Enemy List Parent/Enemy List/Template Journal Entry" + ); + + var fill = new GameObject("Fill") { layer = (int)PhysLayers.UI }; + fill.transform.SetParentReset(swatchRT); + var imgF = fill.AddComponent(); + imgF.sprite = journalIcon.Find("Mask").GetComponent().sprite; + imgF.preserveAspect = true; + fill.RectTransform.FitToParent(); + + var line = new GameObject("Outline") { layer = (int)PhysLayers.UI }; + line.transform.SetParentReset(swatchRT); + var imgL = line.AddComponent(); + imgL.sprite = journalIcon.Find("Standard Frame").GetComponent().sprite; + imgL.preserveAspect = true; + line.AddComponent().effectColor = Color.white with { a = 0.5f }; + line.RectTransform.FitToParent(); + + var indicator = new GameObject("Invalid Indicator") { layer = (int)PhysLayers.UI }; + indicator.transform.SetParentReset(swatchRT); + var text = indicator.AddComponent(); + text.enabled = false; + text.text = "?"; + text.font = textLabelTemplate.GetComponent().font; + text.alignByGeometry = true; + text.alignment = TextAnchor.MiddleCenter; + text.horizontalOverflow = HorizontalWrapMode.Overflow; + text.verticalOverflow = VerticalWrapMode.Truncate; + text.resizeTextForBestFit = true; + text.resizeTextMaxSize = 100; + text.resizeTextMinSize = 10; + text.fontSize = 0; + var indicatorRT = indicator.RectTransform; + indicatorRT.sizeDelta = Vector2.zero; + indicatorRT.anchorMax = new Vector2(0.52f, 0.8f); + indicatorRT.anchorMin = new Vector2(0.52f, 0.2f); + } } internal GameObject NewCustomMenu(LocalizedText title) @@ -205,6 +255,8 @@ internal GameObject NewTextInputContainer(out CustomInputField customInputField) return obj; } + internal GameObject NewColorSwatch() => Object.Instantiate(colorSwatchTemplate); + internal GameObject NewSliderContainer(out Slider slider) { var obj = Object.Instantiate(sliderTemplate); diff --git a/Silksong.ModMenu/Internal/StringUtil.cs b/Silksong.ModMenu/Internal/StringUtil.cs index c164cc7..95ddf5f 100644 --- a/Silksong.ModMenu/Internal/StringUtil.cs +++ b/Silksong.ModMenu/Internal/StringUtil.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Globalization; +using System.Text; namespace Silksong.ModMenu.Internal; @@ -50,4 +51,7 @@ internal static string UnCamelCase(this string self) return sb.ToString(); } + + internal static bool TryParseHex(this string self, out byte n) => + byte.TryParse(self, NumberStyles.HexNumber, null, out n); } diff --git a/Silksong.ModMenu/Models/TextModels.cs b/Silksong.ModMenu/Models/TextModels.cs index b96cee4..6e91719 100644 --- a/Silksong.ModMenu/Models/TextModels.cs +++ b/Silksong.ModMenu/Models/TextModels.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using Silksong.ModMenu.Internal; +using UnityEngine; namespace Silksong.ModMenu.Models; @@ -64,4 +67,55 @@ bool Check(T value) return Check; } + + /// + /// An ITextModel which parses 3, 6, or 8 character hex strings to and from s. + /// + public static ParserTextModel ForHexColors() => + new(HexParser, HexUnparser, INVALID_COLOR); + + private static readonly Color INVALID_COLOR = new(-1, -1, -1, -1); + + private static bool HexParser(string x, out Color c) + { + if (x.Length == 8 && x[6..8].TryParseHex(out byte a)) { } + else + a = byte.MaxValue; + + if ( + (x.Length == 6 || x.Length == 8) + && x[0..2].TryParseHex(out byte r) + && x[2..4].TryParseHex(out byte g) + && x[4..6].TryParseHex(out byte b) + ) + { + c = new Color32(r, g, b, a); + return true; + } + else if ( + x.Length == 3 + && $"{x[0]}{x[0]}".TryParseHex(out r) + && $"{x[1]}{x[1]}".TryParseHex(out g) + && $"{x[2]}{x[2]}".TryParseHex(out b) + ) + { + c = new Color32(r, g, b, a); + return true; + } + + c = INVALID_COLOR; + return false; + } + + private static bool HexUnparser(Color c, out string x) + { + if (!Enumerable.Range(0, 3).Any(i => c[i] < 0 || 1 < c[i])) + { + Color32 c32 = c; + x = $"{c32.r:X2}{c32.g:X2}{c32.b:X2}{c32.a:X2}"; + return true; + } + x = "###"; + return false; + } } diff --git a/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs b/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs index 9fbde99..a228d49 100644 --- a/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs +++ b/Silksong.ModMenuTesting/Tests/StandardElementsTest.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Elements; using Silksong.ModMenu.Models; using Silksong.ModMenu.Screens; +using System.Collections.Generic; namespace Silksong.ModMenuTesting.Tests; @@ -65,6 +63,12 @@ internal static IEnumerable CreateUnboundElements() textModel.OnValueChanged += s => Log($"Text model -> {s}"); yield return stringInput; } + + { + ColorInput colorInput = new("Colour RGBA", "You can input a colour here"); + colorInput.Model.OnValueChanged += c => Log($"Colour model -> {c}"); + yield return colorInput; + } } internal override AbstractMenuScreen BuildMenuScreen() From 325b53258e2bca0e89a83e27ace3eab65cd5dcf5 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Mon, 27 Apr 2026 18:29:04 -0400 Subject: [PATCH 02/11] add generator for ColorInput & test for automatic menu creation --- Silksong.ModMenu/Plugin/ConfigEntryFactory.cs | 22 ++++++++++ .../ModMenuAutoTestingPlugin.cs | 42 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs diff --git a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs index 254c45f..14d9cad 100644 --- a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs +++ b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs @@ -36,6 +36,7 @@ public delegate bool MenuElementGenerator( GenerateIntElement, GenerateFloatElement, GenerateStringElement, + GenerateColorElement, ]; /// @@ -419,6 +420,27 @@ public static bool GenerateStringElement( return true; } + /// + /// Generate a text element for a color. + /// + public static bool GenerateColorElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry colorEntry) + { + menuElement = default; + return false; + } + + ColorInput color = new(entry.LabelName(), entry.DescriptionLine()); + color.SynchronizeWith(colorEntry); + + menuElement = color; + return true; + } + private record ElementTreeNode { public readonly List<(string path, MenuElement element)> Elements = []; diff --git a/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs new file mode 100644 index 0000000..67f65f6 --- /dev/null +++ b/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs @@ -0,0 +1,42 @@ +using BepInEx; +using UnityEngine; + +namespace Silksong.ModMenuTesting; + +[BepInAutoPlugin(id: "org.silksong_modding.modmenuautotesting", name: "ModMenuAutoTesting")] +public partial class ModMenuAutoTestingPlugin : BaseUnityPlugin +{ + public enum TestEnum { EnumOne, EnumTwo, EnumThree } + + private void Awake() + { + // All types supported by the bepinex config system + + + Config.Bind("Unity Types", "KeyCode Option", KeyCode.A); + Config.Bind("Unity Types", "Color Option", Color.green); + Config.Bind("Unity Types", "Vector2 Option", Vector2.one); // not done + Config.Bind("Unity Types", "Vector3 Option", Vector3.one); // not done + Config.Bind("Unity Types", "Vector4 Option", Vector4.one); // not done + Config.Bind("Unity Types", "Quaternion Option", Quaternion.identity); // not done + + + Config.Bind("Value Types", "String Option", "value"); + Config.Bind("Value Types", "Enum Option", TestEnum.EnumOne); + Config.Bind("Value Types", "Bool Option", true); + Config.Bind("Value Types", "Byte Option", (byte)0); // not done + Config.Bind("Value Types", "SByte Option", (sbyte)0); // not done + Config.Bind("Value Types", "Short Option", (short)0); // not done + Config.Bind("Value Types", "UShort Option", (ushort)0); // not done + Config.Bind("Value Types", "Int Option", 0); + Config.Bind("Value Types", "UInt Option", 0u); // not done + Config.Bind("Value Types", "Long Option", 0L); // not done + Config.Bind("Value Types", "ULong Option", 0UL); // not done + Config.Bind("Value Types", "Float Option", 0.0f); + Config.Bind("Value Types", "Double Option", 0.0d); // not done + Config.Bind("Value Types", "Decimal Option", 0.0m); // not done + + + Logger.LogInfo($"Plugin {Name} ({Id}) has loaded!"); + } +} From b681937f4a8cad882f02b05959356d879f57844f Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Mon, 27 Apr 2026 18:39:37 -0400 Subject: [PATCH 03/11] bump version to v0.6.1 --- Silksong.ModMenu/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Silksong.ModMenu/Directory.Build.props b/Silksong.ModMenu/Directory.Build.props index 0f5b4d1..9cf9633 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.6.0 + 0.6.1 From 27a3415de8a741b0f5317d35f571027cf80d9dbf Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Mon, 27 Apr 2026 19:19:01 -0400 Subject: [PATCH 04/11] fix mixed spacing in auto menu test --- .../ModMenuAutoTestingPlugin.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs index 67f65f6..067b54e 100644 --- a/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs +++ b/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs @@ -15,28 +15,28 @@ private void Awake() Config.Bind("Unity Types", "KeyCode Option", KeyCode.A); Config.Bind("Unity Types", "Color Option", Color.green); - Config.Bind("Unity Types", "Vector2 Option", Vector2.one); // not done - Config.Bind("Unity Types", "Vector3 Option", Vector3.one); // not done - Config.Bind("Unity Types", "Vector4 Option", Vector4.one); // not done - Config.Bind("Unity Types", "Quaternion Option", Quaternion.identity); // not done + Config.Bind("Unity Types", "Vector2 Option", Vector2.one); // not done + Config.Bind("Unity Types", "Vector3 Option", Vector3.one); // not done + Config.Bind("Unity Types", "Vector4 Option", Vector4.one); // not done + Config.Bind("Unity Types", "Quaternion Option", Quaternion.identity); // not done Config.Bind("Value Types", "String Option", "value"); Config.Bind("Value Types", "Enum Option", TestEnum.EnumOne); Config.Bind("Value Types", "Bool Option", true); - Config.Bind("Value Types", "Byte Option", (byte)0); // not done - Config.Bind("Value Types", "SByte Option", (sbyte)0); // not done - Config.Bind("Value Types", "Short Option", (short)0); // not done - Config.Bind("Value Types", "UShort Option", (ushort)0); // not done - Config.Bind("Value Types", "Int Option", 0); - Config.Bind("Value Types", "UInt Option", 0u); // not done - Config.Bind("Value Types", "Long Option", 0L); // not done - Config.Bind("Value Types", "ULong Option", 0UL); // not done - Config.Bind("Value Types", "Float Option", 0.0f); - Config.Bind("Value Types", "Double Option", 0.0d); // not done - Config.Bind("Value Types", "Decimal Option", 0.0m); // not done - - - Logger.LogInfo($"Plugin {Name} ({Id}) has loaded!"); + Config.Bind("Value Types", "Byte Option", (byte)0); // not done + Config.Bind("Value Types", "SByte Option", (sbyte)0); // not done + Config.Bind("Value Types", "Short Option", (short)0); // not done + Config.Bind("Value Types", "UShort Option", (ushort)0); // not done + Config.Bind("Value Types", "Int Option", 0); + Config.Bind("Value Types", "UInt Option", 0u); // not done + Config.Bind("Value Types", "Long Option", 0L); // not done + Config.Bind("Value Types", "ULong Option", 0UL); // not done + Config.Bind("Value Types", "Float Option", 0.0f); + Config.Bind("Value Types", "Double Option", 0.0d); // not done + Config.Bind("Value Types", "Decimal Option", 0.0m); // not done + + + Logger.LogInfo($"Plugin {Name} ({Id}) has loaded!"); } } From 9849f868adec38332acb66968ef552edc3e21936 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Mon, 27 Apr 2026 19:53:39 -0400 Subject: [PATCH 05/11] ColorInput now gives access to swatch's RectTransform Probably more useful than the GameObject since the point of having a field for it is to allow for changing the size/position of the swatch --- Silksong.ModMenu/Elements/ColorInput.cs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/Silksong.ModMenu/Elements/ColorInput.cs b/Silksong.ModMenu/Elements/ColorInput.cs index ec1b97b..47109d5 100644 --- a/Silksong.ModMenu/Elements/ColorInput.cs +++ b/Silksong.ModMenu/Elements/ColorInput.cs @@ -1,8 +1,5 @@ -using System.Globalization; -using System.Linq; -using Silksong.ModMenu.Internal; +using Silksong.ModMenu.Internal; using Silksong.ModMenu.Models; -using Silksong.UnityHelper.Extensions; using UnityEngine; using UnityEngine.UI; @@ -32,12 +29,12 @@ public ColorInput(LocalizedText label, LocalizedText description) ApplyDefaultColors = true; Format = InputFormat.RGBA; - Swatch = MenuPrefabs.Get().NewColorSwatch(); - Swatch.transform.SetParent(InputField.textComponent.transform, false); - Swatch.SetActive(true); - SwatchFill = Swatch.FindChild("Fill")!.GetComponent(); - SwatchOutline = Swatch.FindChild("Outline")!.GetComponent(); - InvalidValueIndicator = Swatch.FindChild("Invalid Indicator")!.GetComponent(); + Swatch = MenuPrefabs.Get().NewColorSwatch().RectTransform; + Swatch.SetParent(InputField.textComponent.transform, false); + Swatch.gameObject.SetActive(true); + SwatchFill = Swatch.Find("Fill").GetComponent(); + SwatchOutline = Swatch.Find("Outline").GetComponent(); + InvalidValueIndicator = Swatch.Find("Invalid Indicator").GetComponent(); OnTextValueChanged += _ => { @@ -84,9 +81,9 @@ public enum InputFormat } /// - /// The GameObject that controls the size and position of the preview swatch. + /// The unity component that controls the size and position of the preview swatch. /// - public readonly GameObject Swatch; + public readonly RectTransform Swatch; /// /// The unity component for the filled area of the preview swatch. From 8368530d108e2e6832633667897510253de08aa6 Mon Sep 17 00:00:00 2001 From: flibber-hk <76987839+flibber-hk@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:44:50 +0100 Subject: [PATCH 06/11] Plugin proxy test This to reduce the menu burden of the test plugin --- Silksong.ModMenu/Plugin/ConfigEntryFactory.cs | 16 ++++++- .../BaseProxyPluginTest.cs | 38 +++++++++++++++++ .../ModMenuAutoTestingPlugin.cs | 42 ------------------- .../ModMenuNestedTestingPlugin.cs | 34 --------------- .../Tests/ModMenuAutoTestingPlugin.cs | 36 ++++++++++++++++ .../Tests/ModMenuNestedTestingPlugin.cs | 36 ++++++++++++++++ 6 files changed, 124 insertions(+), 78 deletions(-) create mode 100644 Silksong.ModMenuTesting/BaseProxyPluginTest.cs delete mode 100644 Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs delete mode 100644 Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs create mode 100644 Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs create mode 100644 Silksong.ModMenuTesting/Tests/ModMenuNestedTestingPlugin.cs diff --git a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs index 14d9cad..3eadc01 100644 --- a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs +++ b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs @@ -144,10 +144,20 @@ public virtual bool GenerateEntryButton( LocalizedText name, BaseUnityPlugin plugin, [MaybeNullWhen(false)] out SelectableElement selectableElement + ) => GenerateEntryButton(name, plugin.Config, out _, out selectableElement); + + /// + /// Generate a button which opens a sub-menu for this ConfigFile. + /// + public virtual bool GenerateEntryButton( + LocalizedText name, + ConfigFile config, + [MaybeNullWhen(false)] out AbstractMenuScreen menuScreen, + [MaybeNullWhen(false)] out SelectableElement selectableElement ) { TreeNode elementsTree = new(); - foreach (var entry in plugin.Config.OrderBy(e => e.Key.Key)) + foreach (var entry in config.OrderBy(e => e.Key.Key)) { if (GenerateMenuElement(entry.Value, out var element)) { @@ -169,6 +179,7 @@ public virtual bool GenerateEntryButton( ); if (elementsTree.Value.TotalElements == 0) { + menuScreen = default; selectableElement = default; return false; } @@ -177,7 +188,8 @@ public virtual bool GenerateEntryButton( List subpageNames = []; FindFirstNonEmptyChild(ref elementsTree, subpageNames); - var menu = BuildSubtreeScreen(name, subpageNames, elementsTree); + AbstractMenuScreen menu = BuildSubtreeScreen(name, subpageNames, elementsTree); + menuScreen = menu; selectableElement = new TextButton(name) { OnSubmit = () => MenuScreenNavigation.Show(menu), diff --git a/Silksong.ModMenuTesting/BaseProxyPluginTest.cs b/Silksong.ModMenuTesting/BaseProxyPluginTest.cs new file mode 100644 index 0000000..c35b82b --- /dev/null +++ b/Silksong.ModMenuTesting/BaseProxyPluginTest.cs @@ -0,0 +1,38 @@ +using BepInEx.Configuration; +using Silksong.ModMenu.Plugin; +using Silksong.ModMenu.Screens; +using System; +using System.IO; + +namespace Silksong.ModMenuTesting; + +internal abstract class BaseProxyPluginTest : ModMenuTest +{ + private ConfigFile config; + + public BaseProxyPluginTest(string pluginId) + { + string configFolder = Path.GetDirectoryName(GetType().Assembly.Location); + config = new( + Path.Combine(configFolder, pluginId + ".cfg"), + true + ); + + Setup(config); + } + + protected virtual ConfigEntryFactory GetFactory() => new(); + + protected abstract void Setup(ConfigFile config); + + internal override AbstractMenuScreen BuildMenuScreen() + { + ConfigEntryFactory factory = GetFactory(); + if (!factory.GenerateEntryButton(Name, config, out AbstractMenuScreen? screen, out _)) + { + throw new ArgumentException($"No menu created for {Name}"); + } + + return screen; + } +} diff --git a/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs deleted file mode 100644 index 067b54e..0000000 --- a/Silksong.ModMenuTesting/ModMenuAutoTestingPlugin.cs +++ /dev/null @@ -1,42 +0,0 @@ -using BepInEx; -using UnityEngine; - -namespace Silksong.ModMenuTesting; - -[BepInAutoPlugin(id: "org.silksong_modding.modmenuautotesting", name: "ModMenuAutoTesting")] -public partial class ModMenuAutoTestingPlugin : BaseUnityPlugin -{ - public enum TestEnum { EnumOne, EnumTwo, EnumThree } - - private void Awake() - { - // All types supported by the bepinex config system - - - Config.Bind("Unity Types", "KeyCode Option", KeyCode.A); - Config.Bind("Unity Types", "Color Option", Color.green); - Config.Bind("Unity Types", "Vector2 Option", Vector2.one); // not done - Config.Bind("Unity Types", "Vector3 Option", Vector3.one); // not done - Config.Bind("Unity Types", "Vector4 Option", Vector4.one); // not done - Config.Bind("Unity Types", "Quaternion Option", Quaternion.identity); // not done - - - Config.Bind("Value Types", "String Option", "value"); - Config.Bind("Value Types", "Enum Option", TestEnum.EnumOne); - Config.Bind("Value Types", "Bool Option", true); - Config.Bind("Value Types", "Byte Option", (byte)0); // not done - Config.Bind("Value Types", "SByte Option", (sbyte)0); // not done - Config.Bind("Value Types", "Short Option", (short)0); // not done - Config.Bind("Value Types", "UShort Option", (ushort)0); // not done - Config.Bind("Value Types", "Int Option", 0); - Config.Bind("Value Types", "UInt Option", 0u); // not done - Config.Bind("Value Types", "Long Option", 0L); // not done - Config.Bind("Value Types", "ULong Option", 0UL); // not done - Config.Bind("Value Types", "Float Option", 0.0f); - Config.Bind("Value Types", "Double Option", 0.0d); // not done - Config.Bind("Value Types", "Decimal Option", 0.0m); // not done - - - Logger.LogInfo($"Plugin {Name} ({Id}) has loaded!"); - } -} diff --git a/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs b/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs deleted file mode 100644 index b57eb68..0000000 --- a/Silksong.ModMenuTesting/ModMenuNestedTestingPlugin.cs +++ /dev/null @@ -1,34 +0,0 @@ -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/Tests/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs new file mode 100644 index 0000000..aeb0a1a --- /dev/null +++ b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs @@ -0,0 +1,36 @@ +using BepInEx.Configuration; +using UnityEngine; + +namespace Silksong.ModMenuTesting; + +internal class ModMenuAutoTestingPlugin() : BaseProxyPluginTest("org.silksong_modding.modmenuautotesting") +{ + internal override string Name => "ModMenuAutoTesting"; + + public enum TestEnum { EnumOne, EnumTwo, EnumThree } + + protected override void Setup(ConfigFile config) + { + config.Bind("Unity Types", "KeyCode Option", KeyCode.A); + config.Bind("Unity Types", "Color Option", Color.green); + config.Bind("Unity Types", "Vector2 Option", Vector2.one); // not done + config.Bind("Unity Types", "Vector3 Option", Vector3.one); // not done + config.Bind("Unity Types", "Vector4 Option", Vector4.one); // not done + config.Bind("Unity Types", "Quaternion Option", Quaternion.identity); // not done + + + config.Bind("Value Types", "String Option", "value"); + config.Bind("Value Types", "Enum Option", TestEnum.EnumOne); + config.Bind("Value Types", "Bool Option", true); + config.Bind("Value Types", "Byte Option", (byte)0); // not done + config.Bind("Value Types", "SByte Option", (sbyte)0); // not done + config.Bind("Value Types", "Short Option", (short)0); // not done + config.Bind("Value Types", "UShort Option", (ushort)0); // not done + config.Bind("Value Types", "Int Option", 0); + config.Bind("Value Types", "UInt Option", 0u); // not done + config.Bind("Value Types", "Long Option", 0L); // not done + config.Bind("Value Types", "ULong Option", 0UL); // not done + config.Bind("Value Types", "Float Option", 0.0f); + config.Bind("Value Types", "Double Option", 0.0d); // not done + config.Bind("Value Types", "Decimal Option", 0.0m); // not done } + } diff --git a/Silksong.ModMenuTesting/Tests/ModMenuNestedTestingPlugin.cs b/Silksong.ModMenuTesting/Tests/ModMenuNestedTestingPlugin.cs new file mode 100644 index 0000000..291746a --- /dev/null +++ b/Silksong.ModMenuTesting/Tests/ModMenuNestedTestingPlugin.cs @@ -0,0 +1,36 @@ +using BepInEx.Configuration; +using Silksong.ModMenu.Plugin; + +namespace Silksong.ModMenuTesting; + +internal class ModMenuNestedTestingPlugin() : BaseProxyPluginTest("org.silksong_modding.modmenunestedtesting") +{ + protected override void Setup(ConfigFile config) + { + 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")); + } + + internal override string Name => "Mod Menu Nested Testing"; + + protected override ConfigEntryFactory GetFactory() + { + return new() { GenerateSubgroups = true }; + } +} From f5e9e9007e7232d10b1422bb9054778bedf8ec3d Mon Sep 17 00:00:00 2001 From: flibber-hk <76987839+flibber-hk@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:45:28 +0100 Subject: [PATCH 07/11] Clean up merge --- Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs index aeb0a1a..44b3822 100644 --- a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs +++ b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs @@ -34,3 +34,4 @@ protected override void Setup(ConfigFile config) config.Bind("Value Types", "Double Option", 0.0d); // not done config.Bind("Value Types", "Decimal Option", 0.0m); // not done } } +} \ No newline at end of file From 9a216c461692c1790725a28b896570e1dcb6f77b Mon Sep 17 00:00:00 2001 From: flibber-hk <76987839+flibber-hk@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:46:53 +0100 Subject: [PATCH 08/11] Un-bump version See: 3 other open PRs zotesip --- Silksong.ModMenu/Directory.Build.props | 2 +- Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Silksong.ModMenu/Directory.Build.props b/Silksong.ModMenu/Directory.Build.props index 9cf9633..0f5b4d1 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.6.1 + 0.6.0 diff --git a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs index 44b3822..025fddb 100644 --- a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs +++ b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs @@ -32,6 +32,6 @@ protected override void Setup(ConfigFile config) config.Bind("Value Types", "ULong Option", 0UL); // not done config.Bind("Value Types", "Float Option", 0.0f); config.Bind("Value Types", "Double Option", 0.0d); // not done - config.Bind("Value Types", "Decimal Option", 0.0m); // not done } + config.Bind("Value Types", "Decimal Option", 0.0m); // not done } } \ No newline at end of file From 473f5d0a39dcad0198fb96048364791d83f15749 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Tue, 28 Apr 2026 22:26:49 -0400 Subject: [PATCH 09/11] ColorInput swatch now resizes when font size is set --- Silksong.ModMenu/Elements/ColorInput.cs | 22 +++++++++++++++++++++- Silksong.ModMenu/Internal/MenuPrefabs.cs | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Silksong.ModMenu/Elements/ColorInput.cs b/Silksong.ModMenu/Elements/ColorInput.cs index 47109d5..3d8b7c1 100644 --- a/Silksong.ModMenu/Elements/ColorInput.cs +++ b/Silksong.ModMenu/Elements/ColorInput.cs @@ -11,11 +11,17 @@ namespace Silksong.ModMenu.Elements; /// public class ColorInput : TextInput { + /// + /// The relative size of the Swatch to the choice text when the input was first created. + /// Used to automatically resize the Swatch when is called. + /// + private readonly float swatchSizeMultiplier; + /// /// Construct a color input with no description. /// public ColorInput(LocalizedText label) - : this(label, (LocalizedText)"") { } + : this(label, "") { } /// /// Construct a color input with a description. @@ -36,6 +42,8 @@ public ColorInput(LocalizedText label, LocalizedText description) SwatchOutline = Swatch.Find("Outline").GetComponent(); InvalidValueIndicator = Swatch.Find("Invalid Indicator").GetComponent(); + swatchSizeMultiplier = Swatch.rect.height / InputField.textComponent.preferredHeight; + OnTextValueChanged += _ => { SwatchFill.color = Value; @@ -101,6 +109,18 @@ public enum InputFormat /// public readonly Text InvalidValueIndicator; + /// + /// + /// This will also adjust the size and position of the . + /// + public override void SetFontSizes(FontSizes fontSizes) + { + base.SetFontSizes(fontSizes); + float size = InputField.textComponent.preferredHeight * swatchSizeMultiplier; + Swatch.sizeDelta = Vector2.one * size; + Swatch.anchoredPosition = Swatch.anchoredPosition with { x = -0.5f * size }; + } + /// /// validation for hex codes; only accepts characters a-fA-F0-7. /// diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 5d4cb2b..ff4e78b 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -173,7 +173,7 @@ private MenuPrefabs(UIManager uiManager) swatchRT.sizeDelta = Vector2.one * 70; swatchRT.anchorMax = swatchRT.anchorMin = new Vector2(0, 0.5f); swatchRT.pivot = new Vector2(1, 0.5f); - swatchRT.anchoredPosition = new Vector2(-35, 0); + swatchRT.anchoredPosition = new Vector2(-0.5f * swatchRT.sizeDelta.x, 0); Transform journalIcon = GameCameras.instance.hudCamera.transform.Find( "In-game/Inventory/Journal/Enemy List Parent/Enemy List/Template Journal Entry" From cd3c9dce19f0a127dedd901d27346e734f16d6ae Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Tue, 28 Apr 2026 22:38:18 -0400 Subject: [PATCH 10/11] add RGBColorValues config constraint, which auto-ColorInputs accomodate --- Silksong.ModMenu/Plugin/ConfigEntryFactory.cs | 8 ++++++- Silksong.ModMenu/Plugin/RGBColorValues.cs | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 Silksong.ModMenu/Plugin/RGBColorValues.cs diff --git a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs index 3eadc01..71416a0 100644 --- a/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs +++ b/Silksong.ModMenu/Plugin/ConfigEntryFactory.cs @@ -446,7 +446,13 @@ public static bool GenerateColorElement( return false; } - ColorInput color = new(entry.LabelName(), entry.DescriptionLine()); + ColorInput color = new(entry.LabelName(), entry.DescriptionLine()) + { + Format = + (entry.Description.AcceptableValues is RGBColorValues) + ? ColorInput.InputFormat.RGB + : ColorInput.InputFormat.RGBA, + }; color.SynchronizeWith(colorEntry); menuElement = color; diff --git a/Silksong.ModMenu/Plugin/RGBColorValues.cs b/Silksong.ModMenu/Plugin/RGBColorValues.cs new file mode 100644 index 0000000..6da283d --- /dev/null +++ b/Silksong.ModMenu/Plugin/RGBColorValues.cs @@ -0,0 +1,24 @@ +using BepInEx.Configuration; +using UnityEngine; + +namespace Silksong.ModMenu.Plugin; + +/// +/// Clamps a setting to always be at 100% opacity. +/// +/// +/// Automatically generated s for a setting with this restriction +/// will automatically have their format set to . +/// +public class RGBColorValues() : AcceptableValueBase(typeof(Color)) +{ + /// + public override object Clamp(object value) => (value is Color c ? c : default) with { a = 1 }; + + /// + public override bool IsValid(object value) => value is Color c && c.a == 1; + + /// + public override string ToDescriptionString() => + $"# Acceptable values: color codes from 000000FF to FFFFFFFF"; +} From b2bfce0512d108200d249306d18d3fc566f4d81e Mon Sep 17 00:00:00 2001 From: flibber-hk <76987839+flibber-hk@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:48:02 +0100 Subject: [PATCH 11/11] Spaces --- Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs index 025fddb..269f17c 100644 --- a/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs +++ b/Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs @@ -5,7 +5,7 @@ namespace Silksong.ModMenuTesting; internal class ModMenuAutoTestingPlugin() : BaseProxyPluginTest("org.silksong_modding.modmenuautotesting") { - internal override string Name => "ModMenuAutoTesting"; + internal override string Name => "Mod Menu Auto Testing"; public enum TestEnum { EnumOne, EnumTwo, EnumThree }