diff --git a/Penumbra/Mods/Groups/CombiningModGroup.cs b/Penumbra/Mods/Groups/CombiningModGroup.cs new file mode 100644 index 00000000..255f84aa --- /dev/null +++ b/Penumbra/Mods/Groups/CombiningModGroup.cs @@ -0,0 +1,235 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Filesystem; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +/// Groups that allow all available options to be selected at once. +public sealed class CombiningModGroup : IModGroup +{ + + public GroupType Type + => GroupType.Combining; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.MultiSelection; + + public Mod Mod { get; } + public string Name { get; set; } = "Group"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + public readonly List OptionData = []; + public List Data { get; private set; } + + /// Groups that allow all available options to be selected at once. + public CombiningModGroup(Mod mod) + { + Mod = mod; + Data = [new CombinedDataContainer(this)]; + } + + IReadOnlyList IModGroup.Options + => OptionData; + + public IReadOnlyList DataContainers + => Data; + + public bool IsOption + => OptionData.Count > 0; + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + { + foreach (var path in Data.SelectWhere(o + => (o.Files.TryGetValue(gamePath, out var file) || o.FileSwaps.TryGetValue(gamePath, out file), file))) + return path; + + return null; + } + + public void RemoveOption(int index) + { + if(index < 0 || index >= OptionData.Count) + return; + + OptionData.RemoveAt(index); + var list = new List(Data.Count / 2); + var optionFlag = 1 << index; + list.AddRange(Data.Where((c, i) => (i & optionFlag) == 0)); + Data = list; + } + + public void MoveOption(int from, int to) + { + if (!OptionData.Move(ref from, ref to)) + return; + + var list = new List(Data.Count); + for (var i = 0ul; i < (ulong)Data.Count; ++i) + { + var actualIndex = (int) Functions.MoveBit(i, from, to); + list.Add(Data[actualIndex]); + } + + Data = list; + } + + public IModOption? AddOption(string name, string description = "") + { + var groupIdx = Mod.Groups.IndexOf(this); + if (groupIdx < 0) + return null; + + var subMod = new CombiningSubMod(this) + { + Name = name, + Description = description, + }; + // Double available containers. + FillContainers(2 * Data.Count); + OptionData.Add(subMod); + return subMod; + } + + public static CombiningModGroup? Load(Mod mod, JObject json) + { + var ret = new CombiningModGroup(mod, true); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.OptionData.Count == IModGroup.MaxCombiningOptions) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxCombiningOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new CombiningSubMod(ret, child); + ret.OptionData.Add(subMod); + } + + var requiredContainers = 1 << ret.OptionData.Count; + var containers = json["Containers"]; + if (containers != null) + foreach (var child in containers.Children()) + { + if (requiredContainers <= ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more data containers than it can support with {ret.OptionData.Count} options, ignoring excessive containers.", + NotificationType.Warning); + break; + } + + var container = new CombinedDataContainer(ret, child); + ret.Data.Add(container); + } + + if (requiredContainers > ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has not enough data containers for its {ret.OptionData.Count} options, filling with empty containers.", + NotificationType.Warning); + ret.FillContainers(requiredContainers); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new CombiningModGroupEditDrawer(editDrawer, this); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + => Data[setting.AsIndex].AddDataTo(redirections, manipulations); + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in DataContainers) + identifier.AddChangedItems(container, changedItems); + } + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + + jWriter.WritePropertyName("Containers"); + jWriter.WriteStartArray(); + foreach (var container in Data) + { + jWriter.WriteStartObject(); + if (container.Name.Length > 0) + { + jWriter.WritePropertyName("Name"); + jWriter.WriteValue(container.Name); + } + + SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public Setting FixSetting(Setting setting) + => new(Math.Min(setting.Value, (ulong)(Data.Count - 1))); + + /// Create a group without a mod only for saving it in the creator. + internal static CombiningModGroup WithoutMod(string name) + => new(null!) + { + Name = name, + }; + + /// For loading when no empty container should be created. + private CombiningModGroup(Mod mod, bool _) + { + Mod = mod; + Data = []; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void FillContainers(int requiredCount) + { + if (requiredCount <= Data.Count) + return; + + Data.EnsureCapacity(requiredCount); + Data.AddRange(Enumerable.Repeat(0, requiredCount - Data.Count).Select(_ => new CombinedDataContainer(this))); + } +} diff --git a/Penumbra/Mods/Groups/IModGroup.cs b/Penumbra/Mods/Groups/IModGroup.cs index a6f6e20d..96422caf 100644 --- a/Penumbra/Mods/Groups/IModGroup.cs +++ b/Penumbra/Mods/Groups/IModGroup.cs @@ -22,7 +22,8 @@ public enum GroupDrawBehaviour public interface IModGroup { - public const int MaxMultiOptions = 32; + public const int MaxMultiOptions = 32; + public const int MaxCombiningOptions = 8; public Mod Mod { get; } public string Name { get; set; } diff --git a/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs new file mode 100644 index 00000000..46c8e3db --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs @@ -0,0 +1,72 @@ +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class CombiningModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + protected override CombiningModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + protected override CombiningSubMod? CloneOption(CombiningModGroup group, IModOption option) + { + if (group.OptionData.Count >= IModGroup.MaxCombiningOptions) + { + Penumbra.Log.Error( + $"Could not add option {option.Name} to {group.Name} for mod {group.Mod.Name}, " + + $"since only up to {IModGroup.MaxCombiningOptions} options are supported in one group."); + return null; + } + + var newOption = new CombiningSubMod(group) + { + Name = option.Name, + Description = option.Description, + }; + + if (option is IModDataContainer data) + { + SubMod.Clone(data, newOption); + if (option is MultiSubMod m) + newOption.Priority = m.Priority; + else + newOption.Priority = new ModPriority(group.OptionData.Max(o => o.Priority.Value) + 1); + } + + group.OptionData.Add(newOption); + return newOption; + } + + protected override void RemoveOption(CombiningModGroup group, int optionIndex) + { + var optionFlag = 1 << optionIndex; + for (var i = group.Data.Count - 1; i >= 0; --i) + { + group.Data.RemoveAll() + if ((i & optionFlag) == optionFlag) + group.Data.RemoveAt(i); + } + + group.OptionData.RemoveAt(optionIndex); + group.DefaultSettings = group.DefaultSettings.RemoveBit(optionIndex); + } + + protected override bool MoveOption(MultiModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.Move(ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings = group.DefaultSettings.MoveBit(optionIdxFrom, optionIdxTo); + return true; + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs index d01297db..b66b4d8c 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs @@ -37,6 +37,7 @@ public class ModGroupEditor( SingleModGroupEditor singleEditor, MultiModGroupEditor multiEditor, ImcModGroupEditor imcEditor, + CombiningModGroupEditor combiningEditor, CommunicatorService communicator, SaveService saveService, Configuration config) : IService @@ -50,6 +51,9 @@ public class ModGroupEditor( public ImcModGroupEditor ImcEditor => imcEditor; + public CombiningModGroupEditor CombiningEditor + => combiningEditor; + /// Change the settings stored as default options in a mod. public void ChangeModGroupDefaultOption(IModGroup group, Setting defaultOption) { @@ -223,52 +227,60 @@ public class ModGroupEditor( case ImcSubMod i: ImcEditor.DeleteOption(i); return; + case CombiningModGroup c: + CombiningEditor.DeleteOption(c); + return; } } public IModOption? AddOption(IModGroup group, IModOption option) => group switch { - SingleModGroup s => SingleEditor.AddOption(s, option), - MultiModGroup m => MultiEditor.AddOption(m, option), - ImcModGroup i => ImcEditor.AddOption(i, option), - _ => null, + SingleModGroup s => SingleEditor.AddOption(s, option), + MultiModGroup m => MultiEditor.AddOption(m, option), + ImcModGroup i => ImcEditor.AddOption(i, option), + CombiningModGroup c => CombiningEditor.AddOption(c, option), + _ => null, }; public IModOption? AddOption(IModGroup group, string newName) => group switch { - SingleModGroup s => SingleEditor.AddOption(s, newName), - MultiModGroup m => MultiEditor.AddOption(m, newName), - ImcModGroup i => ImcEditor.AddOption(i, newName), - _ => null, + SingleModGroup s => SingleEditor.AddOption(s, newName), + MultiModGroup m => MultiEditor.AddOption(m, newName), + ImcModGroup i => ImcEditor.AddOption(i, newName), + CombiningModGroup c => CombiningEditor.AddOption(c, newName), + _ => null, }; public IModGroup? AddModGroup(Mod mod, GroupType type, string newName, SaveType saveType = SaveType.ImmediateSync) => type switch { - GroupType.Single => SingleEditor.AddModGroup(mod, newName, saveType), - GroupType.Multi => MultiEditor.AddModGroup(mod, newName, saveType), - GroupType.Imc => ImcEditor.AddModGroup(mod, newName, default, default, saveType), - _ => null, + GroupType.Single => SingleEditor.AddModGroup(mod, newName, saveType), + GroupType.Multi => MultiEditor.AddModGroup(mod, newName, saveType), + GroupType.Imc => ImcEditor.AddModGroup(mod, newName, default, default, saveType), + GroupType.Combining => CombiningEditor.AddModGroup(mod, newName, default, default, saveType), + _ => null, }; public (IModGroup?, int, bool) FindOrAddModGroup(Mod mod, GroupType type, string name, SaveType saveType = SaveType.ImmediateSync) => type switch { - GroupType.Single => SingleEditor.FindOrAddModGroup(mod, name, saveType), - GroupType.Multi => MultiEditor.FindOrAddModGroup(mod, name, saveType), - GroupType.Imc => ImcEditor.FindOrAddModGroup(mod, name, saveType), - _ => (null, -1, false), + GroupType.Single => SingleEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Multi => MultiEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Imc => ImcEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Combining => CombiningEditor.FindOrAddModGroup(mod, name, saveType), + _ => (null, -1, false), }; public (IModOption?, int, bool) FindOrAddOption(IModGroup group, string name, SaveType saveType = SaveType.ImmediateSync) => group switch { - SingleModGroup s => SingleEditor.FindOrAddOption(s, name, saveType), - MultiModGroup m => MultiEditor.FindOrAddOption(m, name, saveType), - ImcModGroup i => ImcEditor.FindOrAddOption(i, name, saveType), - _ => (null, -1, false), + SingleModGroup s => SingleEditor.FindOrAddOption(s, name, saveType), + MultiModGroup m => MultiEditor.FindOrAddOption(m, name, saveType), + ImcModGroup i => ImcEditor.FindOrAddOption(i, name, saveType), + CombiningModGroup c => CombiningEditor.FindOrAddOption(c, name, saveType), + _ => (null, -1, false), }; public void MoveOption(IModOption option, int toIdx) @@ -284,6 +296,9 @@ public class ModGroupEditor( case ImcSubMod i: ImcEditor.MoveOption(i, toIdx); return; + case CombiningSubMod c: + CombiningEditor.MoveOption(c, toIdx); + return; } } } diff --git a/Penumbra/Mods/SubMods/CombinedDataContainer.cs b/Penumbra/Mods/SubMods/CombinedDataContainer.cs new file mode 100644 index 00000000..3e8ec95b --- /dev/null +++ b/Penumbra/Mods/SubMods/CombinedDataContainer.cs @@ -0,0 +1,72 @@ +using Newtonsoft.Json.Linq; +using OtterGui; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; +using Swan.Formatters; + +namespace Penumbra.Mods.SubMods; + +public class CombinedDataContainer(IModGroup group) : IModDataContainer +{ + public IMod Mod + => Group.Mod; + + public IModGroup Group { get; } = group; + + public string Name { get; } = string.Empty; + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public void AddDataTo(Dictionary redirections, MetaDictionary manipulations) + => SubMod.AddContainerTo(this, redirections, manipulations); + + public string GetName() + { + if (Name.Length > 0) + return Name; + + var index = GetDataIndex(); + if (index == 0) + return "None"; + + var sb = new StringBuilder(128); + for (var i = 0; i < IModGroup.MaxCombiningOptions; ++i) + { + if ((index & 1) == 0) + continue; + + sb.Append(Group.Options[i].Name); + sb.Append(' ').Append('+').Append(' '); + index >>= 1; + if (index == 0) + break; + } + + return sb.ToString(0, sb.Length - 3); + } + + public string GetFullName() + => $"{Group.Name}: {GetName()}"; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), GetDataIndex()); + + private int GetDataIndex() + { + var dataIndex = Group.DataContainers.IndexOf(this); + if (dataIndex < 0) + throw new Exception($"Group {Group.Name} from SubMod {Name} does not contain this SubMod."); + + return dataIndex; + } + + public CombinedDataContainer(CombiningModGroup group, JToken token) + : this(group) + { + SubMod.LoadDataContainer(token, this, group.Mod.ModPath); + Name = token["Name"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/CombiningSubMod.cs b/Penumbra/Mods/SubMods/CombiningSubMod.cs new file mode 100644 index 00000000..6eb5de9d --- /dev/null +++ b/Penumbra/Mods/SubMods/CombiningSubMod.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public class CombiningSubMod(IModGroup group) : IModOption +{ + public IModGroup Group { get; } = group; + + public Mod Mod + => Group.Mod; + + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + + public string FullName + => $"{Group.Name}: {Name}"; + + public int GetIndex() + => SubMod.GetIndex(this); + + public CombiningSubMod(CombiningModGroup group, JToken json) + : this(group) + => SubMod.LoadOptionData(json, this); +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 02e945f3..7f1a8ac5 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -452,19 +452,17 @@ public partial class ModEditWindow : Window, IDisposable, IUiService private bool DrawOptionSelectHeader() { - const string defaultOption = "Default Option"; using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero).Push(ImGuiStyleVar.FrameRounding, 0); var width = new Vector2(ImGui.GetContentRegionAvail().X / 3, 0); var ret = false; - if (ImGuiUtil.DrawDisabledButton(defaultOption, width, "Switch to the default option for the mod.\nThis resets unsaved changes.", - _editor.Option is DefaultSubMod)) + if (ImUtf8.ButtonEx("Default Option"u8, "Switch to the default option for the mod.\nThis resets unsaved changes."u8, width, _editor.Option is DefaultSubMod)) { _editor.LoadOption(-1, 0).Wait(); ret = true; } ImGui.SameLine(); - if (ImGuiUtil.DrawDisabledButton("Refresh Data", width, "Refresh data for the current option.\nThis resets unsaved changes.", false)) + if (ImUtf8.ButtonEx("Refresh Data"u8, "Refresh data for the current option.\nThis resets unsaved changes."u8, width)) { _editor.LoadMod(_editor.Mod!, _editor.GroupIdx, _editor.DataIdx).Wait(); ret = true; @@ -474,7 +472,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService ImGui.SetNextItemWidth(width.X); style.Push(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale); using var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value()); - using var combo = ImRaii.Combo("##optionSelector", _editor.Option!.GetFullName()); + using var combo = ImUtf8.Combo("##optionSelector"u8, _editor.Option!.GetFullName()); if (!combo) return ret; diff --git a/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs index c30239bc..a3e7ce14 100644 --- a/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs @@ -48,6 +48,7 @@ public class AddGroupDrawer : IUiService DrawSingleGroupButton(mod, buttonWidth); ImUtf8.SameLineInner(); DrawMultiGroupButton(mod, buttonWidth); + DrawCombiningGroupButton(mod, buttonWidth); } private void DrawSingleGroupButton(Mod mod, Vector2 width) @@ -76,6 +77,18 @@ public class AddGroupDrawer : IUiService _groupNameValid = false; } + private void DrawCombiningGroupButton(Mod mod, Vector2 width) + { + if (!ImUtf8.ButtonEx("Add Combining Group"u8, _groupNameValid + ? "Add a new combining option group to this mod."u8 + : "Can not add a new group of this name."u8, + width, !_groupNameValid)) + return; + + _modManager.OptionEditor.AddModGroup(mod, GroupType.Combining, _groupName); + _groupName = string.Empty; + _groupNameValid = false; + } private void DrawImcInput(float width) { var change = ImcMetaDrawer.DrawObjectType(ref _imcIdentifier, width); diff --git a/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs new file mode 100644 index 00000000..79d2fb43 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs @@ -0,0 +1,11 @@ +using Penumbra.Mods.Groups; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct CombiningModGroupEditDrawer(ModGroupEditDrawer editor, CombiningModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + + } +}