/* * 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; namespace PeterHan.PLib.Options { /// /// A dialog for handling mod options events. /// internal sealed class OptionsDialog { /// /// The color of option category titles. /// private static readonly Color CATEGORY_TITLE_COLOR = new Color32(143, 150, 175, 255); /// /// The text style applied to option category titles. /// private static readonly TextStyleSetting CATEGORY_TITLE_STYLE; /// /// The margins inside the colored boxes in each config section. /// private static readonly int CATEGORY_MARGIN = 8; /// /// The size of the mod preview image displayed. /// private static readonly Vector2 MOD_IMAGE_SIZE = new Vector2(192.0f, 192.0f); /// /// The margins between the dialog edge and the colored boxes in each config section. /// private static readonly int OUTER_MARGIN = 10; /// /// The default size of the Mod Settings dialog. /// private static readonly Vector2 SETTINGS_DIALOG_SIZE = new Vector2(320.0f, 200.0f); /// /// The maximum size of the Mod Settings dialog before it gets scroll bars. /// private static readonly Vector2 SETTINGS_DIALOG_MAX_SIZE = new Vector2(800.0f, 600.0f); /// /// The size of the toggle button on each (non-default) config section. /// 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); } /// /// Creates an options object using the default constructor if possible. /// /// The type of the object to create. 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; } /// /// Saves the mod enabled settings and restarts the game. /// private static void SaveAndRestart() { #if OPTIONS_ONLY POptionsPatches.SaveMods(); #else PGameUtils.SaveMods(); #endif App.instance.Restart(); } /// /// If true, all categories begin collapsed. /// private readonly bool collapseCategories; /// /// The config file attribute for the options type, if present. /// private readonly ConfigFileAttribute configAttr; /// /// The currently active dialog. /// private KScreen dialog; /// /// The sprite to display for this mod. /// private Sprite modImage; /// /// Collects information from the ModInfoAttribute and KMod.Mod objects for display. /// private readonly ModDialogInfo displayInfo; /// /// The event to invoke when the dialog is closed. /// public Action OnClose { get; set; } /// /// The option entries in the dialog. /// private readonly IDictionary optionCategories; /// /// The options read from the config. It might contain hidden options so preserve its /// contents here. /// private object options; /// /// The type used to determine which options are visible. /// 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(); collapseCategories = infoAttr != null && infoAttr.ForceCollapseCategories; configAttr = optionsType.GetCustomAttribute(); displayInfo = new ModDialogInfo(optionsType, infoAttr?.URL, infoAttr?.Image); } /// /// Adds a category header to the dialog. /// /// The parent of the header. /// The header title. /// The panel containing the options in this category. 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)); } /// /// Fills in the mod info screen, assuming that infoAttr is non-null. /// /// The dialog to populate. 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); } } /// /// Checks the mod config class for the [RestartRequired] attribute, and brings up a /// restart dialog if necessary. /// 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); } /// /// Closes the current dialog. /// 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; } } /// /// Fills in the actual mod option fields. /// /// The dialog to populate. private void FillModOptions(PDialog optionsDialog) { IEnumerable 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(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 }); } /// /// Invoked when the manual config button is pressed. /// 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(); } } /// /// Invoked when the dialog is closed. /// /// The action key taken. 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; } } /// /// Invoked when the reset to default button is pressed. /// private void OnResetConfig(GameObject _) { options = CreateOptions(optionsType); UpdateOptions(); } /// /// Triggered when the Mod Options button is clicked. /// 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(); } /// /// Calls the user OnOptionsChanged handler if present. /// /// The updated options object. private void TriggerUpdateOptions(object newOptions) { // Call the user handler if (newOptions is IOptions onChanged) onChanged.OnOptionsChanged(); OnClose?.Invoke(newOptions); } /// /// Updates the dialog with the latest options from the file. /// private void UpdateOptions() { // Read into local options if (options != null) foreach (var catEntries in optionCategories) foreach (var option in catEntries.Value) option.ReadFrom(options); } /// /// If configured, opens the mod's home page in the default browser. /// private void VisitModHomepage(GameObject _) { if (!string.IsNullOrWhiteSpace(displayInfo.URL)) Application.OpenURL(displayInfo.URL); } /// /// Writes the mod options to its config file. /// 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); } } } }