/* * 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; using AllOptions = System.Collections.Generic.IDictionary>; using PeterHan.PLib.Core; namespace PeterHan.PLib.Options { /// /// An abstract parent class containing methods shared by all built-in options handlers. /// public abstract class OptionsEntry : IOptionsEntry, IComparable, IUIComponent { private const BindingFlags INSTANCE_PUBLIC = BindingFlags.Public | BindingFlags. Instance; /// /// The margins around the control used in each entry. /// protected static readonly RectOffset CONTROL_MARGIN = new RectOffset(0, 0, 2, 2); /// /// The margins around the label for each entry. /// protected static readonly RectOffset LABEL_MARGIN = new RectOffset(0, 5, 2, 2); /// /// Adds an options entry to the category list, creating a new category if necessary. /// /// The existing categories. /// The option entry to add. internal static void AddToCategory(AllOptions entries, IOptionsEntry entry) { string category = entry.Category ?? ""; if (!entries.TryGetValue(category, out OptionsList inCat)) { inCat = new List(16); entries.Add(category, inCat); } inCat.Add(entry); } /// /// Builds the options entries from the type. /// /// The type of the options class. /// A list of all public properties annotated for options dialogs. internal static AllOptions BuildOptions(Type forType) { var entries = new SortedList(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; } /// /// 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. /// /// The options entry to be presented. /// The parent where the components will be added. /// The row index where the components will be added. /// The presenter that can display this option's value. 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 }); } /// /// Creates a dynamic options entry. /// /// The property to be created. /// The type which can handle the property. /// The created entry, or null if no entry could be created. 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; } /// /// Runs a dynamic option constructor. /// /// The property to be created. /// The constructor to run. /// The constructed dynamic option. 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(); 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; } /// /// Substitutes default strings for an options entry with an empty title. /// /// The option attribute supplied (Format is still accepted!) /// The item declaring the attribute. /// A substitute attribute with default values from STRINGS. 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 }; } /// /// 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. /// /// The string key to check. /// The string value with that key, or the key if there is no such localized /// string value. public static string LookInStrings(string keyOrValue) { string result = keyOrValue; if (!string.IsNullOrEmpty(keyOrValue) && Strings.TryGet(keyOrValue, out var entry)) result = entry.String; return result; } /// /// Shared code to create an options entry if an [Option] attribute is found on a /// property. /// /// The property to inspect. /// The current depth of iteration to avoid infinite loops. /// The OptionsEntry created, or null if none was. 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.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; } /// /// Creates an options entry if an attribute is a valid IOptionSpec or /// DynamicOptionAttribute. /// /// The attribute to parse. /// The property to inspect. /// The current depth of iteration to avoid infinite loops. /// The OptionsEntry created from the attribute, or null if none was. 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; } /// /// The category for this entry. /// public string Category { get; } /// /// The option field name. /// public string Field { get; } /// /// The format string to use when rendering this option, or null if none was supplied. /// public string Format { get; } public virtual string Name => nameof(OptionsEntry); public event PUIDelegates.OnRealize OnRealize; /// /// The option title on screen. /// public string Title { get; protected set; } /// /// The tool tip to display. /// public string Tooltip { get; protected set; } /// /// The current value selected by the user. /// 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); } /// /// Adds the line item entry for this options entry. /// /// The location to add this entry. /// The layout row index to use. If updated, the row index will /// continue to count up from the new value. public virtual void CreateUIEntry(PGridPanel parent, ref int row) { CreateDefaultUIEntry(this, parent, row, this); } /// /// 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. /// /// The UI component to display. 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); } } } }