Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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>
42 changes: 42 additions & 0 deletions Silksong.ModMenu/Elements/TextInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ 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),
];

/// <summary>
/// Construct a basic text input.
Expand All @@ -48,6 +55,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 +115,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/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
114 changes: 114 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,111 @@ private static bool HexUnparser(Color c, out string x)
x = "###";
return false;
}

#endregion
#region Vectors & Quaternions

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

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

/// <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)
{
string[] rawVals = [.. x.Trim().Trim('(', ')').Split(',').Select(s => s.Trim())];
Comment thread
kaycodes13 marked this conversation as resolved.
Outdated
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
}
104 changes: 104 additions & 0 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,10 @@ public delegate bool MenuElementGenerator(
GenerateDoubleElement,
GenerateStringElement,
GenerateColorElement,
GenerateVector2Element,
GenerateVector3Element,
GenerateVector4Element,
GenerateQuaternionElement,
];

/// <summary>
Expand Down Expand Up @@ -704,6 +708,106 @@ 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
)
{
if (entry is not ConfigEntry<Vector2> vectorEntry)
{
menuElement = default;
return false;
}

TextInput<Vector2> vector = new(
entry.LabelName(),
TextModels.ForVector2(),
entry.DescriptionLine()
);
vector.SynchronizeWith(vectorEntry);

menuElement = vector;
return true;
}

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

TextInput<Vector3> vector = new(
entry.LabelName(),
TextModels.ForVector3(),
entry.DescriptionLine()
);
vector.SynchronizeWith(vectorEntry);

menuElement = vector;
return true;
}

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

TextInput<Vector4> vector = new(
entry.LabelName(),
TextModels.ForVector4(),
entry.DescriptionLine()
);
vector.SynchronizeWith(vectorEntry);

menuElement = vector;
return true;
}

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

TextInput<Quaternion> quaternion = new(
entry.LabelName(),
TextModels.ForQuaternion(),
entry.DescriptionLine()
);
quaternion.SynchronizeWith(quaternionEntry);

menuElement = quaternion;
return true;
}

private record ElementTreeNode
{
public readonly List<(string path, MenuElement element)> Elements = [];
Expand Down
26 changes: 13 additions & 13 deletions Silksong.ModMenuTesting/Tests/ModMenuAutoTestingPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,24 @@ 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("Unity Types", "Vector2 Option", Vector2.one);
config.Bind("Unity Types", "Vector3 Option", Vector3.one);
config.Bind("Unity Types", "Vector4 Option", Vector4.one);
config.Bind("Unity Types", "Quaternion Option", Quaternion.identity);

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", "Byte Option", (byte)0);
config.Bind("Value Types", "SByte Option", (sbyte)0);
config.Bind("Value Types", "Short Option", (short)0);
config.Bind("Value Types", "UShort Option", (ushort)0);
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", "UInt Option", 0u);
config.Bind("Value Types", "Long Option", 0L);
config.Bind("Value Types", "ULong Option", 0UL);
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", "Double Option", 0.0d);
config.Bind("Value Types", "Decimal Option", 0.0m);
}
}