Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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.7.2</Version>
<Version>0.7.3</Version>
</PropertyGroup>
</Project>
43 changes: 43 additions & 0 deletions Silksong.ModMenu/Elements/TextInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ public class TextInput<T> : SelectableValueElement<T>
typeof(ulong),
];
private static readonly HashSet<Type> floatTypes = [typeof(float), typeof(double)];
private static readonly HashSet<Type> floatListTypes =
Comment thread
kaycodes13 marked this conversation as resolved.
[
typeof(Vector2),
typeof(Vector3),
typeof(Vector4),
typeof(Quaternion),
typeof(Rect),
];

/// <summary>
/// Construct a basic text input.
Expand All @@ -48,6 +56,14 @@ public TextInput(LocalizedText label, ITextModel<T> model, LocalizedText descrip
InputField.contentType = InputField.ContentType.IntegerNumber;
else if (floatTypes.Contains(typeof(T)))
InputField.contentType = InputField.ContentType.DecimalNumber;
else if (floatListTypes.Contains(typeof(T)))
{
InputField.contentType = InputField.ContentType.Custom;
InputField.onValidateInput = FloatListValidation;
ApplyDefaultColors = true;
OnTextValueChanged += _ =>
State = TextModel.IsTextValid ? ElementState.DEFAULT : ElementState.INVALID;
}
}

/// <summary>
Expand Down Expand Up @@ -100,4 +116,31 @@ public override void SetFontSizes(FontSizes fontSizes)
DescriptionText.fontSize = fontSizes.DescriptionSize();
InputField.textComponent.fontSize = fontSizes.ChoiceSize();
}

/// <summary>
/// <see cref="UnityEngine.UI.InputField"/> validation for comma-delimited lists
/// of float values, with or without enclosing brackets.
/// </summary>
static char FloatListValidation(string input, int pos, char ch)
{
int leftPos = input.IndexOf('('),
rightPos = input.IndexOf(')');

// Everything must go within the brackets, if they're present
if (leftPos >= pos || (rightPos < pos && rightPos >= 0))
return '\0';

if (
// Brackets must be unique and positioned on the appropriate end of the string
(ch == '(' && leftPos == -1 && pos == 0)
|| (ch == ')' && rightPos == -1 && pos == input.Length)
// Other valid characters
|| char.IsDigit(ch)
|| char.IsWhiteSpace(ch)
|| ",.-".Contains(ch)
)
return ch;

return '\0';
}
}
6 changes: 6 additions & 0 deletions Silksong.ModMenu/Generator/Attributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public class ModMenuRangeAttribute(object Min, object Max) : Attribute
public readonly object Max = Max;
}

/// <summary>
/// Attribute to apply to a <see cref="UnityEngine.Color"/> property, to specify it should only support RGB values with 100% opacity.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class ModMenuRGBAttribute() : Attribute { }
Comment thread
kaycodes13 marked this conversation as resolved.
Outdated

/// <summary>
/// Attribute to mark a field or property of a data class as requiring its own custom sub-menu, of the parameterized type.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions Silksong.ModMenu/Internal/MenuPrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ private MenuPrefabs(UIManager uiManager)
textInputChild.FindChild("CursorRight")!.GetComponent<Animator>(),
];

GameObject underlineObj = new("Underline") { layer = (int)PhysLayers.UI };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How wedded are you to this underline? I think it looked better without the underline, personally

(To be clear, I don't mean we should keep - or remove - it based solely on my opinion, but I'd like it to be scrutinized further)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern with text inputs is making what they are more immediately obvious. Without anything to make it visually distinct from other similar controls, a filled text field looks very similar to a choice element, and an empty text field looks like the choices for a choice element didn't load. Hovering over or otherwise navigating to a text field puts a selection effect or caret on it to make it clear what it is, sure, but the initial impression is still unclear.

My reasoning for choosing a thin underline is because it's familiar visual language for this kind of input - anyone who's filled out a paper form has seen an underline somewhere you're supposed to write something. It's a choice that favours clarity over aesthetics. But if someone has an idea for how to get better aesthetics and similar clarity, sure, I say we try it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I never thought they weren’t obvious without the line but I’ll have to have another look to judge. I do care a lot about aesthetics though so if there’s a better way to achieve what you want then that’d be good

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had another look... here are my current thoughts:

  • A filled text field looking similar to a choice element is unproblematic IMO - the only way to change the setting in either case is to interact with them, and I can't see why the fact that they're visually similar prior to this would be problematic - they both represent settings that can be modified, and it doesn't strictly need to be visually apparent what the mechanism to modify the setting is until/unless they're trying to modify it.
  • I'll concede that an empty text field does look weird without the underline, although it also looks weird with the underline. I think either way people will assume that it is a setting that they can interact with to figure out how to change the setting.

