474 lines
17 KiB
C#
474 lines
17 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.Core;
|
|
using PeterHan.PLib.UI;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using UnityEngine;
|
|
|
|
using OptionsList = System.Collections.Generic.ICollection<PeterHan.PLib.Options.IOptionsEntry>;
|
|
|
|
namespace PeterHan.PLib.Options {
|
|
/// <summary>
|
|
/// A dialog for handling mod options events.
|
|
/// </summary>
|
|
internal sealed class OptionsDialog {
|
|
/// <summary>
|
|
/// The color of option category titles.
|
|
/// </summary>
|
|
private static readonly Color CATEGORY_TITLE_COLOR = new Color32(143, 150, 175, 255);
|
|
|
|
/// <summary>
|
|
/// The text style applied to option category titles.
|
|
/// </summary>
|
|
private static readonly TextStyleSetting CATEGORY_TITLE_STYLE;
|
|
|
|
/// <summary>
|
|
/// The margins inside the colored boxes in each config section.
|
|
/// </summary>
|
|
private static readonly int CATEGORY_MARGIN = 8;
|
|
|
|
/// <summary>
|
|
/// The size of the mod preview image displayed.
|
|
/// </summary>
|
|
private static readonly Vector2 MOD_IMAGE_SIZE = new Vector2(192.0f, 192.0f);
|
|
|
|
/// <summary>
|
|
/// The margins between the dialog edge and the colored boxes in each config section.
|
|
/// </summary>
|
|
private static readonly int OUTER_MARGIN = 10;
|
|
|
|
/// <summary>
|
|
/// The default size of the Mod Settings dialog.
|
|
/// </summary>
|
|
private static readonly Vector2 SETTINGS_DIALOG_SIZE = new Vector2(320.0f, 200.0f);
|
|
|
|
/// <summary>
|
|
/// The maximum size of the Mod Settings dialog before it gets scroll bars.
|
|
/// </summary>
|
|
private static readonly Vector2 SETTINGS_DIALOG_MAX_SIZE = new Vector2(800.0f, 600.0f);
|
|
|
|
/// <summary>
|
|
/// The size of the toggle button on each (non-default) config section.
|
|
/// </summary>
|
|
private static readonly Vector2 TOGGLE_SIZE = new Vector2(12.0f, 12.0f);
|
|
|
|
static OptionsDialog() {
|
|
CATEGORY_TITLE_STYLE = PUITuning.Fonts.UILightStyle.DeriveStyle(newColor:
|
|
CATEGORY_TITLE_COLOR, style: TMPro.FontStyles.Bold);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an options object using the default constructor if possible.
|
|
/// </summary>
|
|
/// <param name="type">The type of the object to create.</param>
|
|
internal static object CreateOptions(Type type) {
|
|
object result = null;
|
|
try {
|
|
var cons = type.GetConstructor(Type.EmptyTypes);
|
|
if (cons != null)
|
|
result = cons.Invoke(null);
|
|
} catch (TargetInvocationException e) {
|
|
// Other mod's error
|
|
PUtil.LogExcWarn(e.GetBaseException());
|
|
} catch (AmbiguousMatchException e) {
|
|
// Other mod's error
|
|
PUtil.LogException(e);
|
|
} catch (MemberAccessException e) {
|
|
// Other mod's error
|
|
PUtil.LogException(e);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the mod enabled settings and restarts the game.
|
|
/// </summary>
|
|
private static void SaveAndRestart() {
|
|
#if OPTIONS_ONLY
|
|
POptionsPatches.SaveMods();
|
|
#else
|
|
PGameUtils.SaveMods();
|
|
#endif
|
|
App.instance.Restart();
|
|
}
|
|
|
|
/// <summary>
|
|
/// If true, all categories begin collapsed.
|
|
/// </summary>
|
|
private readonly bool collapseCategories;
|
|
|
|
/// <summary>
|
|
/// The config file attribute for the options type, if present.
|
|
/// </summary>
|
|
private readonly ConfigFileAttribute configAttr;
|
|
|
|
/// <summary>
|
|
/// The currently active dialog.
|
|
/// </summary>
|
|
private KScreen dialog;
|
|
|
|
/// <summary>
|
|
/// The sprite to display for this mod.
|
|
/// </summary>
|
|
private Sprite modImage;
|
|
|
|
/// <summary>
|
|
/// Collects information from the ModInfoAttribute and KMod.Mod objects for display.
|
|
/// </summary>
|
|
private readonly ModDialogInfo displayInfo;
|
|
|
|
/// <summary>
|
|
/// The event to invoke when the dialog is closed.
|
|
/// </summary>
|
|
public Action<object> OnClose { get; set; }
|
|
|
|
/// <summary>
|
|
/// The option entries in the dialog.
|
|
/// </summary>
|
|
private readonly IDictionary<string, OptionsList> optionCategories;
|
|
|
|
/// <summary>
|
|
/// The options read from the config. It might contain hidden options so preserve its
|
|
/// contents here.
|
|
/// </summary>
|
|
private object options;
|
|
|
|
/// <summary>
|
|
/// The type used to determine which options are visible.
|
|
/// </summary>
|
|
private readonly Type optionsType;
|
|
|
|
internal OptionsDialog(Type optionsType) {
|
|
OnClose = null;
|
|
dialog = null;
|
|
modImage = null;
|
|
this.optionsType = optionsType ?? throw new ArgumentNullException(nameof(
|
|
optionsType));
|
|
optionCategories = OptionsEntry.BuildOptions(optionsType);
|
|
options = null;
|
|
// Determine config location
|
|
var infoAttr = optionsType.GetCustomAttribute<ModInfoAttribute>();
|
|
collapseCategories = infoAttr != null && infoAttr.ForceCollapseCategories;
|
|
configAttr = optionsType.GetCustomAttribute<ConfigFileAttribute>();
|
|
displayInfo = new ModDialogInfo(optionsType, infoAttr?.URL, infoAttr?.Image);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a category header to the dialog.
|
|
/// </summary>
|
|
/// <param name="container">The parent of the header.</param>
|
|
/// <param name="category">The header title.</param>
|
|
/// <param name="contents">The panel containing the options in this category.</param>
|
|
private void AddCategoryHeader(PGridPanel container, string category,
|
|
PGridPanel contents) {
|
|
contents.AddColumn(new GridColumnSpec(flex: 1.0f)).AddColumn(new GridColumnSpec());
|
|
if (!string.IsNullOrEmpty(category)) {
|
|
bool state = !collapseCategories;
|
|
var handler = new CategoryExpandHandler(state);
|
|
container.AddColumn(new GridColumnSpec()).AddColumn(new GridColumnSpec(
|
|
flex: 1.0f)).AddRow(new GridRowSpec()).AddRow(new GridRowSpec(flex: 1.0f));
|
|
// Toggle is upper left, header is upper right
|
|
container.AddChild(new PLabel("CategoryHeader") {
|
|
Text = OptionsEntry.LookInStrings(category), TextStyle =
|
|
CATEGORY_TITLE_STYLE, TextAlignment = TextAnchor.LowerCenter
|
|
}.AddOnRealize(handler.OnRealizeHeader), new GridComponentSpec(0, 1) {
|
|
Margin = new RectOffset(OUTER_MARGIN, OUTER_MARGIN, 0, 0)
|
|
}).AddChild(new PToggle("CategoryToggle") {
|
|
Color = PUITuning.Colors.ComponentDarkStyle, InitialState = state,
|
|
ToolTip = PLibStrings.TOOLTIP_TOGGLE, Size = TOGGLE_SIZE,
|
|
OnStateChanged = handler.OnExpandContract
|
|
}.AddOnRealize(handler.OnRealizeToggle), new GridComponentSpec(0, 0));
|
|
contents.OnRealize += handler.OnRealizePanel;
|
|
container.AddChild(contents, new GridComponentSpec(1, 0) { ColumnSpan = 2 });
|
|
} else
|
|
// Default of unconstrained fills the whole panel
|
|
container.AddColumn(new GridColumnSpec(flex: 1.0f)).AddRow(new GridRowSpec(
|
|
flex: 1.0f)).AddChild(contents, new GridComponentSpec(0, 0));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills in the mod info screen, assuming that infoAttr is non-null.
|
|
/// </summary>
|
|
/// <param name="optionsDialog">The dialog to populate.</param>
|
|
private void AddModInfoScreen(PDialog optionsDialog) {
|
|
string image = displayInfo.Image;
|
|
var body = optionsDialog.Body;
|
|
// Try to load the mod image sprite if possible
|
|
if (modImage == null && !string.IsNullOrEmpty(image)) {
|
|
string rootDir = PUtil.GetModPath(optionsType.Assembly);
|
|
modImage = PUIUtils.LoadSpriteFile(rootDir == null ? image : Path.Combine(
|
|
rootDir, image));
|
|
}
|
|
var websiteButton = new PButton("ModSite") {
|
|
Text = PLibStrings.MOD_HOMEPAGE, ToolTip = PLibStrings.TOOLTIP_HOMEPAGE,
|
|
OnClick = VisitModHomepage, Margin = PDialog.BUTTON_MARGIN
|
|
}.SetKleiBlueStyle();
|
|
var versionLabel = new PLabel("ModVersion") {
|
|
Text = displayInfo.Version, ToolTip = PLibStrings.TOOLTIP_VERSION,
|
|
TextStyle = PUITuning.Fonts.UILightStyle, Margin = new RectOffset(0, 0,
|
|
OUTER_MARGIN, 0)
|
|
};
|
|
// Find mod URL
|
|
string modURL = displayInfo.URL;
|
|
if (modImage != null) {
|
|
// 2 rows and 1 column
|
|
if (optionCategories.Count > 0)
|
|
body.Direction = PanelDirection.Horizontal;
|
|
var infoPanel = new PPanel("ModInfo") {
|
|
FlexSize = Vector2.up, Direction = PanelDirection.Vertical,
|
|
Alignment = TextAnchor.UpperCenter
|
|
}.AddChild(new PLabel("ModImage") {
|
|
SpriteSize = MOD_IMAGE_SIZE, TextAlignment = TextAnchor.UpperLeft,
|
|
Margin = new RectOffset(0, OUTER_MARGIN, 0, OUTER_MARGIN),
|
|
Sprite = modImage
|
|
});
|
|
if (!string.IsNullOrEmpty(modURL))
|
|
infoPanel.AddChild(websiteButton);
|
|
body.AddChild(infoPanel.AddChild(versionLabel));
|
|
} else {
|
|
if (!string.IsNullOrEmpty(modURL))
|
|
body.AddChild(websiteButton);
|
|
body.AddChild(versionLabel);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks the mod config class for the [RestartRequired] attribute, and brings up a
|
|
/// restart dialog if necessary.
|
|
/// </summary>
|
|
private void CheckForRestart() {
|
|
if (options != null && options.GetType().GetCustomAttribute(typeof(
|
|
RestartRequiredAttribute)) != null)
|
|
// Prompt user to restart
|
|
PUIElements.ShowConfirmDialog(null, PLibStrings.RESTART_REQUIRED,
|
|
SaveAndRestart, null, PLibStrings.RESTART_OK, PLibStrings.RESTART_CANCEL);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the current dialog.
|
|
/// </summary>
|
|
private void CloseDialog() {
|
|
if (dialog != null) {
|
|
dialog.Deactivate();
|
|
// dialog's game object is destroyed by Deactivate()
|
|
dialog = null;
|
|
}
|
|
if (modImage != null) {
|
|
UnityEngine.Object.Destroy(modImage);
|
|
modImage = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fills in the actual mod option fields.
|
|
/// </summary>
|
|
/// <param name="optionsDialog">The dialog to populate.</param>
|
|
private void FillModOptions(PDialog optionsDialog) {
|
|
IEnumerable<IOptionsEntry> dynamicOptions;
|
|
var body = optionsDialog.Body;
|
|
var margin = new RectOffset(CATEGORY_MARGIN, CATEGORY_MARGIN, CATEGORY_MARGIN,
|
|
CATEGORY_MARGIN);
|
|
// For each option, add its UI component to panel
|
|
body.Margin = new RectOffset();
|
|
var scrollBody = new PPanel("ScrollContent") {
|
|
Spacing = OUTER_MARGIN, Direction = PanelDirection.Vertical, Alignment =
|
|
TextAnchor.UpperCenter, FlexSize = Vector2.right
|
|
};
|
|
var allOptions = optionCategories;
|
|
// Add options from the user's class
|
|
if (options is IOptions dynOptions && (dynamicOptions = dynOptions.
|
|
CreateOptions()) != null) {
|
|
allOptions = new Dictionary<string, OptionsList>(optionCategories);
|
|
foreach (var dynamicOption in dynamicOptions)
|
|
OptionsEntry.AddToCategory(allOptions, dynamicOption);
|
|
}
|
|
// Display all categories
|
|
foreach (var catEntries in allOptions) {
|
|
string category = catEntries.Key;
|
|
var optionsList = catEntries.Value;
|
|
if (optionsList.Count > 0) {
|
|
string name = string.IsNullOrEmpty(category) ? "Default" : category;
|
|
int i = 0;
|
|
// Not optimal for layout performance, but the panel is needed to have a
|
|
// different background color for each category "box"
|
|
var container = new PGridPanel("Category_" + name) {
|
|
Margin = margin, BackColor = PUITuning.Colors.DialogDarkBackground,
|
|
FlexSize = Vector2.right
|
|
};
|
|
// Needs to be a separate panel so that it can be collapsed
|
|
var contents = new PGridPanel("Entries") { FlexSize = Vector2.right };
|
|
AddCategoryHeader(container, catEntries.Key, contents);
|
|
foreach (var entry in optionsList) {
|
|
contents.AddRow(new GridRowSpec());
|
|
entry.CreateUIEntry(contents, ref i);
|
|
i++;
|
|
}
|
|
scrollBody.AddChild(container);
|
|
}
|
|
}
|
|
// Manual config and reset button
|
|
scrollBody.AddChild(new PPanel("ConfigButtons") {
|
|
Spacing = 10, Direction = PanelDirection.Horizontal, Alignment =
|
|
TextAnchor.MiddleCenter, FlexSize = Vector2.right
|
|
}.AddChild(new PButton("ManualConfig") {
|
|
Text = PLibStrings.BUTTON_MANUAL, ToolTip = PLibStrings.TOOLTIP_MANUAL,
|
|
OnClick = OnManualConfig, TextAlignment = TextAnchor.MiddleCenter, Margin =
|
|
PDialog.BUTTON_MARGIN
|
|
}.SetKleiBlueStyle()).AddChild(new PButton("ResetConfig") {
|
|
Text = PLibStrings.BUTTON_RESET, ToolTip = PLibStrings.TOOLTIP_RESET,
|
|
OnClick = OnResetConfig, TextAlignment = TextAnchor.MiddleCenter, Margin =
|
|
PDialog.BUTTON_MARGIN
|
|
}.SetKleiBlueStyle()));
|
|
body.AddChild(new PScrollPane() {
|
|
ScrollHorizontal = false, ScrollVertical = allOptions.Count > 0,
|
|
Child = scrollBody, FlexSize = Vector2.right, TrackSize = 8,
|
|
AlwaysShowHorizontal = false, AlwaysShowVertical = false
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked when the manual config button is pressed.
|
|
/// </summary>
|
|
private void OnManualConfig(GameObject _) {
|
|
string uri = null, path = POptions.GetConfigFilePath(optionsType);
|
|
try {
|
|
uri = new Uri(Path.GetDirectoryName(path) ?? path).AbsoluteUri;
|
|
} catch (UriFormatException e) {
|
|
PUtil.LogWarning("Unable to convert parent of " + path + " to a URI:");
|
|
PUtil.LogExcWarn(e);
|
|
}
|
|
if (!string.IsNullOrEmpty(uri)) {
|
|
// Open the config folder, opening the file itself might start an unknown
|
|
// editor which could execute the json somehow...
|
|
WriteOptions();
|
|
CloseDialog();
|
|
PUtil.LogDebug("Opening config folder: " + uri);
|
|
Application.OpenURL(uri);
|
|
CheckForRestart();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked when the dialog is closed.
|
|
/// </summary>
|
|
/// <param name="action">The action key taken.</param>
|
|
private void OnOptionsSelected(string action) {
|
|
switch (action) {
|
|
case "ok":
|
|
// Save changes to mod options
|
|
WriteOptions();
|
|
CheckForRestart();
|
|
break;
|
|
case PDialog.DIALOG_KEY_CLOSE:
|
|
OnClose?.Invoke(options);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoked when the reset to default button is pressed.
|
|
/// </summary>
|
|
private void OnResetConfig(GameObject _) {
|
|
options = CreateOptions(optionsType);
|
|
UpdateOptions();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Triggered when the Mod Options button is clicked.
|
|
/// </summary>
|
|
public void ShowDialog() {
|
|
string title;
|
|
if (string.IsNullOrEmpty(displayInfo.Title))
|
|
title = PLibStrings.BUTTON_OPTIONS;
|
|
else
|
|
title = string.Format(PLibStrings.DIALOG_TITLE, OptionsEntry.LookInStrings(
|
|
displayInfo.Title));
|
|
// Close current dialog if open
|
|
CloseDialog();
|
|
// Ensure that it is on top of other screens (which may be +100 modal)
|
|
var pDialog = new PDialog("ModOptions") {
|
|
Title = title, Size = SETTINGS_DIALOG_SIZE, SortKey = 150.0f,
|
|
DialogBackColor = PUITuning.Colors.OptionsBackground,
|
|
DialogClosed = OnOptionsSelected, MaxSize = SETTINGS_DIALOG_MAX_SIZE,
|
|
RoundToNearestEven = true
|
|
}.AddButton("ok", STRINGS.UI.CONFIRMDIALOG.OK, PLibStrings.TOOLTIP_OK,
|
|
PUITuning.Colors.ButtonPinkStyle).AddButton(PDialog.DIALOG_KEY_CLOSE,
|
|
STRINGS.UI.CONFIRMDIALOG.CANCEL, PLibStrings.TOOLTIP_CANCEL,
|
|
PUITuning.Colors.ButtonBlueStyle);
|
|
options = POptions.ReadSettings(POptions.GetConfigFilePath(optionsType),
|
|
optionsType) ?? CreateOptions(optionsType);
|
|
AddModInfoScreen(pDialog);
|
|
FillModOptions(pDialog);
|
|
// Manually build the dialog so the options can be updated after realization
|
|
var obj = pDialog.Build();
|
|
UpdateOptions();
|
|
if (obj.TryGetComponent(out dialog))
|
|
dialog.Activate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls the user OnOptionsChanged handler if present.
|
|
/// </summary>
|
|
/// <param name="newOptions">The updated options object.</param>
|
|
private void TriggerUpdateOptions(object newOptions) {
|
|
// Call the user handler
|
|
if (newOptions is IOptions onChanged)
|
|
onChanged.OnOptionsChanged();
|
|
OnClose?.Invoke(newOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the dialog with the latest options from the file.
|
|
/// </summary>
|
|
private void UpdateOptions() {
|
|
// Read into local options
|
|
if (options != null)
|
|
foreach (var catEntries in optionCategories)
|
|
foreach (var option in catEntries.Value)
|
|
option.ReadFrom(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// If configured, opens the mod's home page in the default browser.
|
|
/// </summary>
|
|
private void VisitModHomepage(GameObject _) {
|
|
if (!string.IsNullOrWhiteSpace(displayInfo.URL))
|
|
Application.OpenURL(displayInfo.URL);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the mod options to its config file.
|
|
/// </summary>
|
|
private void WriteOptions() {
|
|
if (options != null) {
|
|
// Update from local options
|
|
foreach (var catEntries in optionCategories)
|
|
foreach (var option in catEntries.Value)
|
|
option.WriteTo(options);
|
|
POptions.WriteSettings(options, POptions.GetConfigFilePath(optionsType),
|
|
configAttr?.IndentOutput ?? false);
|
|
TriggerUpdateOptions(options);
|
|
}
|
|
}
|
|
}
|
|
}
|