Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Silksong.ModMenu.sln
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.4.11519.219 insiders
VisualStudioVersion = 18.4.11519.219
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silksong.ModMenu", "Silksong.ModMenu\Silksong.ModMenu.csproj", "{2DB486FC-5E09-737A-CED6-80241230DE8A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silksong.ModMenuTesting", "Silksong.ModMenuTesting\Silksong.ModMenuTesting.csproj", "{51062B71-1574-6F9F-4C4B-238385402476}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Silksong.ModMenuAnalyzers", "Silksong.ModMenuAnalyzers\Silksong.ModMenuAnalyzers.csproj", "{CAAE671F-1E40-D2C1-F402-FF77DD2A23AB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -21,6 +23,10 @@ Global
{51062B71-1574-6F9F-4C4B-238385402476}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51062B71-1574-6F9F-4C4B-238385402476}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51062B71-1574-6F9F-4C4B-238385402476}.Release|Any CPU.Build.0 = Release|Any CPU
{CAAE671F-1E40-D2C1-F402-FF77DD2A23AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CAAE671F-1E40-D2C1-F402-FF77DD2A23AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CAAE671F-1E40-D2C1-F402-FF77DD2A23AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CAAE671F-1E40-D2C1-F402-FF77DD2A23AB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
2 changes: 1 addition & 1 deletion Silksong.ModMenu/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
It should follow the format major.minor.patch (semantic versioning). If you publish your mod
as a library to NuGet, this version will also be used as the package version.
-->
<Version>0.6.0</Version>
<Version>0.7.0</Version>
</PropertyGroup>
</Project>
48 changes: 48 additions & 0 deletions Silksong.ModMenu/Generator/Attributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;

namespace Silksong.ModMenu.Generator;

/// <summary>
/// Attribute to automatically generate a custom mod menu class for a given data type.
/// Can also be applied to a non-public field or property on such a class to force its inclusion.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class GenerateMenuAttribute : Attribute { }

/// <summary>
/// Attribute to give a custom field generator for the annotated field or property.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class ElementFactoryAttribute<T> : Attribute
where T : IElementFactory, new() { }

/// <summary>
/// Attribute to apply to any non-public field or property to ensure it gets a menu element generated.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class ModMenuIncludeAttribute : Attribute { }

/// <summary>
/// Attribute to apply to a any numeric property, to specify a minimum and maximum.
/// Dynamic mins/maxes are not yet supported.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class ModMenuRangeAttribute(object Min, object Max) : Attribute
{
/// <summary>
/// The minimum value of this element.
/// </summary>
public readonly object Min = Min;

/// <summary>
/// The maximum value of this element.
/// </summary>
public readonly object Max = Max;
}

/// <summary>
/// Attribute to mark a field or property of a data class as requiring its own custom sub-menu, of the parameterized type.
/// </summary>
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class SubMenuAttribute<T> : Attribute
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The doc article could probably do with an explanation of how to generate submenus

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It does, it's the last section of the doc. Sub-menus are generated the same way normal menus are generated, so it's not a very long section.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm, I apparently failed to parse that, perhaps an example would be helpful

where T : ICustomMenu, new() { }
10 changes: 10 additions & 0 deletions Silksong.ModMenu/Generator/CustomMenuValueChangedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Silksong.ModMenu.Elements;

namespace Silksong.ModMenu.Generator;

/// <summary>
/// Event generated whenever any menu element of an ICustomMenu has its value changed.
/// </summary>
/// <param name="MemberName">The field or property name of the data class that got changed.</param>
/// <param name="Value">The new boxed value. May be null if the field is managed by a sub-menu.</param>
public record CustomMenuValueChangedEvent(string MemberName, object? Value) { }
36 changes: 36 additions & 0 deletions Silksong.ModMenu/Generator/ICustomMenu.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using Silksong.ModMenu.Elements;

namespace Silksong.ModMenu.Generator;

/// <summary>
/// Marker interface for a menu generated by `[GeneratedModMenu]`.
/// </summary>
public interface ICustomMenu<T> : ICustomMenu
{
/// <summary>
/// Notified whenever any element within this group has its value changed.
/// </summary>
public event Action<CustomMenuValueChangedEvent>? OnValueChanged;

/// <summary>
/// Set all fields and properties on `data` to to match this entity's menu values.
/// </summary>
void ExportTo(T data);

/// <summary>
/// Set all this entity's menu values to match the fields and properties on `data`.
/// </summary>
void ApplyFrom(T data);

/// <summary>
/// Return all menu elements defined by this custom menu.
/// </summary>
IEnumerable<MenuElement> Elements();
}

/// <summary>
/// Parent marker interface without generics.
/// </summary>
public interface ICustomMenu { }
20 changes: 20 additions & 0 deletions Silksong.ModMenu/Generator/IElementFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Silksong.ModMenu.Elements;

namespace Silksong.ModMenu.Generator;

/// <summary>
/// Generator for a custom menu element, for the parameterized type.
/// </summary>
public interface IElementFactory<T, E> : IElementFactory
where E : SelectableValueElement<T>
{
/// <summary>
/// Create a menu element for the parameterized type.
/// </summary>
public E CreateElement(LocalizedText name, LocalizedText description);
}

/// <summary>
/// Parent marker interface without generics.
/// </summary>
public interface IElementFactory { }
31 changes: 31 additions & 0 deletions Silksong.ModMenu/Generator/SubMenuElement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Silksong.ModMenu.Elements;
using Silksong.ModMenu.Screens;

namespace Silksong.ModMenu.Generator;

/// <summary>
/// A text button which opens a sub-menu of type M, for data of type T.
/// </summary>
public class SubMenuElement<T, M> : TextButton
where M : ICustomMenu<T>
{
/// <summary>
/// Sub menu abstraction shown by this button.
/// </summary>
public readonly M SubMenu;

/// <summary>
/// Construct a new SubMenuElement.
/// </summary>
public SubMenuElement(LocalizedText text, M subMenu, LocalizedText? description = null)
: base(text, description ?? "")
{
SubMenu = subMenu;

PaginatedMenuScreenBuilder builder = new(text);
builder.AddRange(SubMenu.Elements());
var screen = builder.Build();

OnSubmit += () => MenuScreenNavigation.Show(screen);
}
}
1 change: 1 addition & 0 deletions Silksong.ModMenu/Internal/MenuPrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using MonoDetour.DetourTypes;
using MonoDetour.HookGen;
using Silksong.ModMenu.Elements;
using Silksong.ModMenu.Util;
using Silksong.UnityHelper.Extensions;
using UnityEngine;
using UnityEngine.EventSystems;
Expand Down
125 changes: 125 additions & 0 deletions Silksong.ModMenu/Models/TextModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,68 @@ public static class TextModels
public static ParserTextModel<string> ForStrings() =>
new(DefaultUnparse<string>, DefaultUnparse<string>);

/// <summary>
/// An ITextModel which parses its input into a signed byte.
/// </summary>
public static ParserTextModel<sbyte> ForSignedBytes() =>
new(sbyte.TryParse, DefaultUnparse<sbyte>);

/// <summary>
/// An ITextModel which parses its input into a signed byte clamped between a min and max.
/// </summary>
public static ParserTextModel<sbyte> ForSignedBytes(sbyte min, sbyte max)
{
var model = ForSignedBytes();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into a byte.
/// </summary>
public static ParserTextModel<byte> ForBytes() => new(byte.TryParse, DefaultUnparse<byte>);

/// <summary>
/// An ITextModel which parses its input into a byte clamped between a min and max.
/// </summary>
public static ParserTextModel<byte> ForBytes(byte min, byte max)
{
var model = ForBytes();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into a short.
/// </summary>
public static ParserTextModel<short> ForShorts() => new(short.TryParse, DefaultUnparse<short>);

/// <summary>
/// An ITextModel which parses its input into a short clamped between a min and max.
/// </summary>
public static ParserTextModel<short> ForShorts(short min, short max)
{
var model = ForShorts();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into an unsigned short.
/// </summary>
public static ParserTextModel<ushort> ForUnsignedShorts() =>
new(ushort.TryParse, DefaultUnparse<ushort>);

/// <summary>
/// An ITextModel which parses its input into an unsigned short clamped between a min and max.
/// </summary>
public static ParserTextModel<ushort> ForUnsignedShorts(ushort min, ushort max)
{
var model = ForUnsignedShorts();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into an integer.
/// </summary>
Expand All @@ -32,6 +94,53 @@ public static ParserTextModel<int> ForIntegers(int min, int max)
return model;
}

/// <summary>
/// An ITextModel which parses its input into an unsigned integer.
/// </summary>
public static ParserTextModel<uint> ForUnsignedIntegers() =>
new(uint.TryParse, DefaultUnparse<uint>);

/// <summary>
/// An ITextModel which parses its input into an unsigned integer clamped between a min and max.
/// </summary>
public static ParserTextModel<uint> ForUnsignedIntegers(uint min, uint max)
{
var model = ForUnsignedIntegers();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into a long.
/// </summary>
public static ParserTextModel<long> ForLongs() => new(long.TryParse, DefaultUnparse<long>);

/// <summary>
/// An ITextModel which parses its input into a long clamped between a min and max.
/// </summary>
public static ParserTextModel<long> ForIntegers(long min, long max)
{
var model = ForLongs();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into an unsigned long.
/// </summary>
public static ParserTextModel<ulong> ForUnsignedLongs() =>
new(ulong.TryParse, DefaultUnparse<ulong>);

/// <summary>
/// An ITextModel which parses its input into an unsigned long clamped between a min and max.
/// </summary>
public static ParserTextModel<ulong> ForUnsignedLongs(ulong min, ulong max)
{
var model = ForUnsignedLongs();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

/// <summary>
/// An ITextModel which parses its input into a float.
/// </summary>
Expand All @@ -47,6 +156,22 @@ public static ParserTextModel<float> ForFloats(float min, float max)
return model;
}

/// <summary>
/// An ITextModel which parses its input into a double.
/// </summary>
public static ParserTextModel<double> ForDoubles() =>
new(double.TryParse, DefaultUnparse<double>);

/// <summary>
/// An ITextModel which parses its input into a double clamped between a min and max.
/// </summary>
public static ParserTextModel<double> ForDoubles(double min, double max)
{
var model = ForDoubles();
model.ConstraintFn = RangeConstraint(min, max);
return model;
}

private static bool DefaultUnparse<T>(T value, out string text)
{
text = $"{value}";
Expand Down
7 changes: 6 additions & 1 deletion Silksong.ModMenu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ internal class ModMenuIgnoreAttribute : System.Attribute { }
```
and then putting `[ModMenuIgnore]` above your plugin class (just above or below the `[BepInAutoPlugin(...)]` line).


## API

The ModMenu API can be understood in three separate, hierarchical categories.
Expand Down Expand Up @@ -56,6 +55,12 @@ Models are stateful and remember which value they currently represent, so you sh

It is recommended, though not required, that you create new Models every time your menu is regenerated (and remove old subscribers when the previous menu is disposed).

## Generated Menus

Any data class can be annotated with the `[GenerateMenu]` attribute to generate elements for each associated field automatically, via source-time generation. All public fields and properties with supported types are handled.

A non-public field or property can be included by annotating it with `[GenerateMenu]`. A public property can be ignored by annotating it with `[ModMenuIgnore]`. Various constraints and customizations can be supplied through annotations documented in [`Generator/Attributes.cs`](Generator/Attributes.cs).

## Future work

The ModMenu mod should be considered _unstable_ until version 1.0 is released. Breaking API changes may occur in the pursuit of implementing additional features to reach 1.0.
Expand Down
Loading
Loading