I would say that in almost all cases the text field will be non-empty by default; at the very least non-string types don't support an empty string, and I believe that most string entries will have a non-empty string as the default.

underlineObj.transform.SetParentReset(textInputField.textComponent.transform);
underlineObj.AddComponent<Image>();
underlineObj.RectTransform.FitToParentHorizontal(anchorY: 0);
underlineObj.RectTransform.sizeDelta = new Vector2(0, 3);

sliderTemplate = Object.Instantiate(
canvas.FindChild("AudioMenuScreen/Content/MasterVolume")!
);
Expand Down
152 changes: 152 additions & 0 deletions Silksong.ModMenu/Models/TextModels.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Silksong.ModMenu.Internal;
using UnityEngine;
Expand All @@ -17,6 +18,8 @@ public static class TextModels
public static ParserTextModel<string> ForStrings() =>
new(DefaultUnparse<string>, DefaultUnparse<string>);

#region Numbers

/// <summary>
/// An ITextModel which parses its input into a signed byte.
/// </summary>
Expand Down Expand Up @@ -172,6 +175,8 @@ public static ParserTextModel<double> ForDoubles(double min, double max)
return model;
}

#endregion

private static bool DefaultUnparse<T>(T value, out string text)
{
text = $"{value}";
Expand All @@ -193,6 +198,8 @@ bool Check(T value)
return Check;
}

#region Colors

/// <summary>
/// An ITextModel which parses 3, 6, or 8 character hex strings to and from <see cref="Color"/>s.
/// </summary>
Expand Down Expand Up @@ -243,4 +250,149 @@ private static bool HexUnparser(Color c, out string x)
x = "###";
return false;
}

#endregion
#region Vectors & Quaternions & Rects

/// <summary>
/// An ITextModel which parses comma-delimited pairs of numbers to and from <see cref="Vector2"/>s.
/// </summary>
public static ParserTextModel<Vector2> ForVector2() =>
new(Vector2Parser, Vector2Unparser, INVALID_VECTOR);

/// <summary>
/// An ITextModel which parses comma-delimited trios of numbers to and from <see cref="Vector3"/>s.
/// </summary>
public static ParserTextModel<Vector3> ForVector3() =>
new(Vector3Parser, Vector3Unparser, INVALID_VECTOR);

/// <summary>
/// An ITextModel which parses comma-delimited quartets of numbers to and from <see cref="Vector4"/>s.
/// </summary>
public static ParserTextModel<Vector4> ForVector4() =>
new(Vector4Parser, Vector4Unparser, INVALID_VECTOR);

/// <summary>
/// An ITextModel which parses comma-delimited quartets of numbers to and from <see cref="Quaternion"/>s.
/// </summary>
public static ParserTextModel<Quaternion> ForQuaternion() =>
new(QuaternionParser, QuaternionUnparser, INVALID_QUATERNION);

/// <summary>
/// An ITextModel which parses comma-delimited quartets of numbers to and from <see cref="Rect"/>s.
/// </summary>
public static ParserTextModel<Rect> ForRect() => new(RectParser, RectUnparser, INVALID_RECT);

private static readonly Vector4 INVALID_VECTOR = Vector4.positiveInfinity;
private static readonly Quaternion INVALID_QUATERNION = new(
float.PositiveInfinity,
float.PositiveInfinity,
float.PositiveInfinity,
float.PositiveInfinity
);
private static readonly Rect INVALID_RECT = new(
float.PositiveInfinity,
float.PositiveInfinity,
float.PositiveInfinity,
float.PositiveInfinity
);

private static bool Vector2Parser(string x, out Vector2 v)
{
bool success = FloatListParser(x, out float[] c) && c.Length == 2;
v = success ? new(c[0], c[1]) : INVALID_VECTOR;
return success;
}

private static bool Vector2Unparser(Vector2 v, out string x)
{
x = v.ToString("F2", CultureInfo.InvariantCulture.NumberFormat);
return true;
}

private static bool Vector3Parser(string x, out Vector3 v)
{
bool success = FloatListParser(x, out float[] c) && c.Length == 3;
v = success ? new(c[0], c[1], c[2]) : INVALID_VECTOR;
return success;
}

private static bool Vector3Unparser(Vector3 v, out string x)
{
x = v.ToString("F2", CultureInfo.InvariantCulture.NumberFormat);
return true;
}

private static bool Vector4Parser(string x, out Vector4 v)
{
bool success = FloatListParser(x, out float[] c) && c.Length == 4;
v = success ? new(c[0], c[1], c[2], c[3]) : INVALID_VECTOR;
return success;
}

