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
129 changes: 129 additions & 0 deletions Silksong.ModMenu/Elements/ColorInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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>
/// The relative size of the Swatch to the choice text when the input was first created.
/// Used to automatically resize the Swatch when <see cref="SetFontSizes"/> is called.
/// </summary>
private readonly float swatchSizeMultiplier;

/// <summary>
/// Construct a color input with no description.
/// </summary>
public ColorInput(LocalizedText label)
: this(label, "") { }

/// <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>();

swatchSizeMultiplier = Swatch.rect.height / InputField.textComponent.preferredHeight;

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;

/// <inheritdoc/>
/// <remarks>
/// This will also adjust the size and position of the <see cref="Swatch"/>.
/// </remarks>
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 };
}

/// <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(-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"
);

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;
}
}
Loading