oni-priority-ux/mod/PLibOptions/OptionsEntry.cs

384 lines
14 KiB
C#

/*
* Copyright 2022 Peter Han
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
using PeterHan.PLib.UI;
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using OptionsList = System.Collections.Generic.ICollection<PeterHan.PLib.Options.IOptionsEntry>;
using AllOptions = System.Collections.Generic.IDictionary<string, System.Collections.Generic.
ICollection<PeterHan.PLib.Options.IOptionsEntry>>;
using PeterHan.PLib.Core;
namespace PeterHan.PLib.Options {
/// <summary>
/// An abstract parent class containing methods shared by all built-in options handlers.
/// </summary>
public abstract class OptionsEntry : IOptionsEntry, IComparable<OptionsEntry>,
IUIComponent {
private const BindingFlags INSTANCE_PUBLIC = BindingFlags.Public | BindingFlags.
Instance;
/// <summary>
/// The margins around the control used in each entry.
/// </summary>
protected static readonly RectOffset CONTROL_MARGIN = new RectOffset(0, 0, 2, 2);
/// <summary>
/// The margins around the label for each entry.
/// </summary>
protected static readonly RectOffset LABEL_MARGIN = new RectOffset(0, 5, 2, 2);
/// <summary>
/// Adds an options entry to the category list, creating a new category if necessary.
/// </summary>
/// <param name="entries">The existing categories.</param>
/// <param name="entry">The option entry to add.</param>
internal static void AddToCategory(AllOptions entries, IOptionsEntry entry) {
string category = entry.Category ?? "";
if (!entries.TryGetValue(category, out OptionsList inCat)) {
inCat = new List<IOptionsEntry>(16);
entries.Add(category, inCat);
}
inCat.Add(entry);
}
/// <summary>
/// Builds the options entries from the type.
/// </summary>
/// <param name="forType">The type of the options class.</param>
/// <returns>A list of all public properties annotated for options dialogs.</returns>
internal static AllOptions BuildOptions(Type forType) {
var entries = new SortedList<string, OptionsList>(8);
OptionsHandlers.InitPredefinedOptions();
foreach (var prop in forType.GetProperties(INSTANCE_PUBLIC)) {
// Must have the annotation
var entry = TryCreateEntry(prop, 0);
if (entry != null)
AddToCategory(entries, entry);
}
return entries;
}
/// <summary>
/// Creates a default UI entry. This entry will have the title and tool tip in the
/// first column, and the provided UI component in the second column. Only one row is
/// added by this method.
/// </summary>
/// <param name="entry">The options entry to be presented.</param>
/// <param name="parent">The parent where the components will be added.</param>
/// <param name="row">The row index where the components will be added.</param>
/// <param name="presenter">The presenter that can display this option's value.</param>
public static void CreateDefaultUIEntry(IOptionsEntry entry, PGridPanel parent,
int row, IUIComponent presenter) {
parent.AddChild(new PLabel("Label") {
Text = LookInStrings(entry.Title), ToolTip = LookInStrings(entry.Tooltip),
TextStyle = PUITuning.Fonts.TextLightStyle
}, new GridComponentSpec(row, 0) {
Margin = LABEL_MARGIN, Alignment = TextAnchor.MiddleLeft
});
parent.AddChild(presenter, new GridComponentSpec(row, 1) {
Alignment = TextAnchor.MiddleRight, Margin = CONTROL_MARGIN
});
}
/// <summary>
/// Creates a dynamic options entry.
/// </summary>
/// <param name="prop">The property to be created.</param>
/// <param name="handler">The type which can handle the property.</param>
/// <returns>The created entry, or null if no entry could be created.</returns>
private static IOptionsEntry CreateDynamicOption(PropertyInfo prop, Type handler) {
IOptionsEntry result = null;
var constructors = handler.GetConstructors(BindingFlags.Public | BindingFlags.
Instance);
string name = prop.Name;
int n = constructors.Length;
for (int i = 0; i < n && result == null; i++)
try {
if (ExecuteConstructor(prop, constructors[i]) is IOptionsEntry entry)
result = entry;
} catch (TargetInvocationException e) {
PUtil.LogError("Unable to create option handler for property " +
name + ":");
PUtil.LogException(e.GetBaseException());
} catch (MemberAccessException) {
// Should never happen, filtered to public and instance
} catch (AmbiguousMatchException) {
} catch (TypeLoadException e) {
PUtil.LogError("Unable to instantiate option handler for property " +
name + ":");
PUtil.LogException(e.GetBaseException());
}
if (result == null)
PUtil.LogWarning("Unable to create option handler for property " +
name + ", it must have a public constructor");
return result;
}
/// <summary>
/// Runs a dynamic option constructor.
/// </summary>
/// <param name="prop">The property to be created.</param>
/// <param name="cons">The constructor to run.</param>
/// <returns>The constructed dynamic option.</returns>
private static object ExecuteConstructor(PropertyInfo prop, ConstructorInfo cons) {
object result;
var parameters = cons.GetParameters();
int p = parameters.Length;
if (p == 0)
// Public default
result = cons.Invoke(null);
else {
var values = new object[p];
for (int j = 0; j < p; j++) {
var targetType = parameters[j].ParameterType;
// Custom attribute
if (typeof(Attribute).IsAssignableFrom(targetType))
values[j] = prop.GetCustomAttribute(targetType);
else if (targetType == typeof(IOptionSpec))
values[j] = prop.GetCustomAttribute<OptionAttribute>();
else if (targetType == typeof(string))
values[j] = prop.Name;
else
PUtil.LogWarning("DynamicOption cannot handle constructor parameter of type " +
targetType.FullName);
}
result = cons.Invoke(values);
}
return result;
}
/// <summary>
/// Substitutes default strings for an options entry with an empty title.
/// </summary>
/// <param name="spec">The option attribute supplied (Format is still accepted!)</param>
/// <param name="member">The item declaring the attribute.</param>
/// <returns>A substitute attribute with default values from STRINGS.</returns>
internal static IOptionSpec HandleDefaults(IOptionSpec spec, MemberInfo member) {
// Replace with entries takem from the strings
string prefix = "STRINGS.{0}.OPTIONS.{1}.".F(member.DeclaringType?.
Namespace?.ToUpperInvariant(), member.Name.ToUpperInvariant());
string category = "";
if (Strings.TryGet(prefix + "CATEGORY", out var entry))
category = entry.String;
return new OptionAttribute(prefix + "NAME", prefix + "TOOLTIP", category) {
Format = spec.Format
};
}
/// <summary>
/// First looks to see if the string exists in the string database; if it does, returns
/// the localized value, otherwise returns the string unmodified.
///
/// This method is somewhat slow. Cache the result if possible.
/// </summary>
/// <param name="keyOrValue">The string key to check.</param>
/// <returns>The string value with that key, or the key if there is no such localized
/// string value.</returns>
public static string LookInStrings(string keyOrValue) {
string result = keyOrValue;
if (!string.IsNullOrEmpty(keyOrValue) && Strings.TryGet(keyOrValue, out var
entry))
result = entry.String;
return result;
}
/// <summary>
/// Shared code to create an options entry if an [Option] attribute is found on a
/// property.
/// </summary>
/// <param name="prop">The property to inspect.</param>
/// <param name="depth">The current depth of iteration to avoid infinite loops.</param>
/// <returns>The OptionsEntry created, or null if none was.</returns>
internal static IOptionsEntry TryCreateEntry(PropertyInfo prop, int depth) {
IOptionsEntry result = null;
// Must have the annotation, cannot be indexed
var indexes = prop.GetIndexParameters();
if (indexes.Length < 1) {
var attributes = ListPool<Attribute, OptionsEntry>.Allocate();
bool dlcMatch = true;
attributes.AddRange(prop.GetCustomAttributes());
int n = attributes.Count;
for (int i = 0; i < n; i++)
// Do not create an entry if the DLC does not match
if (attributes[i] is RequireDLCAttribute requireDLC && DlcManager.
IsContentActive(requireDLC.DlcID) != requireDLC.Required) {
dlcMatch = false;
break;
}
if (dlcMatch)
for (int i = 0; i < n; i++) {
result = TryCreateEntry(attributes[i], prop, depth);
if (result != null)
break;
}
attributes.Recycle();
}
return result;
}
/// <summary>
/// Creates an options entry if an attribute is a valid IOptionSpec or
/// DynamicOptionAttribute.
/// </summary>
/// <param name="attribute">The attribute to parse.</param>
/// <param name="prop">The property to inspect.</param>
/// <param name="depth">The current depth of iteration to avoid infinite loops.</param>
/// <returns>The OptionsEntry created from the attribute, or null if none was.</returns>
private static IOptionsEntry TryCreateEntry(Attribute attribute, PropertyInfo prop,
int depth) {
IOptionsEntry result = null;
if (prop == null)
throw new ArgumentNullException(nameof(prop));
if (attribute is IOptionSpec spec) {
if (string.IsNullOrEmpty(spec.Title))
spec = HandleDefaults(spec, prop);
// Attempt to find a class that will represent it
var type = prop.PropertyType;
result = OptionsHandlers.FindOptionClass(spec, prop);
// See if it has entries that can themselves be added, ignore
// value types and avoid infinite recursion
if (result == null && !type.IsValueType && depth < 16 && type !=
prop.DeclaringType)
result = CompositeOptionsEntry.Create(spec, prop, depth);
} else if (attribute is DynamicOptionAttribute doa &&
typeof(IOptionsEntry).IsAssignableFrom(doa.Handler))
result = CreateDynamicOption(prop, doa.Handler);
return result;
}
/// <summary>
/// The category for this entry.
/// </summary>
public string Category { get; }
/// <summary>
/// The option field name.
/// </summary>
public string Field { get; }
/// <summary>
/// The format string to use when rendering this option, or null if none was supplied.
/// </summary>
public string Format { get; }
public virtual string Name => nameof(OptionsEntry);
public event PUIDelegates.OnRealize OnRealize;
/// <summary>
/// The option title on screen.
/// </summary>
public string Title { get; protected set; }
/// <summary>
/// The tool tip to display.
/// </summary>
public string Tooltip { get; protected set; }
/// <summary>
/// The current value selected by the user.
/// </summary>
public abstract object Value { get; set; }
protected OptionsEntry(string field, IOptionSpec attr) {
if (attr == null)
throw new ArgumentNullException(nameof(attr));
Field = field;
Format = attr.Format;
Title = attr.Title ?? throw new ArgumentException("attr.Title is null");
Tooltip = attr.Tooltip;
Category = attr.Category;
}
public GameObject Build() {
var comp = GetUIComponent();
OnRealize?.Invoke(comp);
return comp;
}
public int CompareTo(OptionsEntry other) {
if (other == null)
throw new ArgumentNullException(nameof(other));
return string.Compare(Category, other.Category, StringComparison.
CurrentCultureIgnoreCase);
}
/// <summary>
/// Adds the line item entry for this options entry.
/// </summary>
/// <param name="parent">The location to add this entry.</param>
/// <param name="row">The layout row index to use. If updated, the row index will
/// continue to count up from the new value.</param>
public virtual void CreateUIEntry(PGridPanel parent, ref int row) {
CreateDefaultUIEntry(this, parent, row, this);
}
/// <summary>
/// Retrieves the UI component which can alter this setting. It should be sized
/// properly to display any of the valid settings. The actual value will be set after
/// the component is realized.
/// </summary>
/// <returns>The UI component to display.</returns>
public abstract GameObject GetUIComponent();
public virtual void ReadFrom(object settings) {
if (Field != null && settings != null)
try {
var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC);
if (prop != null && prop.CanRead)
Value = prop.GetValue(settings, null);
} catch (TargetInvocationException e) {
// Other mod's error
PUtil.LogException(e);
} catch (AmbiguousMatchException e) {
// Other mod's error
PUtil.LogException(e);
} catch (InvalidCastException e) {
// Our error!
PUtil.LogException(e);
}
}
public override string ToString() {
return "{1}[field={0},title={2}]".F(Field, GetType().Name, Title);
}
public virtual void WriteTo(object settings) {
if (Field != null && settings != null)
try {
var prop = settings.GetType().GetProperty(Field, INSTANCE_PUBLIC);
if (prop != null && prop.CanWrite)
prop.SetValue(settings, Value, null);
} catch (TargetInvocationException e) {
// Other mod's error
PUtil.LogException(e);
} catch (AmbiguousMatchException e) {
// Other mod's error
PUtil.LogException(e);
} catch (InvalidCastException e) {
// Our error!
PUtil.LogException(e);
}
}
}
}