private static bool Vector4Unparser(Vector4 v, out string x)
{
x = v.ToString("F2", CultureInfo.InvariantCulture.NumberFormat);
return true;
}

private static bool QuaternionParser(string x, out Quaternion q)
{
bool success = FloatListParser(x, out float[] c) && c.Length == 4;
q = success ? new(c[0], c[1], c[2], c[3]) : INVALID_QUATERNION;
return success;
}

private static bool QuaternionUnparser(Quaternion q, out string x)
{
x = q.ToString("F3", CultureInfo.InvariantCulture.NumberFormat);
return true;
}

private static bool RectParser(string x, out Rect r)
{
bool success = FloatListParser(x, out float[] c) && c.Length == 4;
r = success ? new(c[0], c[1], c[2], c[3]) : INVALID_RECT;
return success;
}

private static bool RectUnparser(Rect r, out string x)
{
const string format = "F2";
var provider = CultureInfo.InvariantCulture.NumberFormat;
x = UnityString.Format(
"({0}, {1}, {2}, {3})",
r.x.ToString(format, provider),
r.y.ToString(format, provider),
r.width.ToString(format, provider),
r.height.ToString(format, provider)
);
return true;
}

/// <summary>
/// Parses strings of the format "(-0.0, -0.0, ... , -0.0)" with or without brackets
/// into an array of floats.
/// </summary>
private static bool FloatListParser(string x, out float[] results)
{
x = x.Trim();
if (x[0] == '(')
x = x[1..];
if (x[^1] == ')')
x = x[..^1];

string[] rawVals = x.Split(',');
results = new float[rawVals.Length];

NumberFormatInfo format = CultureInfo.InvariantCulture.NumberFormat;

for (int i = 0; i < rawVals.Length; i++)
if (!float.TryParse(rawVals[i], NumberStyles.Float, format, out results[i]))
return false;

return true;
}

#endregion
}
85 changes: 67 additions & 18 deletions Silksong.ModMenu/Plugin/ConfigEntryFactory.cs
Comment thread
kaycodes13 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public delegate bool MenuElementGenerator(
GenerateDoubleElement,
GenerateStringElement,
GenerateColorElement,
GenerateVector2Element,
GenerateVector3Element,
GenerateVector4Element,
GenerateQuaternionElement,
GenerateRectElement,
];

/// <summary>
Expand Down Expand Up @@ -658,24 +663,7 @@ public static bool GenerateDoubleElement(
public static bool GenerateStringElement(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
)
{
if (entry is not ConfigEntry<string> stringEntry)
{
menuElement = default;
return false;
}

TextInput<string> text = new(
entry.LabelName(),
TextModels.ForStrings(),
entry.DescriptionLine()
);
text.SynchronizeWith(stringEntry);

menuElement = text;
return true;
}
) => GenerateTextInput(TextModels.ForStrings, entry, out menuElement);

/// <summary>
/// Generate a text element for a color.
Expand Down Expand Up @@ -704,6 +692,67 @@ public static bool GenerateColorElement(
return true;
}

/// <summary>
/// Generate a text element for a Vector2.
/// </summary>
public static bool GenerateVector2Element(
Comment thread
kaycodes13 marked this conversation as resolved.
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
) => GenerateTextInput(TextModels.ForVector2, entry, out menuElement);

/// <summary>
/// Generate a text element for a Vector3.
/// </summary>
public static bool GenerateVector3Element(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
) => GenerateTextInput(TextModels.ForVector3, entry, out menuElement);

/// <summary>
/// Generate a text element for a Vector4.
/// </summary>
public static bool GenerateVector4Element(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
) => GenerateTextInput(TextModels.ForVector4, entry, out menuElement);

/// <summary>
/// Generate a text element for a Quaternion.
/// </summary>
public static bool GenerateQuaternionElement(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
) => GenerateTextInput(TextModels.ForQuaternion, entry, out menuElement);

/// <summary>
/// Generate a text element for a Rect.
/// </summary>
public static bool GenerateRectElement(
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
) => GenerateTextInput(TextModels.ForRect, entry, out menuElement);

/// <summary>
/// Generate a text element for a config setting with a <typeparamref name="T"/> value
/// and a model created by the given <paramref name="model"/> function.
/// </summary>
public static bool GenerateTextInput<T>(
Func<ITextModel<T>> model,
ConfigEntryBase entry,
[MaybeNullWhen(false)] out MenuElement menuElement
)
{
if (entry is not ConfigEntry<T> typedEntry)
{
menuElement = default;
return false;
}
TextInput<T> input = new(entry.LabelName(), model(), entry.DescriptionLine());
input.SynchronizeWith(typedEntry);
menuElement = input;
return true;
}

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