Initial commit
This commit is contained in:
473
mod/PLibOptions/OptionsDialog.cs
Normal file
473
mod/PLibOptions/OptionsDialog.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user