Skip to content
Merged
109 changes: 109 additions & 0 deletions Silksong.ModMenu/Elements/ColorInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using Silksong.ModMenu.Internal;
using Silksong.ModMenu.Models;
using UnityEngine;
using UnityEngine.UI;

namespace Silksong.ModMenu.Elements;

/// <summary>
/// Selectable element that accepts <see cref="Color"/> input in 3, 6, or 8 character hex strings.
/// Includes a preview swatch beside the hex code.
/// </summary>
public class ColorInput : TextInput<Color>
Comment thread
kaycodes13 marked this conversation as resolved.
{
/// <summary>
/// Construct a color input with no description.
/// </summary>
public ColorInput(LocalizedText label)
: this(label, (LocalizedText)"") { }
Comment thread
kaycodes13 marked this conversation as resolved.
Outdated

/// <summary>
/// Construct a color input with a description.
/// </summary>
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().RectTransform;
Swatch.SetParent(InputField.textComponent.transform, false);
Swatch.gameObject.SetActive(true);
SwatchFill = Swatch.Find("Fill").GetComponent<Image>();
SwatchOutline = Swatch.Find("Outline").GetComponent<Image>();
InvalidValueIndicator = Swatch.Find("Invalid Indicator").GetComponent<Text>();

OnTextValueChanged += _ =>
{
SwatchFill.color = Value;
State = TextModel.IsTextValid ? ElementState.DEFAULT : ElementState.INVALID;
InvalidValueIndicator.enabled = !TextModel.IsTextValid;
};

Value = Color.clear;
}

/// <summary>
/// Whether or not this input accepts 8-character RGBA strings.
/// </summary>
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;
}
}

/// <summary>
/// Semantic input formats for <see cref="ColorInput"/>s.
/// </summary>
public enum InputFormat
{
/// <summary>
/// The input accepts at most 6-character RGB hex codes.
/// </summary>
RGB = 6,

/// <summary>
/// The input accepts at most 8-character RGBA hex codes.
/// </summary>
RGBA = 8,
}

/// <summary>
/// The unity component that controls the size and position of the preview swatch.
/// </summary>
public readonly RectTransform Swatch;

/// <summary>
/// The unity component for the filled area of the preview swatch.
/// </summary>
public readonly Image SwatchFill;

/// <summary>
/// The unity component for the outline around the preview swatch.
/// </summary>
public readonly Image SwatchOutline;

/// <summary>
/// The unity component for the symbol that appears in the preview swatch
/// to visually indicate an invalid value.
/// </summary>
public readonly Text InvalidValueIndicator;

/// <summary>
/// <see cref="InputField"/> validation for hex codes; only accepts characters a-fA-F0-7.
/// </summary>
static char HexValidation(string input, int index, char addedChar) =>
$"{addedChar}".TryParseHex(out _) ? char.ToUpper(addedChar) : '0';
}
13 changes: 7 additions & 6 deletions Silksong.ModMenu/Internal/CustomInputField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ internal class CustomInputField : InputField
{
base.Awake();
textRect = textComponent.gameObject.GetComponent<RectTransform>();
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;
Comment thread
flibber-hk marked this conversation as resolved.
}

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() =>
Expand Down
21 changes: 16 additions & 5 deletions Silksong.ModMenu/Internal/GameObjectUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

/// <summary>
/// Casts the object's <c>transform</c> to a <see cref="RectTransform"/>.
/// </summary>
/// <exception cref="System.InvalidCastException">
/// If the object doesn't actually have a RectTransform.
/// </exception>
internal RectTransform RectTransform => (RectTransform)self.transform;
Comment thread
flibber-hk marked this conversation as resolved.
}

private class InactiveScope : System.IDisposable
Expand Down
52 changes: 52 additions & 0 deletions Silksong.ModMenu/Internal/MenuPrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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<RectTransform>();
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<Image>();
imgF.sprite = journalIcon.Find("Mask").GetComponent<SpriteMask>().sprite;
imgF.preserveAspect = true;
fill.RectTransform.FitToParent();

var line = new GameObject("Outline") { layer = (int)PhysLayers.UI };
line.transform.SetParentReset(swatchRT);
var imgL = line.AddComponent<Image>();
imgL.sprite = journalIcon.Find("Standard Frame").GetComponent<SpriteRenderer>().sprite;
imgL.preserveAspect = true;
line.AddComponent<Outline>().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>();
text.enabled = false;
text.text = "?";
text.font = textLabelTemplate.GetComponent<Text>().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);
}
Comment thread
flibber-hk marked this conversation as resolved.
}

internal GameObject NewCustomMenu(LocalizedText title)
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion Silksong.ModMenu/Internal/StringUtil.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Globalization;
using System.Text;

namespace Silksong.ModMenu.Internal;

Expand Down Expand Up @@ -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);
}
54 changes: 54 additions & 0 deletions Silksong.ModMenu/Models/TextModels.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Silksong.ModMenu.Internal;
using UnityEngine;

namespace Silksong.ModMenu.Models;

Expand Down Expand Up @@ -64,4 +67,55 @@ bool Check(T value)

return Check;
}

/// <summary>
/// An ITextModel which parses 3, 6, or 8 character hex strings to and from <see cref="Color"/>s.
/// </summary>
public static ParserTextModel<Color> 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;
}
}
38 changes: 36 additions & 2 deletions Silksong.ModMenu/Plugin/ConfigEntryFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public delegate bool MenuElementGenerator(
GenerateIntElement,
GenerateFloatElement,
GenerateStringElement,
GenerateColorElement,
];

/// <summary>
Expand Down Expand Up @@ -143,10 +144,20 @@ public virtual bool GenerateEntryButton(
LocalizedText name,
BaseUnityPlugin plugin,
[MaybeNullWhen(false)] out SelectableElement selectableElement
) => GenerateEntryButton(name, plugin.Config, out _, out selectableElement);

/// <summary>
/// Generate a button which opens a sub-menu for this ConfigFile.
/// </summary>
public virtual bool GenerateEntryButton(
LocalizedText name,
ConfigFile config,
[MaybeNullWhen(false)] out AbstractMenuScreen menuScreen,
[MaybeNullWhen(false)] out SelectableElement selectableElement
)
{
TreeNode<LocalizedText, ElementTreeNode> 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))
{
Expand All @@ -168,6 +179,7 @@ public virtual bool GenerateEntryButton(
);
if (elementsTree.Value.TotalElements == 0)
{
menuScreen = default;
selectableElement = default;
return false;
}
Expand All @@ -176,7 +188,8 @@ public virtual bool GenerateEntryButton(
List<LocalizedText> 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),
Expand Down Expand Up @@ -419,6 +432,27 @@ public static bool GenerateStringElement(
return true;
}

/// <summary>
/// Generate a text element for a color.
/// </summary>
public static bool GenerateColorElement(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
)
{
if (entry is not ConfigEntry<Color> colorEntry)
{
menuElement = default;
return false;
}

ColorInput color = new(entry.LabelName(), entry.DescriptionLine());
Comment thread
kaycodes13 marked this conversation as resolved.
Outdated
color.SynchronizeWith(colorEntry);

menuElement = color;
return true;
}

private record ElementTreeNode
{
public readonly List<(string path, MenuElement element)> Elements = [];
Expand Down
Loading