diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index ddac4a9..086f79d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -1,20 +1,27 @@
-{
- "version": 1,
- "isRoot": true,
- "tools": {
- "tcli": {
- "version": "0.2.4",
- "commands": [
- "tcli"
- ],
- "rollForward": false
- },
- "husky": {
- "version": "0.8.0",
- "commands": [
- "husky"
- ],
- "rollForward": false
- }
- }
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "tcli": {
+ "version": "0.2.4",
+ "commands": [
+ "tcli"
+ ],
+ "rollForward": false
+ },
+ "husky": {
+ "version": "0.8.0",
+ "commands": [
+ "husky"
+ ],
+ "rollForward": false
+ },
+ "csharpier": {
+ "version": "1.2.6",
+ "commands": [
+ "csharpier"
+ ],
+ "rollForward": false
+ }
+ }
}
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
index 8c9415b..5176381 100644
--- a/Directory.Build.props
+++ b/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.2.0
+ 0.3.0
diff --git a/Elements/AbstractGroup.cs b/Elements/AbstractGroup.cs
index 28446e2..85a608a 100644
--- a/Elements/AbstractGroup.cs
+++ b/Elements/AbstractGroup.cs
@@ -1,5 +1,4 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Silksong.ModMenu.Internal;
@@ -13,7 +12,7 @@ namespace Silksong.ModMenu.Elements;
///
public abstract class AbstractGroup : INavigableMenuEntity
{
- private readonly VisibilityManager visibility = new();
+ private readonly VisibilityManager visibility = new(false);
///
public VisibilityManager Visibility => visibility;
@@ -27,14 +26,9 @@ public abstract class AbstractGroup : INavigableMenuEntity
public IEnumerable AllElements() => AllEntities().SelectMany(e => e.AllElements());
///
- /// Record `entity` as a child of this group. The group is responsible for storing it in whatever data structure makes sense for it.
+ /// Register `entity` as a child of this group.
///
- protected void ParentEntity(IMenuEntity entity)
- {
- entity.SetMenuParent(this);
- if (gameObjectParent != null)
- entity.SetGameObjectParent(gameObjectParent);
- }
+ protected void AddChild(IMenuEntity entity) => entity.SetParents(this, gameObjectParent);
///
/// Enumerate all navigables which should be directly connected in `direction`.
@@ -77,8 +71,7 @@ public abstract bool GetSelectable(
///
public virtual void SetGameObjectParent(GameObject parent)
{
- if (gameObjectParent != null)
- throw new ArgumentException("GameObjectParent already set");
+ ClearGameObjectParent();
gameObjectParent = parent;
foreach (var entity in AllEntities())
@@ -86,8 +79,14 @@ public virtual void SetGameObjectParent(GameObject parent)
}
///
- public virtual void SetMenuParent(IMenuEntity parent) =>
- visibility.SetParent(parent.Visibility);
+ public virtual void ClearGameObjectParent()
+ {
+ if (gameObjectParent == null)
+ return;
+
+ foreach (var entity in AllEntities())
+ entity.ClearGameObjectParent();
+ }
///
public virtual void SetNeighbor(NavigationDirection direction, Selectable selectable)
diff --git a/Elements/FreeGroup.cs b/Elements/FreeGroup.cs
index 1947330..c7f7cae 100644
--- a/Elements/FreeGroup.cs
+++ b/Elements/FreeGroup.cs
@@ -24,8 +24,8 @@ public class FreeGroup : AbstractGroup
///
public void Add(IMenuEntity entity, Vector2 offset)
{
- entities.Add(entity, offset);
- ParentEntity(entity);
+ entities[entity] = offset;
+ AddChild(entity);
}
///
@@ -39,6 +39,20 @@ public void Update(IMenuEntity entity, Vector2 offset)
entities[entity] = offset;
}
+ ///
+ /// Remove the specified entity from this group.
+ ///
+ public bool Remove(IMenuEntity entity)
+ {
+ if (entities.Remove(entity))
+ {
+ entity.ClearParents();
+ return true;
+ }
+
+ return false;
+ }
+
// Sort low values first.
private static float SortKey(NavigationDirection direction, Vector2 pos) =>
direction switch
diff --git a/Elements/GridGroup.cs b/Elements/GridGroup.cs
index b25fc55..bd3263f 100644
--- a/Elements/GridGroup.cs
+++ b/Elements/GridGroup.cs
@@ -13,7 +13,8 @@ namespace Silksong.ModMenu.Elements;
///
public class GridGroup(int columns) : AbstractGroup
{
- private readonly List entitiesByRow = [];
+ private readonly List rows = [];
+ private readonly Dictionary index = [];
///
/// The number of columns in this grid.
@@ -24,7 +25,7 @@ public class GridGroup(int columns) : AbstractGroup
///
/// The number of rows in this grid.
///
- public int Rows => entitiesByRow.Count;
+ public int Rows => rows.Count;
///
/// Spacing between different columns.
@@ -46,11 +47,11 @@ public class GridGroup(int columns) : AbstractGroup
///
public void Add(IMenuEntity entity)
{
- while (IsFull(nextEmpty))
- nextEmpty = nextEmpty.Next(this);
+ while (IsFull(nextEmptyCell))
+ nextEmptyCell = nextEmptyCell.Next(this);
- AddAt(nextEmpty.Row, nextEmpty.Column, entity);
- nextEmpty = nextEmpty.Next(this);
+ AddAt(nextEmptyCell.Row, nextEmptyCell.Column, entity);
+ nextEmptyCell = nextEmptyCell.Next(this);
}
///
@@ -65,16 +66,52 @@ public void AddAt(int row, int column, IMenuEntity entity)
throw new ArgumentException($"{nameof(row)}: {row} (Must be >= 0)");
if (column < 0 || column >= Columns)
throw new ArgumentException($"{nameof(column)}: {column} (Must be in [0, {Columns}))");
- if (IsFull(new(row, column)))
- throw new ArgumentException($"Cell({row}, {column}) is already filled.");
- while (entitiesByRow.Count <= row)
- entitiesByRow.Add(new IMenuEntity?[Columns]);
- entitiesByRow[row][column] = entity;
+ GridCell cell = new(row, column);
+ if (index.TryGetValue(entity, out var prevCell))
+ {
+ if (cell == prevCell)
+ return; // Nothing to do.
+ else
+ throw new ArgumentException($"Entity already present at ({row}, {column})");
+ }
- ParentEntity(entity);
+ if (IsFull(cell))
+ throw new ArgumentException($"({row}, {column}) is already filled.");
+
+ while (rows.Count <= row)
+ rows.Add(new IMenuEntity?[Columns]);
+ rows[row][column] = entity;
+ index[entity] = cell;
+ AddChild(entity);
}
+ ///
+ /// Remove the specified entity from the grid.
+ ///
+ public bool Remove(IMenuEntity entity)
+ {
+ if (!index.TryGetValue(entity, out var cell))
+ return false;
+
+ index.Remove(entity);
+ rows[cell.Row][cell.Column] = null;
+ nextEmptyCell = cell.CompareTo(nextEmptyCell) <= 0 ? cell : nextEmptyCell;
+ entity.ClearParents();
+
+ // Truncate empty rows.
+ if (cell.Row == rows.Count - 1)
+ for (int row = cell.Row; row >= 0 && rows[row].All(e => e == null); row--)
+ rows.RemoveAt(row);
+ return true;
+ }
+
+ ///
+ /// Remove the entity at the specified cell.
+ ///
+ public bool RemoveAt(int row, int column) =>
+ TryGetValue(new(row, column), out var entity) && Remove(entity);
+
///
public override bool GetSelectable(
NavigationDirection direction,
@@ -114,24 +151,19 @@ public override void UpdateLayout(Vector2 localAnchorPos)
ClearNeighbors();
// Update positions.
- for (int row = 0; row < entitiesByRow.Count; row++)
+ foreach (var e in index)
{
- for (int column = 0; column < Columns; column++)
- {
- var entity = entitiesByRow[row][column];
- if (entity == null)
- continue;
-
- Vector2 pos = localAnchorPos;
- pos.y -= VerticalSpacing * row;
- pos.x += HorizontalSpacing * (column - (Columns - 1) / 2f);
- entity.UpdateLayout(pos);
- }
+ var (entity, cell) = (e.Key, e.Value);
+
+ Vector2 pos = localAnchorPos;
+ pos.y -= VerticalSpacing * cell.Row;
+ pos.x += HorizontalSpacing * (cell.Column - (Columns - 1) / 2f);
+ entity.UpdateLayout(pos);
}
// Update navigation.
INavigable?[]? prevRow = null;
- foreach (var row in entitiesByRow)
+ foreach (var row in rows)
{
INavigable?[] nextRow =
[
@@ -205,46 +237,62 @@ static bool ClosestColumn(
///
protected override IEnumerable AllEntities() =>
- entitiesByRow.SelectMany(row => row.WhereNonNull());
+ rows.SelectMany(row => row.WhereNonNull());
private ListView> GetColumns() =>
- new(column => new(row => entitiesByRow[row][column], entitiesByRow.Count), Columns);
+ new(column => new(row => rows[row][column], rows.Count), Columns);
///
protected override IEnumerable GetNavigables(NavigationDirection direction) =>
direction switch
{
// All elements of first row with stuff in it.
- NavigationDirection.Up => entitiesByRow
- .Where(row => row.Any(e => e is INavigable && e.VisibleSelf))
+ NavigationDirection.Up => rows.Where(row =>
+ row.Any(e => e is INavigable && e.VisibleSelf)
+ )
.FirstOrDefault()
?.OfType()
- ?? [],
+ ?? [],
// Leftmost element of every row.
- NavigationDirection.Left => entitiesByRow
- .SelectMany(row => row.Where(e => e is INavigable && e.VisibleSelf).Take(1))
+ NavigationDirection.Left => rows.SelectMany(row =>
+ row.Where(e => e is INavigable && e.VisibleSelf).Take(1)
+ )
.OfType(),
// Rightmost element of every row.
- NavigationDirection.Right => entitiesByRow
- .SelectMany(row => row.Where(e => e is INavigable && e.VisibleSelf).TakeLast(1))
+ NavigationDirection.Right => rows.SelectMany(row =>
+ row.Where(e => e is INavigable && e.VisibleSelf).TakeLast(1)
+ )
.OfType(),
// All elements of last row with stuff in it.
- NavigationDirection.Down => entitiesByRow
- .Where(row => row.Any(e => e is INavigable && e.VisibleSelf))
+ NavigationDirection.Down => rows.Where(row =>
+ row.Any(e => e is INavigable && e.VisibleSelf)
+ )
.LastOrDefault()
?.OfType()
- ?? [],
+ ?? [],
_ => throw new ArgumentException($"{direction}"),
};
- private record GridCell(int Row, int Column)
+ private record GridCell(int Row, int Column) : IComparable
{
internal GridCell Next(GridGroup parent) =>
Column == parent.Columns - 1 ? new(Row + 1, 0) : new(Row, Column + 1);
+
+ public int CompareTo(GridCell other) =>
+ Row == other.Row ? Column.CompareTo(other.Column) : Row.CompareTo(other.Row);
}
- private GridCell nextEmpty = new(0, 0);
+ private GridCell nextEmptyCell = new(0, 0);
+
+ private bool TryGetValue(GridCell cell, [MaybeNullWhen(false)] out IMenuEntity entity)
+ {
+ entity = default;
+ if (cell.Row < 0 || cell.Row >= rows.Count || cell.Column < 0 || cell.Column >= Columns)
+ return false;
+
+ entity = rows[cell.Row][cell.Column];
+ return entity != null;
+ }
- private bool IsFull(GridCell cell) =>
- cell.Row < Rows && entitiesByRow[cell.Row][cell.Column] != null;
+ private bool IsFull(GridCell cell) => TryGetValue(cell, out _);
}
diff --git a/Elements/IMenuEntity.cs b/Elements/IMenuEntity.cs
index 665b2fc..396baa2 100644
--- a/Elements/IMenuEntity.cs
+++ b/Elements/IMenuEntity.cs
@@ -31,12 +31,12 @@ public interface IMenuEntity
void UpdateLayout(Vector2 localAnchorPos);
///
- /// Set the visibility parent of this entity. Can only be done once.
+ /// Sets the GameObject container for this entity, from which all positions are relative.
///
- void SetMenuParent(IMenuEntity parent);
+ void SetGameObjectParent(GameObject container);
///
- /// Sets the GameObject container for this entity, from which all positions are relative. Can only be done once.
+ /// Make this entity parent-less, which in most cases also renders it invisible.
///
- void SetGameObjectParent(GameObject container);
+ void ClearGameObjectParent();
}
diff --git a/Elements/IMenuEntityExtensions.cs b/Elements/IMenuEntityExtensions.cs
index 7147dea..b3f6066 100644
--- a/Elements/IMenuEntityExtensions.cs
+++ b/Elements/IMenuEntityExtensions.cs
@@ -1,4 +1,6 @@
-namespace Silksong.ModMenu.Elements;
+using UnityEngine;
+
+namespace Silksong.ModMenu.Elements;
///
/// Helper functions for IMenuEntities.
@@ -28,5 +30,27 @@ public bool VisibleSelf
/// Convenience accessor for VisibileInHierarchy.
///
public bool VisibleInHierarchy => self.Visibility.VisibleInHierarchy;
+
+ ///
+ /// Set the parent(s) of this entity, clearing any previous parents.
+ ///
+ public void SetParents(IMenuEntity parent, GameObject? container = null)
+ {
+ self.Visibility.SetParent(parent.Visibility);
+
+ if (container != null)
+ self.SetGameObjectParent(container);
+ else
+ self.ClearGameObjectParent();
+ }
+
+ ///
+ /// Unset the parent(s) of this entity, which in most cases makes it invisible.
+ ///
+ public void ClearParents()
+ {
+ self.Visibility.ClearParent();
+ self.ClearGameObjectParent();
+ }
}
}
diff --git a/Elements/KeyBindElement.cs b/Elements/KeyBindElement.cs
new file mode 100644
index 0000000..6c83fe8
--- /dev/null
+++ b/Elements/KeyBindElement.cs
@@ -0,0 +1,65 @@
+using Silksong.ModMenu.Internal;
+using Silksong.ModMenu.Models;
+using Silksong.UnityHelper.Extensions;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Silksong.ModMenu.Elements;
+
+///
+/// Element for selecting a key code via input capture.
+///
+public class KeyBindElement : SelectableValueElement
+{
+ ///
+ /// Construct a KeyBindElement with a custom model.
+ ///
+ public KeyBindElement(string label, IValueModel model)
+ : base(
+ MenuPrefabs.Get().NewKeyBindContainer(out var customMappableKey),
+ customMappableKey,
+ model
+ )
+ {
+ customMappableKey.KeyCodeModel = model;
+
+ LabelText = Container.FindChild("Input Button Text")!.GetComponent();
+ KeyBindText = customMappableKey.KeymapText!;
+ KeyBindImage = customMappableKey.KeymapImage!;
+
+ LabelText.text = label;
+ }
+
+ ///
+ /// Construct a KeyBindElement with a default model that accepts any KeyCode.
+ ///
+ public KeyBindElement(string label)
+ : this(label, new ValueModel(KeyCode.A)) { }
+
+ ///
+ /// The unity component for the label of this value choice.
+ ///
+ public readonly Text LabelText;
+
+ ///
+ /// The unity component for the text of the selected key bind.
+ ///
+ public readonly Text KeyBindText;
+
+ ///
+ /// The unity component for the image of the selected key bind.
+ ///
+ public readonly Image KeyBindImage;
+
+ ///
+ public override void SetMainColor(Color color)
+ {
+ LabelText.color = color;
+ KeyBindText.color = color;
+ KeyBindImage.color = color;
+ }
+
+ ///
+ public override void SetFontSizes(FontSizes fontSizes) =>
+ LabelText.fontSize = fontSizes.LabelSize();
+}
diff --git a/Elements/MenuElement.cs b/Elements/MenuElement.cs
index 0b19769..f0d08c3 100644
--- a/Elements/MenuElement.cs
+++ b/Elements/MenuElement.cs
@@ -10,7 +10,7 @@ namespace Silksong.ModMenu.Elements;
///
public abstract class MenuElement : MenuDisposable, IMenuEntity
{
- private readonly VisibilityManager visibility = new();
+ private readonly VisibilityManager visibility = new(false);
///
/// Construct a MenuElement with the provided container object.
@@ -109,16 +109,9 @@ public void UpdateLayout(Vector2 localAnchorPos) =>
public virtual void SetFontSizes(FontSizes fontSizes) { }
///
- public void SetMenuParent(IMenuEntity parent) => visibility.SetParent(parent.Visibility);
-
- ///
- /// Add this MenuElement directly to a UI GameObject without an associated IMenuEntity.
- ///
- public void SetGameObjectParent(GameObject container)
- {
- if (Container.transform.parent != null)
- throw new ArgumentException("GameObject parent already set");
-
+ public void SetGameObjectParent(GameObject container) =>
Container.transform.SetParent(container.transform, false);
- }
+
+ ///
+ public void ClearGameObjectParent() => Container.transform.parent = null;
}
diff --git a/Elements/TextInput.cs b/Elements/TextInput.cs
new file mode 100644
index 0000000..9ff4def
--- /dev/null
+++ b/Elements/TextInput.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+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 arbitrary text input.
+///
+public class TextInput : SelectableValueElement
+{
+ private static readonly HashSet intTypes =
+ [
+ typeof(byte),
+ typeof(short),
+ typeof(ushort),
+ typeof(int),
+ typeof(uint),
+ typeof(long),
+ typeof(ulong),
+ ];
+ private static readonly HashSet floatTypes = [typeof(float), typeof(double)];
+
+ ///
+ /// Construct a basic text input.
+ ///
+ public TextInput(string label, ITextModel model, string description = "")
+ : base(MenuPrefabs.Get().NewTextInputContainer(out var inputField), inputField, model)
+ {
+ TextModel = model;
+ var input = inputField.gameObject;
+
+ LabelText = input.FindChild("Menu Option Label")!.GetComponent();
+ DescriptionText = input.FindChild("Description")!.GetComponent();
+ InputField = inputField;
+
+ OnTextValueChanged += value => InputField.text = value;
+
+ LabelText.text = label;
+ DescriptionText.text = description;
+
+ if (intTypes.Contains(typeof(T)))
+ InputField.contentType = InputField.ContentType.IntegerNumber;
+ else if (floatTypes.Contains(typeof(T)))
+ InputField.contentType = InputField.ContentType.DecimalNumber;
+ }
+
+ ///
+ /// The value holder and model underlying this choice element.
+ ///
+ public readonly ITextModel TextModel;
+
+ ///
+ /// The unity component for the label of this value choice.
+ ///
+ public readonly Text LabelText;
+
+ ///
+ /// The unity component for the description of this value choice.
+ ///
+ public readonly Text DescriptionText;
+
+ ///
+ /// The unity component for the selected value.
+ ///
+ public readonly InputField InputField;
+
+ ///
+ /// Event notified whenever the text value of the field changes.
+ ///
+ public event Action OnTextValueChanged
+ {
+ add => TextModel.OnTextValueChanged += value;
+ remove => TextModel.OnTextValueChanged -= value;
+ }
+
+ ///
+ public override void SetMainColor(Color color)
+ {
+ LabelText.color = color;
+ DescriptionText.color = color;
+ InputField.textComponent.color = color;
+ }
+
+ ///
+ public override void SetFontSizes(FontSizes fontSizes)
+ {
+ LabelText.fontSize = fontSizes.LabelSize();
+ DescriptionText.fontSize = fontSizes.DescriptionSize();
+ InputField.textComponent.fontSize = fontSizes.ChoiceSize();
+ }
+}
diff --git a/Elements/TextLabel.cs b/Elements/TextLabel.cs
new file mode 100644
index 0000000..88bd189
--- /dev/null
+++ b/Elements/TextLabel.cs
@@ -0,0 +1,34 @@
+using Silksong.ModMenu.Internal;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace Silksong.ModMenu.Elements;
+
+///
+/// A simple text label with no interactive functionality.
+///
+public class TextLabel : MenuElement
+{
+ ///
+ /// Construct a label with the given text contents.
+ ///
+ public TextLabel(string text)
+ : base(MenuPrefabs.Get().NewTextLabel())
+ {
+ Container.name = text;
+ Text = Container.GetComponent();
+ Text.text = text;
+ }
+
+ ///
+ /// The unity text component of the given object.
+ ///
+ public readonly Text Text;
+
+ ///
+ public override void SetMainColor(Color color) => Text.color = color;
+
+ ///
+ public override void SetFontSizes(FontSizes fontSizes) =>
+ Text.fontSize = FontSizeConstants.LabelSize(fontSizes);
+}
diff --git a/Elements/VerticalGroup.cs b/Elements/VerticalGroup.cs
index 8b10442..d67a993 100644
--- a/Elements/VerticalGroup.cs
+++ b/Elements/VerticalGroup.cs
@@ -13,7 +13,7 @@ namespace Silksong.ModMenu.Elements;
///
public class VerticalGroup : AbstractGroup
{
- private readonly List entities = [];
+ private readonly IndexedList entities = [];
///
/// Vertical space between rendered elements.
@@ -34,7 +34,7 @@ public class VerticalGroup : AbstractGroup
public void Add(IMenuEntity entity)
{
entities.Add(entity);
- ParentEntity(entity);
+ AddChild(entity);
}
///
@@ -46,6 +46,36 @@ public void AddRange(IEnumerable entities)
Add(entity);
}
+ ///
+ /// Insert the entity at the specified index.
+ ///
+ public void Insert(int index, IMenuEntity entity)
+ {
+ entities.Insert(index, entity);
+ AddChild(entity);
+ }
+
+ ///
+ /// Remove the specified entity from this layout group.
+ ///
+ public bool Remove(IMenuEntity entity)
+ {
+ if (!entities.Remove(entity))
+ return false;
+
+ entity.ClearParents();
+ return true;
+ }
+
+ ///
+ /// Remove the entity at the specified index.
+ ///
+ public void RemoveAt(int index)
+ {
+ if (entities.TryRemoveAt(index, out var entity))
+ entity.ClearParents();
+ }
+
///
public override bool GetSelectable(
NavigationDirection direction,
diff --git a/Elements/VisibilityManager.cs b/Elements/VisibilityManager.cs
index 988c726..997209c 100644
--- a/Elements/VisibilityManager.cs
+++ b/Elements/VisibilityManager.cs
@@ -7,7 +7,8 @@ namespace Silksong.ModMenu.Elements;
///
/// This mirrors the GameObject visibility model, without requiring a GameObject.
///
-public class VisibilityManager
+/// Whether this entity should be treated as visible without a parent.
+public class VisibilityManager(bool DefaultVisibility)
{
private VisibilityManager? parent;
@@ -53,14 +54,24 @@ private set
///
public void SetParent(VisibilityManager parent)
{
- if (this.parent != null)
- throw new ArgumentException($"{nameof(parent)} akready set");
-
+ this.parent?.OnVisibilityChanged -= UpdateVisibleInHierarchy;
this.parent = parent;
- parent.OnVisibilityChanged += _ => UpdateVisibleInHierarchy();
+ this.parent.OnVisibilityChanged += UpdateVisibleInHierarchy;
+ UpdateVisibleInHierarchy();
+ }
+
+ ///
+ /// Remove this entity from its parent, make it headless.
+ ///
+ public void ClearParent()
+ {
+ parent?.OnVisibilityChanged -= UpdateVisibleInHierarchy;
+ parent = null;
UpdateVisibleInHierarchy();
}
+ private void UpdateVisibleInHierarchy(bool parentVisible) => UpdateVisibleInHierarchy();
+
private void UpdateVisibleInHierarchy() =>
- VisibleInHierarchy = VisibleSelf && (parent?.VisibleInHierarchy ?? true);
+ VisibleInHierarchy = VisibleSelf && (parent?.VisibleInHierarchy ?? DefaultVisibility);
}
diff --git a/Internal/CustomInputField.cs b/Internal/CustomInputField.cs
new file mode 100644
index 0000000..f55a599
--- /dev/null
+++ b/Internal/CustomInputField.cs
@@ -0,0 +1,83 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
+
+namespace Silksong.ModMenu.Internal;
+
+// Largely copied from https://github.com/homothetyhk/HollowKnight.MenuChanger/blob/master/MenuChanger/CustomInputField.cs
+internal class CustomInputField : InputField
+{
+ private static readonly HashSet navigationCodes =
+ [
+ KeyCode.UpArrow,
+ KeyCode.DownArrow,
+ KeyCode.LeftArrow,
+ KeyCode.RightArrow,
+ ];
+
+ private RectTransform? textRect;
+ private readonly Event processingEvent = new();
+
+ private new void Awake()
+ {
+ base.Awake();
+ textRect = textComponent.gameObject.GetComponent();
+ }
+
+ private void Update()
+ {
+ if (textRect == null)
+ return;
+
+ var width = Mathf.Max(200, preferredWidth);
+ textRect.offsetMin = new(-width, 0);
+ textRect.sizeDelta = new(width, 0);
+ }
+
+ private bool AllSelected() =>
+ caretPositionInternal == text.Length && caretSelectPositionInternal == 0;
+
+ public override void OnUpdateSelected(BaseEventData eventData)
+ {
+ if (!isFocused)
+ return;
+
+ bool allSelected = AllSelected();
+ bool updateLabel = false;
+ while (Event.PopEvent(processingEvent))
+ {
+ if (processingEvent.rawType == EventType.KeyDown)
+ {
+ updateLabel = true;
+ if (allSelected && navigationCodes.Contains(processingEvent.keyCode))
+ {
+ updateLabel = false;
+ DeactivateInputField();
+ break;
+ // stop processing events, because we are about to deselect
+ }
+ if (KeyPressed(processingEvent) == InputField.EditState.Finish)
+ {
+ DeactivateInputField();
+ break;
+ }
+ }
+
+ if (processingEvent.type <= EventType.ExecuteCommand)
+ {
+ string commandName = processingEvent.commandName;
+ if (commandName != null && commandName == "SelectAll")
+ {
+ SelectAll();
+ updateLabel = true;
+ }
+ }
+ }
+
+ if (updateLabel)
+ UpdateLabel();
+ if (!allSelected)
+ eventData.Use();
+ }
+}
diff --git a/Internal/CustomMappableKey.cs b/Internal/CustomMappableKey.cs
new file mode 100644
index 0000000..f81f891
--- /dev/null
+++ b/Internal/CustomMappableKey.cs
@@ -0,0 +1,256 @@
+using System.Collections.Generic;
+using GlobalEnums;
+using InControl;
+using Silksong.ModMenu.Models;
+using TeamCherry.Localization;
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
+
+namespace Silksong.ModMenu.Internal;
+
+// This is a partial transcription of MappableKey (though it has been dramatically changed).
+// The source that follows is basically the original except:
+// 1) All references to PlayerActions and BindingSources have been removed.
+// 2) Methods that now do nothing have been removed / inlined.
+// 3) Dumb things like member variables that should be constants have been refactored.
+internal class CustomMappableKey
+ : MenuButton,
+ ISubmitHandler,
+ IEventSystemHandler,
+ IPointerClickHandler,
+ ICancelHandler
+{
+ private static readonly HashSet unmappableKeys =
+ [
+ new(Key.Escape),
+ new(Key.Return),
+ new(Key.Numlock),
+ new(Key.LeftCommand),
+ new(Key.RightCommand),
+ ];
+
+ internal Text? KeymapText { get; private set; }
+ internal Image? KeymapImage { get; private set; }
+
+ internal static CustomMappableKey Replace(MappableKey src)
+ {
+ // We copy all the necessary fields over one-by-one rather than modifying the MappableKey source code directly.
+ // The number and scale of source edits required to decouple from PlayerAction would be far worse to execute via ILHooks.
+ var animationTriggers = src.animationTriggers;
+ var colors = src.colors;
+ var keymapImage = src.keymapSprite;
+ var keymapText = src.keymapText;
+ var leftCursor = src.leftCursor;
+ var menuCancelVibration = src.menuCancelVibration;
+ var menuSubmitVibration = src.menuSubmitVibration;
+ var rightCursor = src.rightCursor;
+
+ var obj = src.gameObject;
+ DestroyImmediate(src); // We cannot wait 1 frame to add a new Selectable component.
+
+ var wasActive = obj.activeSelf;
+ obj.SetActive(false);
+ CustomMappableKey dest = obj.AddComponent();
+ dest.animationTriggers = animationTriggers;
+ dest.buttonType = MenuButtonType.Proceed;
+ dest.cancelAction = CancelAction.DoNothing;
+ dest.colors = colors;
+ dest.DontPlaySelectSound = true;
+ dest.KeymapImage = keymapImage;
+ dest.KeymapText = keymapText;
+ dest.leftCursor = leftCursor;
+ dest.menuCancelVibration = menuCancelVibration;
+ dest.menuSubmitVibration = menuSubmitVibration;
+ dest.playSubmitSound = true;
+ dest.prevSelectedObject = obj;
+ dest.rightCursor = rightCursor;
+ dest.transition = Transition.None;
+ dest.uiAudioPlayer = UIManager.instance.uiAudioPlayer;
+ obj.SetActive(wasActive);
+
+ return dest;
+ }
+
+ private bool isListening;
+ private readonly KeyBindingSourceListener listener = new();
+
+ internal IValueModel? KeyCodeModel
+ {
+ get => field;
+ set
+ {
+ if (field == value)
+ return;
+ field?.OnValueChanged -= OnKeyCodeChanged;
+ field = value;
+ field?.OnValueChanged += OnKeyCodeChanged;
+
+ ShowCurrentKeyCode();
+ }
+ }
+
+ private void OnKeyCodeChanged(KeyCode keyCode) => ShowCurrentKeyCode();
+
+ private InputHandler.KeyOrMouseBinding CurrentBinding =>
+ new(isListening ? Key.None : KeyCodeUtil.ToKey(KeyCodeModel?.Value ?? KeyCode.None));
+
+ private new void OnDisable()
+ {
+ if (isListening)
+ AbortRebind();
+ base.OnDisable();
+ }
+
+ private static UIButtonSkins UIButtonSkins => GameManager.instance.ui.uiButtonSkins;
+
+ private void ListenForNewButton()
+ {
+ if (isListening || KeymapText == null || KeymapImage == null)
+ return;
+
+ interactable = false;
+ isListening = true;
+ listener.Reset();
+ ShowCurrentKeyCode();
+ }
+
+ private static bool GetKey(KeyBindingSource keyBinding, out Key key)
+ {
+ List keys = [];
+ for (int i = 0; i < keyBinding.Control.IncludeCount; i++)
+ {
+ var ret = keyBinding.Control.GetInclude(i);
+ if (ret != Key.None)
+ keys.Add(ret);
+ }
+
+ if (keys.Count == 1)
+ {
+ key = keys[0];
+ return true;
+ }
+ else
+ {
+ key = Key.None;
+ return false;
+ }
+ }
+
+ private void Update()
+ {
+ if (!isListening)
+ return;
+
+ var source = listener.Listen(
+ new() { IncludeKeys = true, IncludeModifiersAsFirstClassKeys = true },
+ InputManager.ActiveDevice
+ );
+
+ if (
+ source is KeyBindingSource keyBinding
+ && !unmappableKeys.Contains(keyBinding)
+ && GetKey(keyBinding, out var key)
+ )
+ {
+ isListening = false;
+ interactable = true;
+ KeyCodeModel?.Value = KeyCodeUtil.ToKeyCode(key);
+ ShowCurrentKeyCode();
+ }
+ else if (source != null)
+ AbortRebind();
+ }
+
+ public void ShowCurrentKeyCode()
+ {
+ if (KeymapText == null || KeymapImage == null)
+ return;
+
+ var skins = UIButtonSkins;
+ if (isListening)
+ {
+ KeymapImage.sprite = UIButtonSkins.blankKey;
+ KeymapText.text = Language.Get("KEYBOARD_PRESSKEY", "MainMenu");
+ KeymapText.fontSize = MappableKey.blankFontSize;
+ KeymapText.alignment = MappableKey.blankAlignment;
+ KeymapText.horizontalOverflow = MappableKey.blankOverflow;
+ KeymapText.GetComponent().AlignText();
+ }
+ else if (InputHandler.KeyOrMouseBinding.IsNone(CurrentBinding))
+ {
+ KeymapImage.sprite = skins.blankKey;
+ KeymapText.text = Language.Get("KEYBOARD_UNMAPPED", "MainMenu");
+ KeymapText.fontSize = MappableKey.blankFontSize;
+ KeymapText.alignment = MappableKey.blankAlignment;
+ KeymapText.resizeTextForBestFit = MappableKey.blankBestFit;
+ KeymapText.horizontalOverflow = MappableKey.blankOverflow;
+ KeymapText.GetComponent().AlignText();
+ }
+ else
+ {
+ ButtonSkin keyboardSkinFor = skins.GetButtonSkinFor(CurrentBinding.ToString());
+ KeymapImage.sprite =
+ keyboardSkinFor.sprite != null ? keyboardSkinFor.sprite : skins.blankKey;
+ KeymapText.text = keyboardSkinFor.symbol;
+ if (keyboardSkinFor.skinType == ButtonSkinType.SQUARE)
+ {
+ KeymapText.fontSize = MappableKey.sqrFontSize;
+ KeymapText.alignment = MappableKey.sqrAlignment;
+ KeymapText.rectTransform.anchoredPosition = new(
+ MappableKey.sqrX,
+ KeymapText.rectTransform.anchoredPosition.y
+ );
+ KeymapText.rectTransform.SetSizeWithCurrentAnchors(
+ RectTransform.Axis.Horizontal,
+ MappableKey.sqrWidth
+ );
+ KeymapText.resizeTextForBestFit = MappableKey.sqrBestFit;
+ KeymapText.resizeTextMinSize = MappableKey.sqrMinFont;
+ KeymapText.resizeTextMaxSize = MappableKey.sqrMaxFont;
+ KeymapText.horizontalOverflow = MappableKey.sqrHOverflow;
+ }
+ else if (keyboardSkinFor.skinType == ButtonSkinType.WIDE)
+ {
+ KeymapText.fontSize = MappableKey.wideFontSize;
+ KeymapText.alignment = MappableKey.wideAlignment;
+ KeymapText.rectTransform.anchoredPosition = new(
+ MappableKey.wideX,
+ KeymapText.rectTransform.anchoredPosition.y
+ );
+ KeymapText.rectTransform.SetSizeWithCurrentAnchors(
+ RectTransform.Axis.Horizontal,
+ MappableKey.wideWidth
+ );
+ KeymapText.resizeTextForBestFit = MappableKey.wideBestFit;
+ KeymapText.horizontalOverflow = MappableKey.wideHOverflow;
+ }
+ else
+ KeymapText.alignment = skins.labelAlignment;
+
+ KeymapText.GetComponent().AlignTextKeymap();
+ }
+ }
+
+ internal void AbortRebind()
+ {
+ if (!isListening || KeymapText == null || KeymapImage == null)
+ return;
+
+ interactable = true;
+ isListening = false;
+ ShowCurrentKeyCode();
+ }
+
+ public new void OnSubmit(BaseEventData eventData) => ListenForNewButton();
+
+ public new void OnPointerClick(PointerEventData eventData) => ListenForNewButton();
+
+ public new void OnCancel(BaseEventData eventData)
+ {
+ if (isListening)
+ AbortRebind();
+ else
+ base.OnCancel(eventData);
+ }
+}
diff --git a/Internal/EventSuppressor.cs b/Internal/EventSuppressor.cs
new file mode 100644
index 0000000..69dad73
--- /dev/null
+++ b/Internal/EventSuppressor.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace Silksong.ModMenu.Internal;
+
+internal class EventSuppressor
+{
+ private int suppressors;
+
+ public bool Suppressed => suppressors > 0;
+
+ public IDisposable Suppress() => new Lease(this);
+
+ private class Lease : IDisposable
+ {
+ private readonly EventSuppressor parent;
+
+ public Lease(EventSuppressor parent)
+ {
+ this.parent = parent;
+ parent.suppressors++;
+ }
+
+ public void Dispose() => parent.suppressors--;
+ }
+}
diff --git a/Internal/IndexedList.cs b/Internal/IndexedList.cs
new file mode 100644
index 0000000..b2f9f4c
--- /dev/null
+++ b/Internal/IndexedList.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Silksong.ModMenu.Internal;
+
+///
+/// A list that forces unique elements and indexes them for efficient containment checks.
+///
+internal class IndexedList : IList
+{
+ private readonly List list = [];
+ private readonly Dictionary lookup = [];
+
+ public T this[int index]
+ {
+ get => list[index];
+ set
+ {
+ if (index < 0 || index >= list.Count)
+ throw new IndexOutOfRangeException($"{index} (Count: {list.Count})");
+
+ if (lookup.TryGetValue(list[index], out var prev))
+ {
+ if (prev == index)
+ return;
+ else
+ throw new ArgumentException($"Item already elsewhere in list");
+ }
+
+ lookup.Remove(list[index]);
+ list[index] = value;
+ lookup[value] = index;
+ }
+ }
+
+ public int Count => list.Count;
+
+ public bool IsReadOnly => false;
+
+ public void Add(T item)
+ {
+ if (lookup.ContainsKey(item))
+ throw new ArgumentException("Item already exists");
+
+ list.Add(item);
+ lookup[item] = list.Count - 1;
+ }
+
+ public void Clear()
+ {
+ list.Clear();
+ lookup.Clear();
+ }
+
+ public bool Contains(T item) => lookup.ContainsKey(item);
+
+ public void CopyTo(T[] array, int arrayIndex) => list.CopyTo(array, arrayIndex);
+
+ public IEnumerator GetEnumerator() => list.GetEnumerator();
+
+ public int IndexOf(T item) => lookup.TryGetValue(item, out var idx) ? idx : -1;
+
+ public void Insert(int index, T item)
+ {
+ if (lookup.TryGetValue(item, out var idx))
+ {
+ if (index == idx)
+ return;
+ else
+ throw new ArgumentException("Item already exists");
+ }
+
+ list.Insert(index, item);
+ UpdateIndex(index);
+ }
+
+ public bool Remove(T item)
+ {
+ if (!lookup.TryGetValue(item, out var index))
+ return false;
+
+ RemoveAt(index);
+ return true;
+ }
+
+ public void RemoveAt(int index) => TryRemoveAt(index, out _);
+
+ public bool TryRemoveAt(int index, [MaybeNullWhen(false)] out T item)
+ {
+ item = default;
+ if (index < 0 || index >= list.Count)
+ return false;
+
+ item = list[index];
+ list.RemoveAt(index);
+ UpdateIndex(index);
+ return true;
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => list.GetEnumerator();
+
+ private void UpdateIndex(int from)
+ {
+ for (int i = from; i < list.Count; i++)
+ lookup[list[i]] = i;
+ }
+}
diff --git a/Internal/KeyCodeUtil.cs b/Internal/KeyCodeUtil.cs
new file mode 100644
index 0000000..08afaca
--- /dev/null
+++ b/Internal/KeyCodeUtil.cs
@@ -0,0 +1,486 @@
+using InControl;
+using UnityEngine;
+
+namespace Silksong.ModMenu.Internal;
+
+internal static class KeyCodeUtil
+{
+ internal static KeyCode ToKeyCode(Key key) =>
+ key switch
+ {
+ Key.None => KeyCode.None,
+ Key.Shift => KeyCode.LeftShift,
+ Key.Alt => KeyCode.LeftAlt,
+ Key.Command => KeyCode.LeftCommand,
+ Key.Control => KeyCode.LeftControl,
+ Key.LeftShift => KeyCode.LeftShift,
+ Key.LeftAlt => KeyCode.LeftAlt,
+ Key.LeftCommand => KeyCode.LeftCommand,
+ Key.LeftControl => KeyCode.LeftControl,
+ Key.RightShift => KeyCode.RightShift,
+ Key.RightAlt => KeyCode.RightAlt,
+ Key.RightCommand => KeyCode.RightCommand,
+ Key.RightControl => KeyCode.RightControl,
+ Key.Escape => KeyCode.Escape,
+ Key.F1 => KeyCode.F1,
+ Key.F2 => KeyCode.F2,
+ Key.F3 => KeyCode.F3,
+ Key.F4 => KeyCode.F4,
+ Key.F5 => KeyCode.F5,
+ Key.F6 => KeyCode.F6,
+ Key.F7 => KeyCode.F7,
+ Key.F8 => KeyCode.F8,
+ Key.F9 => KeyCode.F9,
+ Key.F10 => KeyCode.F10,
+ Key.F11 => KeyCode.F11,
+ Key.F12 => KeyCode.F12,
+ Key.Key0 => KeyCode.Alpha0,
+ Key.Key1 => KeyCode.Alpha1,
+ Key.Key2 => KeyCode.Alpha2,
+ Key.Key3 => KeyCode.Alpha3,
+ Key.Key4 => KeyCode.Alpha4,
+ Key.Key5 => KeyCode.Alpha5,
+ Key.Key6 => KeyCode.Alpha6,
+ Key.Key7 => KeyCode.Alpha7,
+ Key.Key8 => KeyCode.Alpha8,
+ Key.Key9 => KeyCode.Alpha9,
+ Key.A => KeyCode.A,
+ Key.B => KeyCode.B,
+ Key.C => KeyCode.C,
+ Key.D => KeyCode.D,
+ Key.E => KeyCode.E,
+ Key.F => KeyCode.F,
+ Key.G => KeyCode.G,
+ Key.H => KeyCode.H,
+ Key.I => KeyCode.I,
+ Key.J => KeyCode.J,
+ Key.K => KeyCode.K,
+ Key.L => KeyCode.L,
+ Key.M => KeyCode.M,
+ Key.N => KeyCode.N,
+ Key.O => KeyCode.O,
+ Key.P => KeyCode.P,
+ Key.Q => KeyCode.Q,
+ Key.R => KeyCode.R,
+ Key.S => KeyCode.S,
+ Key.T => KeyCode.T,
+ Key.U => KeyCode.U,
+ Key.V => KeyCode.V,
+ Key.W => KeyCode.W,
+ Key.X => KeyCode.X,
+ Key.Y => KeyCode.Y,
+ Key.Z => KeyCode.Z,
+ Key.Backquote => KeyCode.BackQuote,
+ Key.Minus => KeyCode.Minus,
+ Key.Equals => KeyCode.Equals,
+ Key.Backspace => KeyCode.Backspace,
+ Key.Tab => KeyCode.Tab,
+ Key.LeftBracket => KeyCode.LeftBracket,
+ Key.RightBracket => KeyCode.RightBracket,
+ Key.Backslash => KeyCode.Backslash,
+ Key.Semicolon => KeyCode.Semicolon,
+ Key.Quote => KeyCode.Quote,
+ Key.Return => KeyCode.Return,
+ Key.Comma => KeyCode.Comma,
+ Key.Period => KeyCode.Period,
+ Key.Slash => KeyCode.Slash,
+ Key.Space => KeyCode.Space,
+ Key.Insert => KeyCode.Insert,
+ Key.Delete => KeyCode.Delete,
+ Key.Home => KeyCode.Home,
+ Key.End => KeyCode.End,
+ Key.PageUp => KeyCode.PageUp,
+ Key.PageDown => KeyCode.PageDown,
+ Key.LeftArrow => KeyCode.LeftArrow,
+ Key.RightArrow => KeyCode.RightArrow,
+ Key.UpArrow => KeyCode.UpArrow,
+ Key.DownArrow => KeyCode.DownArrow,
+ Key.Pad0 => KeyCode.Keypad0,
+ Key.Pad1 => KeyCode.Keypad1,
+ Key.Pad2 => KeyCode.Keypad2,
+ Key.Pad3 => KeyCode.Keypad3,
+ Key.Pad4 => KeyCode.Keypad4,
+ Key.Pad5 => KeyCode.Keypad5,
+ Key.Pad6 => KeyCode.Keypad6,
+ Key.Pad7 => KeyCode.Keypad7,
+ Key.Pad8 => KeyCode.Keypad8,
+ Key.Pad9 => KeyCode.Keypad9,
+ Key.Numlock => KeyCode.Numlock,
+ Key.PadDivide => KeyCode.KeypadDivide,
+ Key.PadMultiply => KeyCode.KeypadMultiply,
+ Key.PadMinus => KeyCode.KeypadMinus,
+ Key.PadPlus => KeyCode.KeypadPlus,
+ Key.PadEnter => KeyCode.KeypadEnter,
+ Key.PadPeriod => KeyCode.KeypadPeriod,
+ Key.Clear => KeyCode.Clear,
+ Key.PadEquals => KeyCode.KeypadEquals,
+ Key.F13 => KeyCode.F13,
+ Key.F14 => KeyCode.F14,
+ Key.F15 => KeyCode.F15,
+ Key.AltGr => KeyCode.AltGr,
+ Key.CapsLock => KeyCode.CapsLock,
+ Key.ExclamationMark => KeyCode.Exclaim,
+ Key.Tilde => KeyCode.Tilde,
+ Key.At => KeyCode.At,
+ Key.Hash => KeyCode.Hash,
+ Key.Dollar => KeyCode.Dollar,
+ Key.Percent => KeyCode.Percent,
+ Key.Caret => KeyCode.Caret,
+ Key.Ampersand => KeyCode.Ampersand,
+ Key.Asterisk => KeyCode.Asterisk,
+ Key.LeftParen => KeyCode.LeftParen,
+ Key.RightParen => KeyCode.RightParen,
+ Key.Underscore => KeyCode.Underscore,
+ Key.Plus => KeyCode.Plus,
+ Key.LeftBrace => KeyCode.LeftCurlyBracket,
+ Key.RightBrace => KeyCode.RightCurlyBracket,
+ Key.Pipe => KeyCode.Pipe,
+ Key.Colon => KeyCode.Colon,
+ Key.DoubleQuote => KeyCode.DoubleQuote,
+ Key.LessThan => KeyCode.Less,
+ Key.GreaterThan => KeyCode.Greater,
+ Key.QuestionMark => KeyCode.Question,
+ _ => KeyCode.None,
+ };
+
+ internal static Key ToKey(KeyCode keyCode) =>
+ keyCode switch
+ {
+ KeyCode.None => Key.None,
+ KeyCode.Backspace => Key.Backspace,
+ KeyCode.Delete => Key.Delete,
+ KeyCode.Tab => Key.Tab,
+ KeyCode.Clear => Key.Clear,
+ KeyCode.Return => Key.Return,
+ KeyCode.Pause => Key.None,
+ KeyCode.Escape => Key.Escape,
+ KeyCode.Space => Key.Space,
+ KeyCode.Keypad0 => Key.Pad0,
+ KeyCode.Keypad1 => Key.Pad1,
+ KeyCode.Keypad2 => Key.Pad2,
+ KeyCode.Keypad3 => Key.Pad3,
+ KeyCode.Keypad4 => Key.Pad4,
+ KeyCode.Keypad5 => Key.Pad5,
+ KeyCode.Keypad6 => Key.Pad6,
+ KeyCode.Keypad7 => Key.Pad7,
+ KeyCode.Keypad8 => Key.Pad8,
+ KeyCode.Keypad9 => Key.Pad9,
+ KeyCode.KeypadPeriod => Key.PadPeriod,
+ KeyCode.KeypadDivide => Key.PadDivide,
+ KeyCode.KeypadMultiply => Key.PadMultiply,
+ KeyCode.KeypadMinus => Key.PadMinus,
+ KeyCode.KeypadPlus => Key.PadPlus,
+ KeyCode.KeypadEnter => Key.PadEnter,
+ KeyCode.KeypadEquals => Key.PadEquals,
+ KeyCode.UpArrow => Key.UpArrow,
+ KeyCode.DownArrow => Key.DownArrow,
+ KeyCode.RightArrow => Key.RightArrow,
+ KeyCode.LeftArrow => Key.LeftArrow,
+ KeyCode.Insert => Key.Insert,
+ KeyCode.Home => Key.Home,
+ KeyCode.End => Key.End,
+ KeyCode.PageUp => Key.PageUp,
+ KeyCode.PageDown => Key.PageDown,
+ KeyCode.F1 => Key.F1,
+ KeyCode.F2 => Key.F2,
+ KeyCode.F3 => Key.F3,
+ KeyCode.F4 => Key.F4,
+ KeyCode.F5 => Key.F5,
+ KeyCode.F6 => Key.F6,
+ KeyCode.F7 => Key.F7,
+ KeyCode.F8 => Key.F8,
+ KeyCode.F9 => Key.F9,
+ KeyCode.F10 => Key.F10,
+ KeyCode.F11 => Key.F11,
+ KeyCode.F12 => Key.F12,
+ KeyCode.F13 => Key.F13,
+ KeyCode.F14 => Key.F14,
+ KeyCode.F15 => Key.F15,
+ KeyCode.Alpha0 => Key.Key0,
+ KeyCode.Alpha1 => Key.Key1,
+ KeyCode.Alpha2 => Key.Key2,
+ KeyCode.Alpha3 => Key.Key3,
+ KeyCode.Alpha4 => Key.Key4,
+ KeyCode.Alpha5 => Key.Key5,
+ KeyCode.Alpha6 => Key.Key6,
+ KeyCode.Alpha7 => Key.Key7,
+ KeyCode.Alpha8 => Key.Key8,
+ KeyCode.Alpha9 => Key.Key9,
+ KeyCode.Exclaim => Key.ExclamationMark,
+ KeyCode.DoubleQuote => Key.DoubleQuote,
+ KeyCode.Hash => Key.Hash,
+ KeyCode.Dollar => Key.Dollar,
+ KeyCode.Percent => Key.Percent,
+ KeyCode.Ampersand => Key.Ampersand,
+ KeyCode.Quote => Key.Quote,
+ KeyCode.LeftParen => Key.LeftParen,
+ KeyCode.RightParen => Key.RightParen,
+ KeyCode.Asterisk => Key.Asterisk,
+ KeyCode.Plus => Key.Plus,
+ KeyCode.Comma => Key.Comma,
+ KeyCode.Minus => Key.Minus,
+ KeyCode.Period => Key.Period,
+ KeyCode.Slash => Key.Slash,
+ KeyCode.Colon => Key.Colon,
+ KeyCode.Semicolon => Key.Semicolon,
+ KeyCode.Less => Key.LessThan,
+ KeyCode.Equals => Key.Equals,
+ KeyCode.Greater => Key.GreaterThan,
+ KeyCode.Question => Key.QuestionMark,
+ KeyCode.At => Key.At,
+ KeyCode.LeftBracket => Key.LeftBracket,
+ KeyCode.Backslash => Key.Backslash,
+ KeyCode.RightBracket => Key.RightBracket,
+ KeyCode.Caret => Key.Caret,
+ KeyCode.Underscore => Key.Underscore,
+ KeyCode.BackQuote => Key.Backquote,
+ KeyCode.A => Key.A,
+ KeyCode.B => Key.B,
+ KeyCode.C => Key.C,
+ KeyCode.D => Key.D,
+ KeyCode.E => Key.E,
+ KeyCode.F => Key.F,
+ KeyCode.G => Key.G,
+ KeyCode.H => Key.H,
+ KeyCode.I => Key.I,
+ KeyCode.J => Key.J,
+ KeyCode.K => Key.K,
+ KeyCode.L => Key.L,
+ KeyCode.M => Key.M,
+ KeyCode.N => Key.N,
+ KeyCode.O => Key.O,
+ KeyCode.P => Key.P,
+ KeyCode.Q => Key.Q,
+ KeyCode.R => Key.R,
+ KeyCode.S => Key.S,
+ KeyCode.T => Key.T,
+ KeyCode.U => Key.U,
+ KeyCode.V => Key.V,
+ KeyCode.W => Key.W,
+ KeyCode.X => Key.X,
+ KeyCode.Y => Key.Y,
+ KeyCode.Z => Key.Z,
+ KeyCode.LeftCurlyBracket => Key.LeftBrace,
+ KeyCode.Pipe => Key.Pipe,
+ KeyCode.RightCurlyBracket => Key.RightBrace,
+ KeyCode.Tilde => Key.Tilde,
+ KeyCode.Numlock => Key.Numlock,
+ KeyCode.CapsLock => Key.CapsLock,
+ KeyCode.ScrollLock => Key.None,
+ KeyCode.RightShift => Key.RightShift,
+ KeyCode.LeftShift => Key.LeftShift,
+ KeyCode.RightControl => Key.RightControl,
+ KeyCode.LeftControl => Key.LeftControl,
+ KeyCode.RightAlt => Key.RightAlt,
+ KeyCode.LeftAlt => Key.LeftAlt,
+ KeyCode.LeftMeta => Key.None,
+ KeyCode.LeftWindows => Key.LeftCommand,
+ KeyCode.RightMeta => Key.None,
+ KeyCode.RightWindows => Key.RightCommand,
+ KeyCode.AltGr => Key.AltGr,
+ KeyCode.Help => Key.None,
+ KeyCode.Print => Key.None,
+ KeyCode.SysReq => Key.None,
+ KeyCode.Break => Key.None,
+ KeyCode.Menu => Key.None,
+ KeyCode.WheelUp => Key.None,
+ KeyCode.WheelDown => Key.None,
+ KeyCode.F16 => Key.None,
+ KeyCode.F17 => Key.None,
+ KeyCode.F18 => Key.None,
+ KeyCode.F19 => Key.None,
+ KeyCode.F20 => Key.None,
+ KeyCode.F21 => Key.None,
+ KeyCode.F22 => Key.None,
+ KeyCode.F23 => Key.None,
+ KeyCode.F24 => Key.None,
+ KeyCode.Mouse0 => Key.None,
+ KeyCode.Mouse1 => Key.None,
+ KeyCode.Mouse2 => Key.None,
+ KeyCode.Mouse3 => Key.None,
+ KeyCode.Mouse4 => Key.None,
+ KeyCode.Mouse5 => Key.None,
+ KeyCode.Mouse6 => Key.None,
+ KeyCode.JoystickButton0 => Key.None,
+ KeyCode.JoystickButton1 => Key.None,
+ KeyCode.JoystickButton2 => Key.None,
+ KeyCode.JoystickButton3 => Key.None,
+ KeyCode.JoystickButton4 => Key.None,
+ KeyCode.JoystickButton5 => Key.None,
+ KeyCode.JoystickButton6 => Key.None,
+ KeyCode.JoystickButton7 => Key.None,
+ KeyCode.JoystickButton8 => Key.None,
+ KeyCode.JoystickButton9 => Key.None,
+ KeyCode.JoystickButton10 => Key.None,
+ KeyCode.JoystickButton11 => Key.None,
+ KeyCode.JoystickButton12 => Key.None,
+ KeyCode.JoystickButton13 => Key.None,
+ KeyCode.JoystickButton14 => Key.None,
+ KeyCode.JoystickButton15 => Key.None,
+ KeyCode.JoystickButton16 => Key.None,
+ KeyCode.JoystickButton17 => Key.None,
+ KeyCode.JoystickButton18 => Key.None,
+ KeyCode.JoystickButton19 => Key.None,
+ KeyCode.Joystick1Button0 => Key.None,
+ KeyCode.Joystick1Button1 => Key.None,
+ KeyCode.Joystick1Button2 => Key.None,
+ KeyCode.Joystick1Button3 => Key.None,
+ KeyCode.Joystick1Button4 => Key.None,
+ KeyCode.Joystick1Button5 => Key.None,
+ KeyCode.Joystick1Button6 => Key.None,
+ KeyCode.Joystick1Button7 => Key.None,
+ KeyCode.Joystick1Button8 => Key.None,
+ KeyCode.Joystick1Button9 => Key.None,
+ KeyCode.Joystick1Button10 => Key.None,
+ KeyCode.Joystick1Button11 => Key.None,
+ KeyCode.Joystick1Button12 => Key.None,
+ KeyCode.Joystick1Button13 => Key.None,
+ KeyCode.Joystick1Button14 => Key.None,
+ KeyCode.Joystick1Button15 => Key.None,
+ KeyCode.Joystick1Button16 => Key.None,
+ KeyCode.Joystick1Button17 => Key.None,
+ KeyCode.Joystick1Button18 => Key.None,
+ KeyCode.Joystick1Button19 => Key.None,
+ KeyCode.Joystick2Button0 => Key.None,
+ KeyCode.Joystick2Button1 => Key.None,
+ KeyCode.Joystick2Button2 => Key.None,
+ KeyCode.Joystick2Button3 => Key.None,
+ KeyCode.Joystick2Button4 => Key.None,
+ KeyCode.Joystick2Button5 => Key.None,
+ KeyCode.Joystick2Button6 => Key.None,
+ KeyCode.Joystick2Button7 => Key.None,
+ KeyCode.Joystick2Button8 => Key.None,
+ KeyCode.Joystick2Button9 => Key.None,
+ KeyCode.Joystick2Button10 => Key.None,
+ KeyCode.Joystick2Button11 => Key.None,
+ KeyCode.Joystick2Button12 => Key.None,
+ KeyCode.Joystick2Button13 => Key.None,
+ KeyCode.Joystick2Button14 => Key.None,
+ KeyCode.Joystick2Button15 => Key.None,
+ KeyCode.Joystick2Button16 => Key.None,
+ KeyCode.Joystick2Button17 => Key.None,
+ KeyCode.Joystick2Button18 => Key.None,
+ KeyCode.Joystick2Button19 => Key.None,
+ KeyCode.Joystick3Button0 => Key.None,
+ KeyCode.Joystick3Button1 => Key.None,
+ KeyCode.Joystick3Button2 => Key.None,
+ KeyCode.Joystick3Button3 => Key.None,
+ KeyCode.Joystick3Button4 => Key.None,
+ KeyCode.Joystick3Button5 => Key.None,
+ KeyCode.Joystick3Button6 => Key.None,
+ KeyCode.Joystick3Button7 => Key.None,
+ KeyCode.Joystick3Button8 => Key.None,
+ KeyCode.Joystick3Button9 => Key.None,
+ KeyCode.Joystick3Button10 => Key.None,
+ KeyCode.Joystick3Button11 => Key.None,
+ KeyCode.Joystick3Button12 => Key.None,
+ KeyCode.Joystick3Button13 => Key.None,
+ KeyCode.Joystick3Button14 => Key.None,
+ KeyCode.Joystick3Button15 => Key.None,
+ KeyCode.Joystick3Button16 => Key.None,
+ KeyCode.Joystick3Button17 => Key.None,
+ KeyCode.Joystick3Button18 => Key.None,
+ KeyCode.Joystick3Button19 => Key.None,
+ KeyCode.Joystick4Button0 => Key.None,
+ KeyCode.Joystick4Button1 => Key.None,
+ KeyCode.Joystick4Button2 => Key.None,
+ KeyCode.Joystick4Button3 => Key.None,
+ KeyCode.Joystick4Button4 => Key.None,
+ KeyCode.Joystick4Button5 => Key.None,
+ KeyCode.Joystick4Button6 => Key.None,
+ KeyCode.Joystick4Button7 => Key.None,
+ KeyCode.Joystick4Button8 => Key.None,
+ KeyCode.Joystick4Button9 => Key.None,
+ KeyCode.Joystick4Button10 => Key.None,
+ KeyCode.Joystick4Button11 => Key.None,
+ KeyCode.Joystick4Button12 => Key.None,
+ KeyCode.Joystick4Button13 => Key.None,
+ KeyCode.Joystick4Button14 => Key.None,
+ KeyCode.Joystick4Button15 => Key.None,
+ KeyCode.Joystick4Button16 => Key.None,
+ KeyCode.Joystick4Button17 => Key.None,
+ KeyCode.Joystick4Button18 => Key.None,
+ KeyCode.Joystick4Button19 => Key.None,
+ KeyCode.Joystick5Button0 => Key.None,
+ KeyCode.Joystick5Button1 => Key.None,
+ KeyCode.Joystick5Button2 => Key.None,
+ KeyCode.Joystick5Button3 => Key.None,
+ KeyCode.Joystick5Button4 => Key.None,
+ KeyCode.Joystick5Button5 => Key.None,
+ KeyCode.Joystick5Button6 => Key.None,
+ KeyCode.Joystick5Button7 => Key.None,
+ KeyCode.Joystick5Button8 => Key.None,
+ KeyCode.Joystick5Button9 => Key.None,
+ KeyCode.Joystick5Button10 => Key.None,
+ KeyCode.Joystick5Button11 => Key.None,
+ KeyCode.Joystick5Button12 => Key.None,
+ KeyCode.Joystick5Button13 => Key.None,
+ KeyCode.Joystick5Button14 => Key.None,
+ KeyCode.Joystick5Button15 => Key.None,
+ KeyCode.Joystick5Button16 => Key.None,
+ KeyCode.Joystick5Button17 => Key.None,
+ KeyCode.Joystick5Button18 => Key.None,
+ KeyCode.Joystick5Button19 => Key.None,
+ KeyCode.Joystick6Button0 => Key.None,
+ KeyCode.Joystick6Button1 => Key.None,
+ KeyCode.Joystick6Button2 => Key.None,
+ KeyCode.Joystick6Button3 => Key.None,
+ KeyCode.Joystick6Button4 => Key.None,
+ KeyCode.Joystick6Button5 => Key.None,
+ KeyCode.Joystick6Button6 => Key.None,
+ KeyCode.Joystick6Button7 => Key.None,
+ KeyCode.Joystick6Button8 => Key.None,
+ KeyCode.Joystick6Button9 => Key.None,
+ KeyCode.Joystick6Button10 => Key.None,
+ KeyCode.Joystick6Button11 => Key.None,
+ KeyCode.Joystick6Button12 => Key.None,
+ KeyCode.Joystick6Button13 => Key.None,
+ KeyCode.Joystick6Button14 => Key.None,
+ KeyCode.Joystick6Button15 => Key.None,
+ KeyCode.Joystick6Button16 => Key.None,
+ KeyCode.Joystick6Button17 => Key.None,
+ KeyCode.Joystick6Button18 => Key.None,
+ KeyCode.Joystick6Button19 => Key.None,
+ KeyCode.Joystick7Button0 => Key.None,
+ KeyCode.Joystick7Button1 => Key.None,
+ KeyCode.Joystick7Button2 => Key.None,
+ KeyCode.Joystick7Button3 => Key.None,
+ KeyCode.Joystick7Button4 => Key.None,
+ KeyCode.Joystick7Button5 => Key.None,
+ KeyCode.Joystick7Button6 => Key.None,
+ KeyCode.Joystick7Button7 => Key.None,
+ KeyCode.Joystick7Button8 => Key.None,
+ KeyCode.Joystick7Button9 => Key.None,
+ KeyCode.Joystick7Button10 => Key.None,
+ KeyCode.Joystick7Button11 => Key.None,
+ KeyCode.Joystick7Button12 => Key.None,
+ KeyCode.Joystick7Button13 => Key.None,
+ KeyCode.Joystick7Button14 => Key.None,
+ KeyCode.Joystick7Button15 => Key.None,
+ KeyCode.Joystick7Button16 => Key.None,
+ KeyCode.Joystick7Button17 => Key.None,
+ KeyCode.Joystick7Button18 => Key.None,
+ KeyCode.Joystick7Button19 => Key.None,
+ KeyCode.Joystick8Button0 => Key.None,
+ KeyCode.Joystick8Button1 => Key.None,
+ KeyCode.Joystick8Button2 => Key.None,
+ KeyCode.Joystick8Button3 => Key.None,
+ KeyCode.Joystick8Button4 => Key.None,
+ KeyCode.Joystick8Button5 => Key.None,
+ KeyCode.Joystick8Button6 => Key.None,
+ KeyCode.Joystick8Button7 => Key.None,
+ KeyCode.Joystick8Button8 => Key.None,
+ KeyCode.Joystick8Button9 => Key.None,
+ KeyCode.Joystick8Button10 => Key.None,
+ KeyCode.Joystick8Button11 => Key.None,
+ KeyCode.Joystick8Button12 => Key.None,
+ KeyCode.Joystick8Button13 => Key.None,
+ KeyCode.Joystick8Button14 => Key.None,
+ KeyCode.Joystick8Button15 => Key.None,
+ KeyCode.Joystick8Button16 => Key.None,
+ KeyCode.Joystick8Button17 => Key.None,
+ KeyCode.Joystick8Button18 => Key.None,
+ KeyCode.Joystick8Button19 => Key.None,
+ _ => Key.None,
+ };
+}
diff --git a/Internal/MenuPrefabs.cs b/Internal/MenuPrefabs.cs
index 96f13d9..a2712fc 100644
--- a/Internal/MenuPrefabs.cs
+++ b/Internal/MenuPrefabs.cs
@@ -1,9 +1,14 @@
-using Silksong.UnityHelper.Extensions;
+using MonoDetour;
+using MonoDetour.DetourTypes;
+using MonoDetour.HookGen;
+using Silksong.UnityHelper.Extensions;
using UnityEngine;
+using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Silksong.ModMenu.Internal;
+[MonoDetourTargets(typeof(MappableKey), GenerateControlFlowVariants = true)]
internal class MenuPrefabs
{
private static MenuPrefabs? instance;
@@ -17,8 +22,11 @@ internal static MenuPrefabs Get() =>
private readonly GameObject menuTemplate;
private readonly GameObject emptyContentPane;
+ private readonly GameObject keyBindTemplate;
private readonly GameObject textButtonTemplate;
+ private readonly GameObject textLabelTemplate;
private readonly GameObject textChoiceTemplate;
+ private readonly GameObject textInputTemplate;
private readonly GameObject sliderTemplate;
private MenuPrefabs(UIManager uiManager)
@@ -46,6 +54,19 @@ private MenuPrefabs(UIManager uiManager)
Object.Destroy(emptyContentPane.GetComponent());
Object.DontDestroyOnLoad(emptyContentPane);
+ // MappableKey.OnEnable() breaks when instantiated outside the UIButtonSkins hierarchy.
+ using (mappableKeyInit.Suppress())
+ {
+ keyBindTemplate = Object.Instantiate(
+ canvas.FindChild("KeyboardMenuScreen/Content/MappableKeys/UpButton")!
+ );
+ }
+ keyBindTemplate.SetActive(false);
+ Object.Destroy(
+ keyBindTemplate.FindChild("Input Button Text")!.GetComponent()
+ );
+ Object.DontDestroyOnLoad(keyBindTemplate);
+
textButtonTemplate = Object.Instantiate(optionsScreen.FindChild("Content/GameOptions")!);
textButtonTemplate.SetActive(false);
textButtonTemplate.name = "TextButtonContainer";
@@ -55,6 +76,13 @@ private MenuPrefabs(UIManager uiManager)
buttonChild.name = "TextButton";
Object.Destroy(buttonChild.GetComponent());
+ textLabelTemplate = Object.Instantiate(
+ optionsScreen.FindChild("Content/GameOptions/GameOptionsButton/Menu Button Text")!
+ );
+ textLabelTemplate.SetActive(false);
+ textLabelTemplate.name = "TextLabel";
+ Object.DontDestroyOnLoad(textLabelTemplate);
+
textChoiceTemplate = Object.Instantiate(
canvas.FindChild("GameOptionsMenuScreen/Content/CamShakeSetting")!
);
@@ -74,6 +102,26 @@ private MenuPrefabs(UIManager uiManager)
choiceChild.FindChild("Menu Option Label")!.GetComponent()
);
+ textInputTemplate = Object.Instantiate(textChoiceTemplate);
+ textInputTemplate.SetActive(false);
+ textInputTemplate.name = "TextInputContainer";
+ Object.DontDestroyOnLoad(textInputTemplate);
+
+ var textInputChild = textInputTemplate.FindChild("ValueChoice")!;
+ textInputChild.name = "TextInput";
+ Object.Destroy(textInputChild.GetComponent());
+ Object.Destroy(textInputChild.GetComponent());
+ Object.DestroyImmediate(textInputChild.GetComponent()); // We must delete the Selectable immediately to add a new one.
+ Object.Destroy(textInputChild.GetComponent());
+ var textInputField = textInputChild.AddComponent();
+ textInputField.textComponent = textInputChild
+ .FindChild("Menu Option Text")!
+ .GetComponent();
+ textInputField.caretColor = Color.white;
+ textInputField.contentType = InputField.ContentType.Standard;
+ textInputField.caretWidth = 8;
+ textInputField.text = "";
+
sliderTemplate = Object.Instantiate(
canvas.FindChild("AudioMenuScreen/Content/MasterVolume")!
);
@@ -104,6 +152,13 @@ internal GameObject NewCustomMenu(string title)
internal GameObject NewEmptyContentPane() => Object.Instantiate(emptyContentPane);
+ internal GameObject NewKeyBindContainer(out CustomMappableKey customMappableKey)
+ {
+ var obj = Object.Instantiate(keyBindTemplate);
+ customMappableKey = CustomMappableKey.Replace(obj.GetComponent());
+ return obj;
+ }
+
internal GameObject NewTextButtonContainer(out MenuButton menuButton)
{
var obj = Object.Instantiate(textButtonTemplate);
@@ -111,6 +166,8 @@ internal GameObject NewTextButtonContainer(out MenuButton menuButton)
return obj;
}
+ internal GameObject NewTextLabel() => Object.Instantiate(textLabelTemplate);
+
internal GameObject NewTextChoiceContainer(out MenuOptionHorizontal menuOptionHorizontal)
{
var obj = Object.Instantiate(textChoiceTemplate);
@@ -118,10 +175,30 @@ internal GameObject NewTextChoiceContainer(out MenuOptionHorizontal menuOptionHo
return obj;
}
+ internal GameObject NewTextInputContainer(out CustomInputField customInputField)
+ {
+ var obj = Object.Instantiate(textInputTemplate);
+ customInputField = obj.FindChild("TextInput")!.GetComponent();
+ return obj;
+ }
+
internal GameObject NewSliderContainer(out Slider slider)
{
var obj = Object.Instantiate(sliderTemplate);
slider = obj.FindChild("Slider")!.GetComponent();
return obj;
}
+
+ private static readonly EventSuppressor mappableKeyInit = new();
+
+ private static ReturnFlow MappableKeyInit(MappableKey self) =>
+ mappableKeyInit.Suppressed ? ReturnFlow.SkipOriginal : ReturnFlow.None;
+
+ [MonoDetourHookInitialize]
+ private static void Hook()
+ {
+ Md.MappableKey.OnEnable.ControlFlowPrefix(MappableKeyInit);
+ Md.MappableKey.SetupRefs.ControlFlowPrefix(MappableKeyInit);
+ Md.MappableKey.Start.ControlFlowPrefix(MappableKeyInit);
+ }
}
diff --git a/Models/ITextModel.cs b/Models/ITextModel.cs
new file mode 100644
index 0000000..624cba8
--- /dev/null
+++ b/Models/ITextModel.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace Silksong.ModMenu.Models;
+
+///
+/// Model for a text input field.
+///
+public interface ITextModel : IValueModel
+{
+ ///
+ /// Returns true if the current text value successfully parses to a domain value.
+ ///
+ bool IsTextValid { get; }
+
+ ///
+ /// Event notified whenever the text value of this model changes.
+ ///
+ event Action? OnTextValueChanged;
+
+ ///
+ /// Gets the current text value of the field.
+ ///
+ string GetTextValue();
+
+ ///
+ /// Set the current text value of the field, updating the domain value if possible.
+ ///
+ void SetTextValue(string value);
+
+ ///
+ /// Convenience accessor for the text value of the model.
+ ///
+ string TextValue { get; set; }
+}
diff --git a/Models/ParserTextModel.cs b/Models/ParserTextModel.cs
new file mode 100644
index 0000000..398e188
--- /dev/null
+++ b/Models/ParserTextModel.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+
+namespace Silksong.ModMenu.Models;
+
+///
+/// A delegate-based text model which converts text to and from the domain type.
+/// Bijection is not necessary, but conversions should stabilize within two rounds to avoid bizarre behaviors.
+///
+/// Text takes precedence over domain values, to allow input elements to temporarily have invalid states. While invalid text is stored, the domain value is reported as the supplied `defaultValue`. Consumers should always check `IsTextValid` before consuming the domain value.
+///
+public class ParserTextModel : ITextModel
+{
+ ///
+ /// Delegate for parsing arbitrary text into a domain value.
+ ///
+ public delegate bool Parse(string text, out T value);
+
+ ///
+ /// Delegate for converting an arbitrary domain value into text.
+ ///
+ public delegate bool Unparse(T value, out string text);
+
+ private readonly Parse parser;
+ private readonly Unparse unparser;
+ private readonly T defaultValue;
+
+ private string text;
+ private T value;
+
+ ///
+ /// An optional constraint function to limit acceptable values.
+ ///
+ public Func ConstraintFn = _ => true;
+
+ ///
+ /// Construct a new ParserTextModel with a sentinel value for invalid text.
+ ///
+#pragma warning disable CS8601 // Possible null reference assignment.
+ public ParserTextModel(Parse parser, Unparse unparser, T defaultValue = default)
+#pragma warning restore CS8601 // Possible null reference assignment.
+ {
+ this.parser = parser;
+ this.unparser = unparser;
+ this.defaultValue = defaultValue;
+
+ text = "";
+ value = defaultValue;
+ SetValue(defaultValue);
+ }
+
+ ///
+ public event Action? OnTextValueChanged;
+
+ ///
+ public event Action? OnValueChanged;
+
+ ///
+ public event Action