From 8c1199b4814470c2606907dd68fbc57ed8a81d15 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Fri, 6 Feb 2026 12:44:45 -0500 Subject: [PATCH 01/10] Add CSharpier to dotnet-tools --- .config/dotnet-tools.json | 45 ++++++++++++++++++++++----------------- Elements/GridGroup.cs | 4 ++-- Silksong.ModMenu.csproj | 2 +- 3 files changed, 29 insertions(+), 22 deletions(-) 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/Elements/GridGroup.cs b/Elements/GridGroup.cs index b25fc55..4062246 100644 --- a/Elements/GridGroup.cs +++ b/Elements/GridGroup.cs @@ -219,7 +219,7 @@ protected override IEnumerable GetNavigables(NavigationDirection dir .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)) @@ -233,7 +233,7 @@ protected override IEnumerable GetNavigables(NavigationDirection dir .Where(row => row.Any(e => e is INavigable && e.VisibleSelf)) .LastOrDefault() ?.OfType() - ?? [], + ?? [], _ => throw new ArgumentException($"{direction}"), }; diff --git a/Silksong.ModMenu.csproj b/Silksong.ModMenu.csproj index 56dd3f8..85c4edc 100644 --- a/Silksong.ModMenu.csproj +++ b/Silksong.ModMenu.csproj @@ -43,7 +43,7 @@ - + From 2c01eaec05fb03c1f608e17edb309de5661811f2 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Fri, 6 Feb 2026 12:44:45 -0500 Subject: [PATCH 02/10] Publicize local refs --- Silksong.ModMenu.csproj | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Silksong.ModMenu.csproj b/Silksong.ModMenu.csproj index 85c4edc..2092e23 100644 --- a/Silksong.ModMenu.csproj +++ b/Silksong.ModMenu.csproj @@ -49,11 +49,16 @@ + - + From d0b52db12e0a216e782353c8510112a052d217fe Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sun, 7 Dec 2025 10:39:58 -0500 Subject: [PATCH 03/10] Implement IndexedList for VerticalGroup. --- Internal/IndexedList.cs | 109 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 Internal/IndexedList.cs 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; + } +} From 01cc6204340674d4b6374bec202d9407187b0689 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sun, 7 Dec 2025 09:39:48 -0500 Subject: [PATCH 04/10] Refactor ownership model to support removals. --- Elements/AbstractGroup.cs | 27 +++---- Elements/FreeGroup.cs | 18 ++++- Elements/GridGroup.cs | 126 +++++++++++++++++++++--------- Elements/IMenuEntity.cs | 8 +- Elements/IMenuEntityExtensions.cs | 26 +++++- Elements/MenuElement.cs | 17 ++-- Elements/VerticalGroup.cs | 34 +++++++- Elements/VisibilityManager.cs | 23 ++++-- Plugin/MenuElementGenerators.cs | 4 +- Screens/AbstractMenuScreen.cs | 27 ++++++- Screens/BasicMenuScreen.cs | 32 +++++--- Screens/PaginatedMenuScreen.cs | 37 ++++++++- 12 files changed, 277 insertions(+), 102 deletions(-) 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 4062246..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/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/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/Plugin/MenuElementGenerators.cs b/Plugin/MenuElementGenerators.cs index b706e47..65eee6a 100644 --- a/Plugin/MenuElementGenerators.cs +++ b/Plugin/MenuElementGenerators.cs @@ -1,10 +1,8 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; -using System.Text; using BepInEx.Configuration; using Silksong.ModMenu.Elements; using Silksong.ModMenu.Models; diff --git a/Screens/AbstractMenuScreen.cs b/Screens/AbstractMenuScreen.cs index 2b81e9f..8632610 100644 --- a/Screens/AbstractMenuScreen.cs +++ b/Screens/AbstractMenuScreen.cs @@ -73,6 +73,11 @@ protected AbstractMenuScreen(string title) /// The 'Back' button at the bottom of the menu screen. /// public readonly MenuButton BackButton; + + /// + /// MenuScreens are root objects and visibile by default without a parent. + /// + private readonly VisibilityManager visibility = new(true) { VisibleSelf = false }; #endregion #region Events @@ -108,9 +113,18 @@ protected AbstractMenuScreen(string title) protected abstract void UpdateLayout(); /// - /// Get all elements recursively contained within this menu screen. + /// Register the given entity as a child of this menu screen. + /// + protected void AddChild(IMenuEntity entity) + { + entity.Visibility.SetParent(visibility); + entity.SetGameObjectParent(Container); + } + + /// + /// Get all entities directly contained within this menu screen. /// - protected abstract IEnumerable AllElements(); + protected abstract IEnumerable AllEntities(); #endregion #region SelectOnShow @@ -140,13 +154,15 @@ protected virtual Selectable SelectOnShow(MenuScreenNavigation.NavigationType na #region Internal internal void InvokeOnShow(MenuScreenNavigation.NavigationType navigationType) { + visibility.VisibleSelf = true; UIManager.instance.StartCoroutine(SelectDelayed(SelectOnShow(navigationType))); OnShow?.Invoke(navigationType); } private void UpdateLastSelected() { - var sel = AllElements() + var sel = AllEntities() + .SelectMany(e => e.AllElements()) .OfType() .Where(s => s.IsSelected) .FirstOrDefault(); @@ -169,8 +185,11 @@ internal void InvokeOnGoBack() MenuScreenNavigation.GoBack(); } - internal void InvokeOnHide(MenuScreenNavigation.NavigationType navigationType) => + internal void InvokeOnHide(MenuScreenNavigation.NavigationType navigationType) + { OnHide?.Invoke(navigationType); + visibility.VisibleSelf = false; + } private SelectableElement? lastSelected; diff --git a/Screens/BasicMenuScreen.cs b/Screens/BasicMenuScreen.cs index 10b1870..d380327 100644 --- a/Screens/BasicMenuScreen.cs +++ b/Screens/BasicMenuScreen.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Silksong.ModMenu.Elements; using Silksong.ModMenu.Internal; using UnityEngine; @@ -8,22 +9,31 @@ namespace Silksong.ModMenu.Screens; /// /// A simple menu screen with a single content entity. /// -public class BasicMenuScreen : AbstractMenuScreen +public class BasicMenuScreen(string title, INavigableMenuEntity content) : AbstractMenuScreen(title) { /// - /// Construct a BasicMenuScreen with the given title and content pane. + /// The content displayed by this menu screen, minus the back button. /// - public BasicMenuScreen(string title, INavigableMenuEntity content) - : base(title) + public INavigableMenuEntity Content { - Content = content; - content.SetGameObjectParent(ContentPane); - } + get => field; + set + { + if (value == null) + throw new ArgumentNullException(nameof(Content)); + + if (field != value) + field.ClearParents(); + + field = value; + AddChild(field); + } + } = content; /// - /// The content displayed by this menu screen, minus the back button. + /// Remove the content pane for this menu, showing nothing instead. /// - public readonly INavigableMenuEntity Content; + public void ClearContent() => Content = new VerticalGroup(); /// /// Top anchor point for the elements. @@ -31,7 +41,7 @@ public BasicMenuScreen(string title, INavigableMenuEntity content) public Vector2 Anchor = SpacingConstants.TOP_CENTER_ANCHOR; /// - protected override IEnumerable AllElements() => Content.AllElements(); + protected override IEnumerable AllEntities() => [Content]; /// protected override SelectableElement? GetDefaultSelectableInternal() => diff --git a/Screens/PaginatedMenuScreen.cs b/Screens/PaginatedMenuScreen.cs index 6219658..fd41f47 100644 --- a/Screens/PaginatedMenuScreen.cs +++ b/Screens/PaginatedMenuScreen.cs @@ -12,7 +12,7 @@ namespace Silksong.ModMenu.Screens; /// public class PaginatedMenuScreen : AbstractMenuScreen { - private readonly List pages = []; + private readonly IndexedList pages = []; private readonly IntRangeChoiceModel pageNumberModel; private readonly ChoiceElement pageNumberElement; @@ -57,8 +57,7 @@ public int PageNumber public Vector2 Anchor = SpacingConstants.TOP_CENTER_ANCHOR; /// - protected override IEnumerable AllElements() => - pages.SelectMany(p => p.AllElements()).Concat([pageNumberElement]); + protected override IEnumerable AllEntities() => pages.Concat([pageNumberElement]); /// /// Add a singular page to the list of pages. @@ -66,7 +65,7 @@ protected override IEnumerable AllElements() => public void AddPage(INavigableMenuEntity page) { pages.Add(page); - page.SetGameObjectParent(ContentPane); + AddChild(page); } /// @@ -78,6 +77,36 @@ public void AddPages(IEnumerable pages) AddPage(page); } + /// + /// Insert the given page at a specific index. + /// + public void InsertPage(int index, INavigableMenuEntity page) + { + pages.Insert(index, page); + AddChild(page); + } + + /// + /// Remove the specified page from this menu screen. + /// + public bool RemovePage(INavigableMenuEntity page) + { + if (!pages.Remove(page)) + return false; + + page.ClearParents(); + return true; + } + + /// + /// Remove the page at the specified index from this screen. + /// + public void RemoveAt(int index) + { + if (pages.TryRemoveAt(index, out var page)) + page.ClearParents(); + } + private INavigableMenuEntity? ActivePage => pages.Count > 0 ? pages[PageNumber] : null; /// From ce851c18348e19e521470a4d9df215f6bf8a72eb Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sat, 6 Dec 2025 21:49:19 -0500 Subject: [PATCH 05/10] Implement TextLabel. --- Elements/TextLabel.cs | 34 ++++++++++++++++++++++++++++++++++ Internal/MenuPrefabs.cs | 10 ++++++++++ README.md | 3 +-- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 Elements/TextLabel.cs 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/Internal/MenuPrefabs.cs b/Internal/MenuPrefabs.cs index 96f13d9..e60faf4 100644 --- a/Internal/MenuPrefabs.cs +++ b/Internal/MenuPrefabs.cs @@ -18,6 +18,7 @@ internal static MenuPrefabs Get() => private readonly GameObject menuTemplate; private readonly GameObject emptyContentPane; private readonly GameObject textButtonTemplate; + private readonly GameObject textLabelTemplate; private readonly GameObject textChoiceTemplate; private readonly GameObject sliderTemplate; @@ -55,6 +56,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")! ); @@ -111,6 +119,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); diff --git a/README.md b/README.md index 68e6aa5..564d71e 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The ModMenu mod should be considered _unstable_ version 1.0 is released. Breakin Required for 1.0: * New menu element implementations: - * Labels, icons + * Icons/images * Raw text input Optional future work: @@ -71,5 +71,4 @@ Optional future work: * Multi-row * Scroll pane * New menu element implementations: - * Icons/images * Keybinds From 494d7b38f91881bf010ce588154728af8dd7d55b Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sat, 6 Dec 2025 22:14:50 -0500 Subject: [PATCH 06/10] Implement FreeTextInput. --- Elements/TextInput.cs | 96 +++++++++++++++++++++++++ Internal/CustomInputField.cs | 83 ++++++++++++++++++++++ Internal/EventSuppressor.cs | 25 +++++++ Internal/MenuPrefabs.cs | 29 ++++++++ Models/ITextModel.cs | 34 +++++++++ Models/ParserTextModel.cs | 131 ++++++++++++++++++++++++++++++++++ Models/TextModels.cs | 67 ++++++++++++++++++ Plugin/ConfigEntryFactory.cs | 134 ++++++++++++++++++++++++++++------- README.md | 1 - 9 files changed, 573 insertions(+), 27 deletions(-) create mode 100644 Elements/TextInput.cs create mode 100644 Internal/CustomInputField.cs create mode 100644 Internal/EventSuppressor.cs create mode 100644 Models/ITextModel.cs create mode 100644 Models/ParserTextModel.cs create mode 100644 Models/TextModels.cs 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/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/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/MenuPrefabs.cs b/Internal/MenuPrefabs.cs index e60faf4..ac490d2 100644 --- a/Internal/MenuPrefabs.cs +++ b/Internal/MenuPrefabs.cs @@ -1,5 +1,6 @@ using Silksong.UnityHelper.Extensions; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.UI; namespace Silksong.ModMenu.Internal; @@ -20,6 +21,7 @@ internal static MenuPrefabs Get() => private readonly GameObject textButtonTemplate; private readonly GameObject textLabelTemplate; private readonly GameObject textChoiceTemplate; + private readonly GameObject textInputTemplate; private readonly GameObject sliderTemplate; private MenuPrefabs(UIManager uiManager) @@ -82,6 +84,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")! ); @@ -128,6 +150,13 @@ 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); 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? OnRawValueChanged; + + /// + public bool IsTextValid { get; private set; } = false; + + /// + public string GetTextValue() => text; + + /// + public void SetTextValue(string value) + { + if (text == value) + return; + text = value; + + IsTextValid = parser(value, out var newValue) && ConstraintFn(newValue); + if (!IsTextValid) + newValue = defaultValue; + + if (!EqualityComparer.Default.Equals(this.value, newValue)) + { + this.value = newValue; + OnValueChanged?.Invoke(this.value); + OnRawValueChanged?.Invoke(this.value!); + } + OnTextValueChanged?.Invoke(text); + } + + /// + public T GetValue() => Value; + + /// + public bool SetValue(T value) + { + if (EqualityComparer.Default.Equals(this.value, value)) + return true; + if (!unparser(value, out var newText) || !ConstraintFn(value)) + return false; + + IsTextValid = true; + this.value = value; + bool updateText = newText != text; + if (updateText) + text = newText; + + OnValueChanged?.Invoke(value); + OnRawValueChanged?.Invoke(value!); + if (updateText) + OnTextValueChanged?.Invoke(text); + return true; + } + + /// + public string DisplayString() => text; + + /// + public T Value + { + get => value; + set + { + if (!SetValue(value)) + throw new ArgumentException($"{nameof(value)}: {value}"); + } + } + + /// + public string TextValue + { + get => text; + set => SetTextValue(value); + } +} diff --git a/Models/TextModels.cs b/Models/TextModels.cs new file mode 100644 index 0000000..b96cee4 --- /dev/null +++ b/Models/TextModels.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace Silksong.ModMenu.Models; + +/// +/// A collection of helper methods for ITextModels. +/// +public static class TextModels +{ + /// + /// A transparent ITextModel that uses the text value as the domain value. + /// + public static ParserTextModel ForStrings() => + new(DefaultUnparse, DefaultUnparse); + + /// + /// An ITextModel which parses its input into an integer. + /// + public static ParserTextModel ForIntegers() => new(int.TryParse, DefaultUnparse); + + /// + /// An ITextModel which parses its input into an integer clamped between a min and max. + /// + public static ParserTextModel ForIntegers(int min, int max) + { + var model = ForIntegers(); + model.ConstraintFn = RangeConstraint(min, max); + return model; + } + + /// + /// An ITextModel which parses its input into a float. + /// + public static ParserTextModel ForFloats() => new(float.TryParse, DefaultUnparse); + + /// + /// An ITextModel which parses its input into a float clamped between a min and max. + /// + public static ParserTextModel ForFloats(float min, float max) + { + var model = ForFloats(); + model.ConstraintFn = RangeConstraint(min, max); + return model; + } + + private static bool DefaultUnparse(T value, out string text) + { + text = $"{value}"; + return true; + } + + private static Func RangeConstraint(T min, T max) + where T : IComparable + { + bool Check(T value) + { + if (Comparer.Default.Compare(value, min) < 0) + return false; + if (Comparer.Default.Compare(value, max) > 0) + return false; + return true; + } + + return Check; + } +} diff --git a/Plugin/ConfigEntryFactory.cs b/Plugin/ConfigEntryFactory.cs index b5a40a2..72d6c5a 100644 --- a/Plugin/ConfigEntryFactory.cs +++ b/Plugin/ConfigEntryFactory.cs @@ -27,9 +27,12 @@ public delegate bool MenuElementGenerator( private static readonly List defaultGenerators = [ GenerateCustomElement, - GenerateBoolElement, GenerateEnumChoiceElement, GenerateAcceptableValuesChoiceElement, + GenerateBoolElement, + GenerateIntElement, + GenerateFloatElement, + GenerateStringElement, ]; /// @@ -115,31 +118,6 @@ public static bool GenerateCustomElement( return false; } - /// - /// Generate a menu element for a config setting with a boolean value. - /// - public static bool GenerateBoolElement( - ConfigEntryBase entry, - [MaybeNullWhen(false)] out MenuElement menuElement - ) - { - if (entry is not ConfigEntry boolEntry) - { - menuElement = default; - return false; - } - - ChoiceElement choice = new( - boolEntry.LabelName(), - ChoiceModels.ForBool(), - boolEntry.DescriptionLine() - ); - choice.SynchronizeWith(boolEntry); - - menuElement = choice; - return true; - } - /// /// Generate a menu element for a config entry on an enum type. /// @@ -210,6 +188,110 @@ public static bool GenerateAcceptableValuesChoiceElement( menuElement = choice; return true; } + + /// + /// Generate a menu element for a config setting with a boolean value. + /// + public static bool GenerateBoolElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry boolEntry) + { + menuElement = default; + return false; + } + + ChoiceElement choice = new( + boolEntry.LabelName(), + ChoiceModels.ForBool(), + boolEntry.DescriptionLine() + ); + choice.SynchronizeWith(boolEntry); + + menuElement = choice; + return true; + } + + /// + /// Generates a menu element for a config setting with a free or ranged int value. + /// + public static bool GenerateIntElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry intEntry) + { + menuElement = default; + return false; + } + + var acceptableValues = entry.Description.AcceptableValues; + var model = + (acceptableValues is AcceptableValueRange range) + ? TextModels.ForIntegers(range.MinValue, range.MaxValue) + : TextModels.ForIntegers(); + + TextInput text = new(entry.LabelName(), model, entry.DescriptionLine()); + text.SynchronizeWith(intEntry); + + menuElement = text; + return true; + } + + /// + /// Generates a menu element for a config setting with a free or ranged float value. + /// + public static bool GenerateFloatElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry floatEntry) + { + menuElement = default; + return false; + } + + var acceptableValues = entry.Description.AcceptableValues; + var model = + (acceptableValues is AcceptableValueRange range) + ? TextModels.ForFloats(range.MinValue, range.MaxValue) + : TextModels.ForFloats(); + + TextInput text = new(entry.LabelName(), model, entry.DescriptionLine()); + text.SynchronizeWith(floatEntry); + + menuElement = text; + return true; + } + + /// + /// Generate a text element for an arbitrary string. + /// + public static bool GenerateStringElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry stringEntry) + { + menuElement = default; + return false; + } + + TextInput text = new( + entry.LabelName(), + TextModels.ForStrings(), + entry.DescriptionLine() + ); + text.SynchronizeWith(stringEntry); + + menuElement = text; + return true; + } } /// diff --git a/README.md b/README.md index 564d71e..63ed0e9 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ The ModMenu mod should be considered _unstable_ version 1.0 is released. Breakin Required for 1.0: * New menu element implementations: * Icons/images - * Raw text input Optional future work: * Extending the 'new game' menu with new game modes. From 2a9517cd96c1a989bf357a0a6b7d46b00b8b0658 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Thu, 5 Feb 2026 22:06:31 -0500 Subject: [PATCH 07/10] Implement KeyBindElement --- Elements/KeyBindElement.cs | 65 +++++ Internal/CustomMappableKey.cs | 256 ++++++++++++++++++ Internal/KeyCodeUtil.cs | 486 ++++++++++++++++++++++++++++++++++ Internal/MenuPrefabs.cs | 40 ++- Models/ValueModel.cs | 25 ++ Plugin/ConfigEntryFactory.cs | 23 ++ README.md | 2 - 7 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 Elements/KeyBindElement.cs create mode 100644 Internal/CustomMappableKey.cs create mode 100644 Internal/KeyCodeUtil.cs create mode 100644 Models/ValueModel.cs 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/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/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 ac490d2..a2712fc 100644 --- a/Internal/MenuPrefabs.cs +++ b/Internal/MenuPrefabs.cs @@ -1,10 +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; @@ -18,6 +22,7 @@ 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; @@ -49,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"; @@ -134,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); @@ -163,4 +188,17 @@ internal GameObject NewSliderContainer(out Slider slider) 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/ValueModel.cs b/Models/ValueModel.cs new file mode 100644 index 0000000..c8ca9a2 --- /dev/null +++ b/Models/ValueModel.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Silksong.ModMenu.Models; + +/// +/// A basic ValueModel that implements no other interfaces. +/// +public class ValueModel(T value) : AbstractValueModel +{ + private T value = value; + + /// + public override T GetValue() => value; + + /// + public override bool SetValue(T value) + { + if (EqualityComparer.Default.Equals(this.value, value)) + return true; + + this.value = value; + InvokeOnValueChanged(); + return true; + } +} diff --git a/Plugin/ConfigEntryFactory.cs b/Plugin/ConfigEntryFactory.cs index 72d6c5a..1276139 100644 --- a/Plugin/ConfigEntryFactory.cs +++ b/Plugin/ConfigEntryFactory.cs @@ -7,6 +7,7 @@ using Silksong.ModMenu.Internal; using Silksong.ModMenu.Models; using Silksong.ModMenu.Screens; +using UnityEngine; namespace Silksong.ModMenu.Plugin; @@ -27,6 +28,7 @@ public delegate bool MenuElementGenerator( private static readonly List defaultGenerators = [ GenerateCustomElement, + GenerateKeyCodeElement, GenerateEnumChoiceElement, GenerateAcceptableValuesChoiceElement, GenerateBoolElement, @@ -118,6 +120,27 @@ public static bool GenerateCustomElement( return false; } + /// + /// Generate a menu element for a key bind. + /// + public static bool GenerateKeyCodeElement( + ConfigEntryBase entry, + [MaybeNullWhen(false)] out MenuElement menuElement + ) + { + if (entry is not ConfigEntry keyCodeEntry) + { + menuElement = default; + return false; + } + + KeyBindElement element = new(entry.LabelName()); + element.SynchronizeWith(keyCodeEntry); + + menuElement = element; + return true; + } + /// /// Generate a menu element for a config entry on an enum type. /// diff --git a/README.md b/README.md index 63ed0e9..c88457e 100644 --- a/README.md +++ b/README.md @@ -69,5 +69,3 @@ Optional future work: * New content pane implementations: * Multi-row * Scroll pane -* New menu element implementations: - * Keybinds From 52a138f490608c441afe9cdefdeed4adba179837 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sun, 8 Feb 2026 13:39:44 -0500 Subject: [PATCH 08/10] Bump version --- Directory.Build.props | 2 +- README.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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/README.md b/README.md index c88457e..0110cc7 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,12 @@ It is recommended, though not required, that you create new Models every time yo The ModMenu mod should be considered _unstable_ version 1.0 is released. Breaking API changes may occur in the pursuit of implementing additional features to reach 1.0. Required for 1.0: -* New menu element implementations: - * Icons/images +* Localization Optional future work: * Extending the 'new game' menu with new game modes. * New content pane implementations: * Multi-row * Scroll pane + * New menu element implementations: + * Icons/images From c54b99035598b4918dd3f17b2753b6a89050af59 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Fri, 6 Feb 2026 21:43:29 -0500 Subject: [PATCH 09/10] l10n update This resolves https://github.com/silksong-modding/Silksong.ModMenu/issues/14 --- Elements/ChoiceElement.cs | 20 +++++-- Elements/DynamicDescriptionChoiceElement.cs | 18 +++--- Elements/KeyBindElement.cs | 6 +- Elements/LocalizedText.cs | 62 +++++++++++++++++++++ Elements/LocalizedTextExtensions.cs | 46 +++++++++++++++ Elements/SliderElement.cs | 8 +-- Elements/TextButton.cs | 6 +- Elements/TextInput.cs | 12 +++- Elements/TextLabel.cs | 6 +- Internal/CustomMenuOptionHorizontal.cs | 3 +- Models/AbstractValueModel.cs | 3 +- Models/Delegates.cs | 6 +- Models/IDisplayable.cs | 6 +- Models/IntRangeChoiceModel.cs | 6 +- Models/LinearFloatSliderModel.cs | 3 +- Models/ListChoiceModel.cs | 5 +- Models/ParserTextModel.cs | 3 +- Models/SliderModel.cs | 5 +- README.md | 3 - 19 files changed, 181 insertions(+), 46 deletions(-) create mode 100644 Elements/LocalizedText.cs create mode 100644 Elements/LocalizedTextExtensions.cs diff --git a/Elements/ChoiceElement.cs b/Elements/ChoiceElement.cs index 05ebc5c..150043b 100644 --- a/Elements/ChoiceElement.cs +++ b/Elements/ChoiceElement.cs @@ -17,7 +17,7 @@ public class ChoiceElement : SelectableValueElement /// /// Construct a ChoiceElement with the given label text, model, and description text. /// - public ChoiceElement(string label, IChoiceModel model, string description = "") + public ChoiceElement(LocalizedText label, IChoiceModel model, LocalizedText description) : base( MenuPrefabs.Get().NewTextChoiceContainer(out var menuOptionHorizontal), menuOptionHorizontal, @@ -36,17 +36,29 @@ public ChoiceElement(string label, IChoiceModel model, string description = " OnValueChanged += _ => custom.UpdateText(); - LabelText.text = label; - DescriptionText.text = description; + LabelText.LocalizedText = label; + DescriptionText.LocalizedText = description; custom.UpdateText(); } + /// + /// Construct a ChoiceElement with the given label and model. + /// + public ChoiceElement(LocalizedText label, IChoiceModel model) + : this(label, model, "") { } + /// /// Shortcut for building a ChoiceElement from a finite list of values. /// - public ChoiceElement(string label, List items, string description = "") + public ChoiceElement(LocalizedText label, List items, LocalizedText description) : this(label, ChoiceModels.ForValues(items), description) { } + /// + /// Shortcut for building a ChoiceElement from a finite list of values. + /// + public ChoiceElement(LocalizedText label, List items) + : this(label, ChoiceModels.ForValues(items), "") { } + /// /// The value holder and model underlying this choice element. /// diff --git a/Elements/DynamicDescriptionChoiceElement.cs b/Elements/DynamicDescriptionChoiceElement.cs index b1c2864..9283bc3 100644 --- a/Elements/DynamicDescriptionChoiceElement.cs +++ b/Elements/DynamicDescriptionChoiceElement.cs @@ -19,17 +19,17 @@ public class DynamicDescriptionChoiceElement : ChoiceElement { internal const string RIGHT_DESCRIPTION_NAME = "ModMenu-Right Description"; - /// + /// public DynamicDescriptionChoiceElement( - string label, + LocalizedText label, IChoiceModel model, - string description, - string rightDescription + LocalizedText description, + LocalizedText rightDescription ) : base(label, model, description) { RightText = SetupRightDescription(DescriptionText, ChoiceText); - RightText.text = rightDescription; + RightText.LocalizedText = rightDescription; } /// @@ -40,14 +40,14 @@ string rightDescription /// /// Function used to determine the description below the choice. public DynamicDescriptionChoiceElement( - string label, + LocalizedText label, IChoiceModel model, - string description, - Func getRightDescription + LocalizedText description, + Func getRightDescription ) : this(label, model, description, getRightDescription(model.Value)) { - model.OnValueChanged += value => RightText.text = getRightDescription(value); + model.OnValueChanged += value => RightText.LocalizedText = getRightDescription(value); } /// diff --git a/Elements/KeyBindElement.cs b/Elements/KeyBindElement.cs index 6c83fe8..a15d1e7 100644 --- a/Elements/KeyBindElement.cs +++ b/Elements/KeyBindElement.cs @@ -14,7 +14,7 @@ public class KeyBindElement : SelectableValueElement /// /// Construct a KeyBindElement with a custom model. /// - public KeyBindElement(string label, IValueModel model) + public KeyBindElement(LocalizedText label, IValueModel model) : base( MenuPrefabs.Get().NewKeyBindContainer(out var customMappableKey), customMappableKey, @@ -27,13 +27,13 @@ public KeyBindElement(string label, IValueModel model) KeyBindText = customMappableKey.KeymapText!; KeyBindImage = customMappableKey.KeymapImage!; - LabelText.text = label; + LabelText.LocalizedText = label; } /// /// Construct a KeyBindElement with a default model that accepts any KeyCode. /// - public KeyBindElement(string label) + public KeyBindElement(LocalizedText label) : this(label, new ValueModel(KeyCode.A)) { } /// diff --git a/Elements/LocalizedText.cs b/Elements/LocalizedText.cs new file mode 100644 index 0000000..1dae199 --- /dev/null +++ b/Elements/LocalizedText.cs @@ -0,0 +1,62 @@ +using TeamCherry.Localization; + +namespace Silksong.ModMenu.Elements; + +/// +/// Wrapper class for either text that supports localization, or raw text which does not. +/// +public class LocalizedText +{ + private readonly TeamCherry.Localization.LocalisedString localisedString; + private readonly string rawText; + + private LocalizedText(LocalisedString localisedString, string rawText) + { + this.localisedString = localisedString; + this.rawText = rawText; + } + + /// + /// Get the readable text represented by this object. + /// + public string Text => + localisedString.IsEmpty + ? rawText + : Language.Get(localisedString.Key, localisedString.Sheet); + + /// + /// Returns true if this object has localization support. + /// + public bool IsLocalized => !localisedString.IsEmpty; + + /// + /// Returns the localized identifier for this object, which may be empty. + /// + public LocalisedString Localized => localisedString; + + /// + /// Represents localized text with the given key and an empty sheet title. + /// + public static LocalizedText Key(string languageKey) => new(new("", languageKey), ""); + + /// + /// Represents localized text with the given key. + /// + public static LocalizedText Key(LocalisedString localisedString) => new(localisedString, ""); + + /// + /// Represents text with no localization that always renders to the given value. + /// + public static LocalizedText Raw(string rawText) => new(new(), rawText); + + /// + /// Implicit conversion for raw text to un-localized LocalizedText. + /// + public static implicit operator LocalizedText(string rawText) => Raw(rawText); + + /// + /// Implicit conversion from a LocalisedString. + /// + public static implicit operator LocalizedText(LocalisedString localisedString) => + Key(localisedString); +} diff --git a/Elements/LocalizedTextExtensions.cs b/Elements/LocalizedTextExtensions.cs new file mode 100644 index 0000000..59974f6 --- /dev/null +++ b/Elements/LocalizedTextExtensions.cs @@ -0,0 +1,46 @@ +using UnityEngine.UI; + +namespace Silksong.ModMenu.Elements; + +/// +/// Helper methods for working with LocalizedText. +/// +public static class LocalizedTextExtensions +{ + extension(Text self) + { + /// + /// Helper field to set localized text on a Text component. + /// + public LocalizedText LocalizedText + { + get + { + if (self.TryGetComponent(out var auto) && !auto.text.IsEmpty) + return auto.text; + else + return self.text; + } + set + { + if (value.IsLocalized) + { + if (!self.TryGetComponent(out var auto)) + { + auto = self.gameObject.AddComponent(); + auto.textField = self; + } + + auto.text = value.Localized; + auto.RefreshTextFromLocalization(); + } + else + { + if (self.TryGetComponent(out var auto)) + UnityEngine.Object.Destroy(auto); + self.text = value.Text; + } + } + } + } +} diff --git a/Elements/SliderElement.cs b/Elements/SliderElement.cs index 16c6632..513ee74 100644 --- a/Elements/SliderElement.cs +++ b/Elements/SliderElement.cs @@ -19,10 +19,10 @@ public class SliderElement : SelectableValueElement /// /// The label text for the slider. /// The model for the domain range and underlying value. - public SliderElement(string label, SliderModel model) + public SliderElement(LocalizedText label, SliderModel model) : base(MenuPrefabs.Get().NewSliderContainer(out var slider), slider, model) { - Container.name = label; + Container.name = label.Text; Slider = slider; var sliderObj = Slider.gameObject; @@ -57,7 +57,7 @@ public SliderElement(string label, SliderModel model) } }); - LabelText.text = label; + LabelText.LocalizedText = label; UpdateValueText(); } @@ -95,5 +95,5 @@ public override void SetFontSizes(FontSizes fontSizes) ValueText.fontSize = fontSizes.SliderSize(); } - private void UpdateValueText() => ValueText.text = SliderModel.DisplayString(); + private void UpdateValueText() => ValueText.LocalizedText = SliderModel.DisplayString(); } diff --git a/Elements/TextButton.cs b/Elements/TextButton.cs index 92eeba7..553f3ce 100644 --- a/Elements/TextButton.cs +++ b/Elements/TextButton.cs @@ -15,10 +15,10 @@ public class TextButton : SelectableElement /// /// Construct a text button with the given text. /// - public TextButton(string text) + public TextButton(LocalizedText text) : base(MenuPrefabs.Get().NewTextButtonContainer(out var menuButton), menuButton) { - Container.name = text; + Container.name = text.Text; MenuButton = menuButton; MenuButton @@ -35,7 +35,7 @@ public TextButton(string text) MenuButton.buttonType = MenuButton.MenuButtonType.Activate; ButtonText = menuButton.gameObject.FindChild("Menu Button Text")!.GetComponent(); - ButtonText.text = text; + ButtonText.LocalizedText = text; } /// diff --git a/Elements/TextInput.cs b/Elements/TextInput.cs index 9ff4def..0a0af3d 100644 --- a/Elements/TextInput.cs +++ b/Elements/TextInput.cs @@ -28,7 +28,7 @@ public class TextInput : SelectableValueElement /// /// Construct a basic text input. /// - public TextInput(string label, ITextModel model, string description = "") + public TextInput(LocalizedText label, ITextModel model, LocalizedText description) : base(MenuPrefabs.Get().NewTextInputContainer(out var inputField), inputField, model) { TextModel = model; @@ -40,8 +40,8 @@ public TextInput(string label, ITextModel model, string description = "") OnTextValueChanged += value => InputField.text = value; - LabelText.text = label; - DescriptionText.text = description; + LabelText.LocalizedText = label; + DescriptionText.LocalizedText = description; if (intTypes.Contains(typeof(T))) InputField.contentType = InputField.ContentType.IntegerNumber; @@ -49,6 +49,12 @@ public TextInput(string label, ITextModel model, string description = "") InputField.contentType = InputField.ContentType.DecimalNumber; } + /// + /// Construct a basic text input with no description. + /// + public TextInput(LocalizedText label, ITextModel model) + : this(label, model, "") { } + /// /// The value holder and model underlying this choice element. /// diff --git a/Elements/TextLabel.cs b/Elements/TextLabel.cs index 88bd189..1271d9a 100644 --- a/Elements/TextLabel.cs +++ b/Elements/TextLabel.cs @@ -12,12 +12,12 @@ public class TextLabel : MenuElement /// /// Construct a label with the given text contents. /// - public TextLabel(string text) + public TextLabel(LocalizedText text) : base(MenuPrefabs.Get().NewTextLabel()) { - Container.name = text; + Container.name = text.Text; Text = Container.GetComponent(); - Text.text = text; + Text.LocalizedText = text; } /// diff --git a/Internal/CustomMenuOptionHorizontal.cs b/Internal/CustomMenuOptionHorizontal.cs index f4a36d9..450c2fa 100644 --- a/Internal/CustomMenuOptionHorizontal.cs +++ b/Internal/CustomMenuOptionHorizontal.cs @@ -1,6 +1,7 @@ using MonoDetour; using MonoDetour.DetourTypes; using MonoDetour.HookGen; +using Silksong.ModMenu.Elements; using Silksong.ModMenu.Models; using UnityEngine; using UnityEngine.EventSystems; @@ -27,7 +28,7 @@ internal void UpdateText() if (orig == null) return; - orig.optionText.text = Model?.DisplayString() ?? "???"; + orig.optionText.LocalizedText = Model?.DisplayString() ?? "???"; if (orig.optionText.TryGetComponent(out var align)) align.AlignText(); } diff --git a/Models/AbstractValueModel.cs b/Models/AbstractValueModel.cs index 1dc7736..9f69cf8 100644 --- a/Models/AbstractValueModel.cs +++ b/Models/AbstractValueModel.cs @@ -1,4 +1,5 @@ using System; +using Silksong.ModMenu.Elements; namespace Silksong.ModMenu.Models; @@ -57,5 +58,5 @@ public T Value } /// - public virtual string DisplayString() => $"{GetValue()}"; + public virtual LocalizedText DisplayString() => $"{GetValue()}"; } diff --git a/Models/Delegates.cs b/Models/Delegates.cs index e923489..0eb0705 100644 --- a/Models/Delegates.cs +++ b/Models/Delegates.cs @@ -1,6 +1,8 @@ -namespace Silksong.ModMenu.Models; +using Silksong.ModMenu.Elements; + +namespace Silksong.ModMenu.Models; /// /// Converts 'item' to string with the additional context of an associated index, most likely within a list. /// -public delegate string IndexedToString(int index, T item); +public delegate LocalizedText IndexedToString(int index, T item); diff --git a/Models/IDisplayable.cs b/Models/IDisplayable.cs index ebe2231..b66ff8e 100644 --- a/Models/IDisplayable.cs +++ b/Models/IDisplayable.cs @@ -1,4 +1,6 @@ -namespace Silksong.ModMenu.Models; +using Silksong.ModMenu.Elements; + +namespace Silksong.ModMenu.Models; /// /// Base interface for all models which ultimately display a string to represent the chosen value. @@ -8,5 +10,5 @@ public interface IDisplayable /// /// The UI string to display for this entity. /// - string DisplayString(); + LocalizedText DisplayString(); } diff --git a/Models/IntRangeChoiceModel.cs b/Models/IntRangeChoiceModel.cs index 2ac12de..686d873 100644 --- a/Models/IntRangeChoiceModel.cs +++ b/Models/IntRangeChoiceModel.cs @@ -1,4 +1,5 @@ using System; +using Silksong.ModMenu.Elements; namespace Silksong.ModMenu.Models; @@ -90,10 +91,11 @@ public override bool SetValue(int value) /// /// A custom display function to use for the selected integer value. /// - public Func? DisplayFn; + public Func? DisplayFn; /// - public override string DisplayString() => DisplayFn?.Invoke(GetValue()) ?? $"{GetValue()}"; + public override LocalizedText DisplayString() => + DisplayFn?.Invoke(GetValue()) ?? $"{GetValue()}"; private void ResetParamsInternal(int min, int max, int value) { diff --git a/Models/LinearFloatSliderModel.cs b/Models/LinearFloatSliderModel.cs index 179e564..d07a606 100644 --- a/Models/LinearFloatSliderModel.cs +++ b/Models/LinearFloatSliderModel.cs @@ -1,4 +1,5 @@ using System; +using Silksong.ModMenu.Elements; using UnityEngine; namespace Silksong.ModMenu.Models; @@ -67,5 +68,5 @@ protected override bool GetIndex(float value, out int index) } /// - protected override string DefaultDisplayString(int index, float item) => $"{item:0.###}"; + protected override LocalizedText DefaultDisplayString(int index, float item) => $"{item:0.###}"; } diff --git a/Models/ListChoiceModel.cs b/Models/ListChoiceModel.cs index e45b29e..f7e9409 100644 --- a/Models/ListChoiceModel.cs +++ b/Models/ListChoiceModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Silksong.ModMenu.Elements; namespace Silksong.ModMenu.Models; @@ -102,13 +103,13 @@ public override bool SetValue(T value) public IndexedToString? DisplayFn; /// - public override string DisplayString() => + public override LocalizedText DisplayString() => (DisplayFn ?? DefaultDisplayString).Invoke(Index, GetValue()); /// /// Default string to display, in the absence of a DisplayFn. /// - protected virtual string DefaultDisplayString(int index, T item) => $"{item}"; + protected virtual LocalizedText DefaultDisplayString(int index, T item) => $"{item}"; private bool Move(int delta) { diff --git a/Models/ParserTextModel.cs b/Models/ParserTextModel.cs index 398e188..5627ea9 100644 --- a/Models/ParserTextModel.cs +++ b/Models/ParserTextModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Silksong.ModMenu.Elements; namespace Silksong.ModMenu.Models; @@ -109,7 +110,7 @@ public bool SetValue(T value) } /// - public string DisplayString() => text; + public LocalizedText DisplayString() => text; /// public T Value diff --git a/Models/SliderModel.cs b/Models/SliderModel.cs index 3503912..3008955 100644 --- a/Models/SliderModel.cs +++ b/Models/SliderModel.cs @@ -1,4 +1,5 @@ using System; +using Silksong.ModMenu.Elements; namespace Silksong.ModMenu.Models; @@ -89,11 +90,11 @@ public override bool SetValue(T value) public IndexedToString? DisplayFn; /// - public override string DisplayString() => + public override LocalizedText DisplayString() => (DisplayFn ?? DefaultDisplayString).Invoke(Index, GetValue()); /// /// The default display string for this model, in the absence of a DisplayFn. /// - protected virtual string DefaultDisplayString(int index, T item) => $"{item}"; + protected virtual LocalizedText DefaultDisplayString(int index, T item) => $"{item}"; } diff --git a/README.md b/README.md index 0110cc7..7c2bf1c 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,6 @@ It is recommended, though not required, that you create new Models every time yo The ModMenu mod should be considered _unstable_ version 1.0 is released. Breaking API changes may occur in the pursuit of implementing additional features to reach 1.0. -Required for 1.0: -* Localization - Optional future work: * Extending the 'new game' menu with new game modes. * New content pane implementations: From 89b90cfe5baa0dc195a9dbde47dd474005b13dd9 Mon Sep 17 00:00:00 2001 From: Daniel Ploch Date: Sun, 8 Feb 2026 13:41:00 -0500 Subject: [PATCH 10/10] Bump version --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 5176381..372c6fa 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.3.0 + 0.4.0