diff --git a/Penumbra/Communication/ModPathChanged.cs b/Penumbra/Communication/ModPathChanged.cs
index 608c10eb..0a362267 100644
--- a/Penumbra/Communication/ModPathChanged.cs
+++ b/Penumbra/Communication/ModPathChanged.cs
@@ -37,13 +37,14 @@ public sealed class ModPathChanged : EventWrapper
ModManager = 0,
+ ///
+ ModMerger = 0,
+
///
CollectionStorage = 10,
///
CollectionCacheManagerRemoval = 100,
-
-
}
public ModPathChanged()
: base(nameof(ModPathChanged))
diff --git a/Penumbra/Mods/Editor/DuplicateManager.cs b/Penumbra/Mods/Editor/DuplicateManager.cs
index 62edb9fb..03321cc3 100644
--- a/Penumbra/Mods/Editor/DuplicateManager.cs
+++ b/Penumbra/Mods/Editor/DuplicateManager.cs
@@ -8,22 +8,19 @@ using System.Threading.Tasks;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.String.Classes;
-using Penumbra.Util;
-namespace Penumbra.Mods;
+namespace Penumbra.Mods.Editor;
public class DuplicateManager
{
private readonly SaveService _saveService;
private readonly ModManager _modManager;
private readonly SHA256 _hasher = SHA256.Create();
- private readonly ModFileCollection _files;
private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = new();
- public DuplicateManager(ModFileCollection files, ModManager modManager, SaveService saveService)
+ public DuplicateManager(ModManager modManager, SaveService saveService)
{
- _files = files;
- _modManager = modManager;
+ _modManager = modManager;
_saveService = saveService;
}
@@ -43,7 +40,7 @@ public class DuplicateManager
Task.Run(() => CheckDuplicates(filesTmp));
}
- public void DeleteDuplicates(Mod mod, ISubMod option, bool useModManager)
+ public void DeleteDuplicates(ModFileCollection files, Mod mod, ISubMod option, bool useModManager)
{
if (!Finished || _duplicates.Count == 0)
return;
@@ -60,7 +57,7 @@ public class DuplicateManager
_duplicates.Clear();
DeleteEmptyDirectories(mod.ModPath);
- _files.UpdateAll(mod, option);
+ files.UpdateAll(mod, option);
}
public void Clear()
@@ -248,9 +245,10 @@ public class DuplicateManager
_modManager.Creator.ReloadMod(mod, true, out _);
Finished = false;
- _files.UpdateAll(mod, mod.Default);
- CheckDuplicates(_files.Available.OrderByDescending(f => f.FileSize).ToArray());
- DeleteDuplicates(mod, mod.Default, false);
+ var files = new ModFileCollection();
+ files.UpdateAll(mod, mod.Default);
+ CheckDuplicates(files.Available.OrderByDescending(f => f.FileSize).ToArray());
+ DeleteDuplicates(files, mod, mod.Default, false);
}
catch (Exception e)
{
diff --git a/Penumbra/Mods/Editor/MdlMaterialEditor.cs b/Penumbra/Mods/Editor/MdlMaterialEditor.cs
index dc32869f..f616d128 100644
--- a/Penumbra/Mods/Editor/MdlMaterialEditor.cs
+++ b/Penumbra/Mods/Editor/MdlMaterialEditor.cs
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using OtterGui;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
+using Penumbra.Mods.Editor;
namespace Penumbra.Mods;
diff --git a/Penumbra/Mods/Editor/ModEditor.cs b/Penumbra/Mods/Editor/ModEditor.cs
index c19b9962..a874f629 100644
--- a/Penumbra/Mods/Editor/ModEditor.cs
+++ b/Penumbra/Mods/Editor/ModEditor.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using OtterGui;
+using Penumbra.Mods.Editor;
namespace Penumbra.Mods;
diff --git a/Penumbra/Mods/Editor/ModFileCollection.cs b/Penumbra/Mods/Editor/ModFileCollection.cs
index fa3d5614..5ef290dc 100644
--- a/Penumbra/Mods/Editor/ModFileCollection.cs
+++ b/Penumbra/Mods/Editor/ModFileCollection.cs
@@ -3,11 +3,10 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
-using Microsoft.Win32;
using OtterGui;
using Penumbra.String.Classes;
-namespace Penumbra.Mods;
+namespace Penumbra.Mods.Editor;
public class ModFileCollection : IDisposable
{
diff --git a/Penumbra/Mods/Editor/ModFileEditor.cs b/Penumbra/Mods/Editor/ModFileEditor.cs
index 17505550..4f9d22b3 100644
--- a/Penumbra/Mods/Editor/ModFileEditor.cs
+++ b/Penumbra/Mods/Editor/ModFileEditor.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager;
using Penumbra.String.Classes;
diff --git a/Penumbra/Mods/Editor/ModMerger.cs b/Penumbra/Mods/Editor/ModMerger.cs
new file mode 100644
index 00000000..29ed210e
--- /dev/null
+++ b/Penumbra/Mods/Editor/ModMerger.cs
@@ -0,0 +1,361 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using Dalamud.Interface.Internal.Notifications;
+using Dalamud.Utility;
+using OtterGui;
+using Penumbra.Api.Enums;
+using Penumbra.Communication;
+using Penumbra.Mods.Manager;
+using Penumbra.Services;
+using Penumbra.String.Classes;
+using Penumbra.UI.ModsTab;
+
+namespace Penumbra.Mods.Editor;
+
+public class ModMerger : IDisposable
+{
+ private readonly CommunicatorService _communicator;
+ private readonly ModOptionEditor _editor;
+ private readonly ModFileSystemSelector _selector;
+ private readonly DuplicateManager _duplicates;
+ private readonly ModManager _mods;
+ private readonly ModCreator _creator;
+
+ public Mod? MergeFromMod { get; private set; }
+ public Mod? MergeToMod;
+ public string OptionGroupName = "Merges";
+ public string OptionName = string.Empty;
+
+
+ private readonly Dictionary _fileToFile = new();
+ private readonly HashSet _createdDirectories = new();
+ public readonly HashSet SelectedOptions = new();
+
+ private int _createdGroup = -1;
+ private SubMod? _createdOption;
+ public Exception? Error { get; private set; }
+
+ public ModMerger(ModManager mods, ModOptionEditor editor, ModFileSystemSelector selector, DuplicateManager duplicates,
+ CommunicatorService communicator, ModCreator creator)
+ {
+ _editor = editor;
+ _selector = selector;
+ _duplicates = duplicates;
+ _communicator = communicator;
+ _creator = creator;
+ _mods = mods;
+ _selector.SelectionChanged += OnSelectionChange;
+ _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.Api);
+ }
+
+ public void Dispose()
+ {
+ _selector.SelectionChanged -= OnSelectionChange;
+ _communicator.ModPathChanged.Unsubscribe(OnModPathChange);
+ }
+
+ public IEnumerable ModsWithoutCurrent
+ => _mods.Where(m => m != MergeFromMod);
+
+ public bool CanMerge
+ => MergeToMod != null && MergeToMod != MergeFromMod && !MergeFromMod!.HasOptions;
+
+ public void Merge()
+ {
+ if (MergeFromMod == null || MergeToMod == null || MergeFromMod == MergeToMod)
+ return;
+
+ try
+ {
+ Error = null;
+ DataCleanup();
+ if (MergeFromMod.HasOptions)
+ MergeWithOptions();
+ else
+ MergeIntoOption(OptionGroupName, OptionName);
+ _duplicates.DeduplicateMod(MergeToMod.ModPath);
+ }
+ catch (Exception ex)
+ {
+ Error = ex;
+ Penumbra.ChatService.NotificationMessage(
+ $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}, cleaning up changes.:\n{ex}", "Failure",
+ NotificationType.Error);
+ FailureCleanup();
+ DataCleanup();
+ }
+ }
+
+ private void MergeWithOptions()
+ {
+ // Not supported
+ }
+
+ private void MergeIntoOption(string groupName, string optionName)
+ {
+ if (groupName.Length == 0 && optionName.Length == 0)
+ {
+ CopyFiles(MergeToMod!.ModPath);
+ MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), MergeToMod!.Default);
+ }
+ else if (groupName.Length * optionName.Length == 0)
+ {
+ return;
+ }
+
+ var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, GroupType.Multi, groupName);
+ if (groupCreated)
+ _createdGroup = groupIdx;
+ var (option, optionCreated) = _editor.FindOrAddOption(MergeToMod!, groupIdx, optionName);
+ if (optionCreated)
+ _createdOption = option;
+ var dir = ModCreator.NewOptionDirectory(MergeToMod!.ModPath, groupName);
+ if (!dir.Exists)
+ _createdDirectories.Add(dir.FullName);
+ dir = ModCreator.NewOptionDirectory(dir, optionName);
+ if (!dir.Exists)
+ _createdDirectories.Add(dir.FullName);
+ CopyFiles(dir);
+ MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), option);
+ }
+
+ private void MergeIntoOption(IEnumerable mergeOptions, SubMod option)
+ {
+ var redirections = option.FileData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ var swaps = option.FileSwapData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ var manips = option.ManipulationData.ToHashSet();
+ foreach (var originalOption in mergeOptions)
+ {
+ foreach (var manip in originalOption.Manipulations)
+ {
+ if (!manips.Add(manip))
+ throw new Exception(
+ $"Could not add meta manipulation {manip} from {originalOption.FullName} to {option.FullName} because another manipulation of the same data already exists in this option.");
+ }
+
+ foreach (var (swapA, swapB) in originalOption.FileSwaps)
+ {
+ if (!swaps.TryAdd(swapA, swapB))
+ throw new Exception(
+ $"Could not add file swap {swapB} -> {swapA} from {originalOption.FullName} to {option.FullName} because another swap of the key already exists.");
+ }
+
+ foreach (var (gamePath, relPath) in originalOption.Files)
+ {
+ if (!_fileToFile.TryGetValue(relPath.FullName, out var newFile))
+ throw new Exception(
+ $"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because the file does not exist in the new mod.");
+ if (!redirections.TryAdd(gamePath, new FullPath(newFile)))
+ throw new Exception(
+ $"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because a redirection for the game path already exists.");
+ }
+ }
+
+ _editor.OptionSetFiles(MergeToMod!, option.GroupIdx, option.OptionIdx, redirections);
+ _editor.OptionSetFileSwaps(MergeToMod!, option.GroupIdx, option.OptionIdx, swaps);
+ _editor.OptionSetManipulations(MergeToMod!, option.GroupIdx, option.OptionIdx, manips);
+ }
+
+ private void CopyFiles(DirectoryInfo directory)
+ {
+ directory = Directory.CreateDirectory(directory.FullName);
+ foreach (var file in MergeFromMod!.ModPath.EnumerateDirectories()
+ .Where(d => !d.IsHidden())
+ .SelectMany(FileExtensions.EnumerateNonHiddenFiles))
+ {
+ var path = Path.GetRelativePath(MergeFromMod.ModPath.FullName, file.FullName);
+ path = Path.Combine(directory.FullName, path);
+ var finalDir = Path.GetDirectoryName(path)!;
+ var dir = finalDir;
+ while (!dir.IsNullOrEmpty())
+ {
+ if (!Directory.Exists(dir))
+ _createdDirectories.Add(dir);
+ else
+ break;
+
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Directory.CreateDirectory(finalDir);
+ file.CopyTo(path);
+ Penumbra.Log.Verbose($"[Merger] Copied file {file.FullName} to {path}.");
+ _fileToFile.Add(file.FullName, path);
+ }
+ }
+
+ public void SplitIntoMod(string modName)
+ {
+ var mods = SelectedOptions.ToList();
+ if (mods.Count == 0)
+ return;
+
+ Error = null;
+ DirectoryInfo? dir = null;
+ Mod? result = null;
+ try
+ {
+ dir = _creator.CreateEmptyMod(_mods.BasePath, modName, $"Split off from {mods[0].ParentMod.Name}.");
+ if (dir == null)
+ throw new Exception($"Could not split off mods, unable to create new mod with name {modName}.");
+
+ _mods.AddMod(dir);
+ result = _mods[^1];
+ if (mods.Count == 1)
+ {
+ var files = CopySubModFiles(mods[0], dir);
+ _editor.OptionSetFiles(result, -1, 0, files);
+ _editor.OptionSetFileSwaps(result, -1, 0, mods[0].FileSwapData);
+ _editor.OptionSetManipulations(result, -1, 0, mods[0].ManipulationData);
+ }
+ else
+ {
+ foreach (var originalOption in mods)
+ {
+ var originalGroup = originalOption.ParentMod.Groups[originalOption.GroupIdx];
+ if (originalOption.IsDefault)
+ {
+ var files = CopySubModFiles(mods[0], dir);
+ _editor.OptionSetFiles(result, -1, 0, files);
+ _editor.OptionSetFileSwaps(result, -1, 0, mods[0].FileSwapData);
+ _editor.OptionSetManipulations(result, -1, 0, mods[0].ManipulationData);
+ }
+ else
+ {
+ var (group, groupIdx, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name);
+ var (option, _) = _editor.FindOrAddOption(result, groupIdx, originalOption.Name);
+ var folder = Path.Combine(dir.FullName, group.Name, option.Name);
+ var files = CopySubModFiles(originalOption, new DirectoryInfo(folder));
+ _editor.OptionSetFiles(result, groupIdx, option.OptionIdx, files);
+ _editor.OptionSetFileSwaps(result, groupIdx, option.OptionIdx, originalOption.FileSwapData);
+ _editor.OptionSetManipulations(result, groupIdx, option.OptionIdx, originalOption.ManipulationData);
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Error = e;
+ if (result != null)
+ _mods.DeleteMod(result);
+ else if (dir != null)
+ try
+ {
+ Directory.Delete(dir.FullName);
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error($"Could not clean up after failure to split options into new mod {modName}:\n{ex}");
+ }
+ }
+ }
+
+ private static Dictionary CopySubModFiles(SubMod option, DirectoryInfo newMod)
+ {
+ var ret = new Dictionary(option.FileData.Count);
+ var parentPath = ((Mod)option.ParentMod).ModPath.FullName;
+ foreach (var (path, file) in option.FileData)
+ {
+ var target = Path.GetRelativePath(parentPath, file.FullName);
+ target = Path.Combine(newMod.FullName, target);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.Copy(file.FullName, target);
+ Penumbra.Log.Verbose($"[Splitter] Copied file {file.FullName} to {target}.");
+ ret.Add(path, new FullPath(target));
+ }
+
+ return ret;
+ }
+
+ private void DataCleanup()
+ {
+ _fileToFile.Clear();
+ _createdDirectories.Clear();
+ _createdOption = null;
+ _createdGroup = -1;
+ }
+
+ private void FailureCleanup()
+ {
+ if (_createdGroup >= 0 && _createdGroup < MergeToMod!.Groups.Count)
+ _editor.DeleteModGroup(MergeToMod!, _createdGroup);
+ else if (_createdOption != null)
+ _editor.DeleteOption(MergeToMod!, _createdOption.GroupIdx, _createdOption.OptionIdx);
+
+ foreach (var dir in _createdDirectories)
+ {
+ if (!Directory.Exists(dir))
+ continue;
+
+ try
+ {
+ Directory.Delete(dir, true);
+ Penumbra.Log.Verbose($"[Merger] Deleted {dir}.");
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error(
+ $"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {dir}:\n{ex}");
+ }
+ }
+
+ foreach (var (_, file) in _fileToFile)
+ {
+ if (!File.Exists(file))
+ continue;
+
+ try
+ {
+ File.Delete(file);
+ Penumbra.Log.Verbose($"[Merger] Deleted {file}.");
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error(
+ $"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {file}:\n{ex}");
+ }
+ }
+ }
+
+ private void OnSelectionChange(Mod? oldSelection, Mod? newSelection, in ModFileSystemSelector.ModState state)
+ {
+ if (OptionGroupName == "Merges" && OptionName.Length == 0 || OptionName == oldSelection?.Name.Text)
+ OptionName = newSelection?.Name.Text ?? string.Empty;
+
+ if (MergeToMod == newSelection)
+ MergeToMod = null;
+
+ SelectedOptions.Clear();
+ MergeFromMod = newSelection;
+ }
+
+ private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2)
+ {
+ switch (type)
+ {
+ case ModPathChangeType.Deleted:
+ {
+ if (mod == MergeFromMod)
+ {
+ SelectedOptions.Clear();
+ MergeFromMod = null;
+ }
+
+ if (mod == MergeToMod)
+ MergeToMod = null;
+ break;
+ }
+ case ModPathChangeType.StartingReload:
+ SelectedOptions.Clear();
+ MergeFromMod = null;
+ MergeToMod = null;
+ break;
+ case ModPathChangeType.Reloaded:
+ MergeFromMod = _selector.Selected;
+ break;
+ }
+ }
+}
diff --git a/Penumbra/Mods/Manager/ModOptionEditor.cs b/Penumbra/Mods/Manager/ModOptionEditor.cs
index c30d90c3..a5e77c37 100644
--- a/Penumbra/Mods/Manager/ModOptionEditor.cs
+++ b/Penumbra/Mods/Manager/ModOptionEditor.cs
@@ -87,7 +87,7 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupRenamed, mod, groupIdx, -1, -1);
}
- /// Add a new mod, empty option group of the given type and name.
+ /// Add a new, empty option group of the given type and name.
public void AddModGroup(Mod mod, GroupType type, string newName)
{
if (!VerifyFileName(mod, null, newName, true))
@@ -110,6 +110,20 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, mod.Groups.Count - 1, -1, -1);
}
+ /// Add a new mod, empty option group of the given type and name if it does not exist already.
+ public (IModGroup, int, bool) FindOrAddModGroup(Mod mod, GroupType type, string newName)
+ {
+ var idx = mod.Groups.IndexOf(g => g.Name == newName);
+ if (idx >= 0)
+ return (mod.Groups[idx], idx, false);
+
+ AddModGroup(mod, type, newName);
+ if (mod.Groups[^1].Name != newName)
+ throw new Exception($"Could not create new mod group with name {newName}.");
+
+ return (mod.Groups[^1], mod.Groups.Count - 1, true);
+ }
+
/// Delete a given option group. Fires an event to prepare before actually deleting.
public void DeleteModGroup(Mod mod, int groupIdx)
{
@@ -242,6 +256,21 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionAdded, mod, groupIdx, group.Count - 1, -1);
}
+ /// Add a new empty option of the given name for the given group if it does not exist already.
+ public (SubMod, bool) FindOrAddOption(Mod mod, int groupIdx, string newName)
+ {
+ var group = mod.Groups[groupIdx];
+ var idx = group.IndexOf(o => o.Name == newName);
+ if (idx >= 0)
+ return ((SubMod)group[idx], false);
+
+ AddOption(mod, groupIdx, newName);
+ if (group[^1].Name != newName)
+ throw new Exception($"Could not create new option with name {newName} in {group.Name}.");
+
+ return ((SubMod)group[^1], true);
+ }
+
/// Add an existing option to a given group with a given priority.
public void AddOption(Mod mod, int groupIdx, ISubMod option, int priority = 0)
{
diff --git a/Penumbra/Mods/Manager/ModStorage.cs b/Penumbra/Mods/Manager/ModStorage.cs
index 5e8999d7..8421d6e2 100644
--- a/Penumbra/Mods/Manager/ModStorage.cs
+++ b/Penumbra/Mods/Manager/ModStorage.cs
@@ -2,9 +2,24 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using OtterGui.Classes;
+using OtterGui.Widgets;
namespace Penumbra.Mods.Manager;
+public class ModCombo : FilterComboCache
+{
+ protected override bool IsVisible(int globalIndex, LowerString filter)
+ => Items[globalIndex].Name.Contains(filter);
+
+ protected override string ToString(Mod obj)
+ => obj.Name.Text;
+
+ public ModCombo(Func> generator)
+ : base(generator)
+ { }
+}
+
public class ModStorage : IReadOnlyList
{
/// The actual list of mods.
diff --git a/Penumbra/Services/ServiceManager.cs b/Penumbra/Services/ServiceManager.cs
index 6d1bf710..3b9e3e31 100644
--- a/Penumbra/Services/ServiceManager.cs
+++ b/Penumbra/Services/ServiceManager.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using Dalamud.Plugin;
using Microsoft.Extensions.DependencyInjection;
using OtterGui.Classes;
@@ -13,6 +14,7 @@ using Penumbra.Interop.ResourceTree;
using Penumbra.Interop.Services;
using Penumbra.Meta;
using Penumbra.Mods;
+using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager;
using Penumbra.UI;
using Penumbra.UI.AdvancedWindow;
@@ -158,7 +160,8 @@ public static class ServiceManager
.AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton();
private static IServiceCollection AddModEditor(this IServiceCollection services)
=> services.AddSingleton()
@@ -168,6 +171,7 @@ public static class ServiceManager
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton();
private static IServiceCollection AddApi(this IServiceCollection services)
diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs
index ba6cc0aa..47d8f770 100644
--- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs
+++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs
@@ -35,6 +35,7 @@ public partial class ModEditWindow : Window, IDisposable
private readonly MetaFileManager _metaFileManager;
private readonly ActiveCollections _activeCollections;
private readonly StainService _stainService;
+ private readonly ModMergeTab _modMergeTab;
private Mod? _mod;
private Vector2 _iconSize = Vector2.Zero;
@@ -144,6 +145,7 @@ public partial class ModEditWindow : Window, IDisposable
DrawFileTab();
DrawMetaTab();
DrawSwapTab();
+ _modMergeTab.Draw();
DrawMissingFilesTab();
DrawDuplicatesTab();
DrawQuickImportTab();
@@ -311,7 +313,7 @@ public partial class ModEditWindow : Window, IDisposable
}
if (ImGui.Button("Delete and Redirect Duplicates"))
- _editor.Duplicates.DeleteDuplicates(_editor.Mod!, _editor.Option!, true);
+ _editor.Duplicates.DeleteDuplicates(_editor.Files, _editor.Mod!, _editor.Option!, true);
if (_editor.Duplicates.SavedSpace > 0)
{
@@ -419,7 +421,8 @@ public partial class ModEditWindow : Window, IDisposable
if (otherSwaps > 0)
{
ImGui.SameLine();
- ImGuiUtil.DrawTextButton($"There are {otherSwaps} file swaps configured in other options.", Vector2.Zero, ColorId.RedundantAssignment.Value());
+ ImGuiUtil.DrawTextButton($"There are {otherSwaps} file swaps configured in other options.", Vector2.Zero,
+ ColorId.RedundantAssignment.Value());
}
using var child = ImRaii.Child("##swaps", -Vector2.One, true);
@@ -509,7 +512,7 @@ public partial class ModEditWindow : Window, IDisposable
public ModEditWindow(PerformanceTracker performance, FileDialogService fileDialog, ItemSwapTab itemSwapTab, DataManager gameData,
Configuration config, ModEditor editor, ResourceTreeFactory resourceTreeFactory, MetaFileManager metaFileManager,
- StainService stainService, ActiveCollections activeCollections, UiBuilder uiBuilder, DalamudServices dalamud)
+ StainService stainService, ActiveCollections activeCollections, UiBuilder uiBuilder, DalamudServices dalamud, ModMergeTab modMergeTab)
: base(WindowBaseLabel)
{
_performance = performance;
@@ -520,6 +523,7 @@ public partial class ModEditWindow : Window, IDisposable
_stainService = stainService;
_activeCollections = activeCollections;
_dalamud = dalamud;
+ _modMergeTab = modMergeTab;
_fileDialog = fileDialog;
_materialTab = new FileEditor(this, gameData, config, _fileDialog, "Materials", ".mtrl",
() => _editor.Files.Mtrl, DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty,
diff --git a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs
new file mode 100644
index 00000000..c4cf1fb4
--- /dev/null
+++ b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs
@@ -0,0 +1,104 @@
+using System.Linq;
+using System.Numerics;
+using Dalamud.Interface;
+using ImGuiNET;
+using OtterGui;
+using OtterGui.Raii;
+using Penumbra.Mods.Editor;
+using Penumbra.Mods.Manager;
+using Penumbra.UI.Classes;
+using SixLabors.ImageSharp.ColorSpaces;
+
+namespace Penumbra.UI.AdvancedWindow;
+
+public class ModMergeTab
+{
+ private readonly ModMerger _modMerger;
+ private readonly ModCombo _modCombo;
+
+ private string _newModName = string.Empty;
+
+ public ModMergeTab(ModMerger modMerger)
+ {
+ _modMerger = modMerger;
+ _modCombo = new ModCombo(() => _modMerger.ModsWithoutCurrent.ToList());
+ }
+
+ public void Draw()
+ {
+ if (_modMerger.MergeFromMod == null)
+ return;
+
+ using var tab = ImRaii.TabItem("Merge Mods");
+ if (!tab)
+ return;
+
+ ImGui.AlignTextToFramePadding();
+ ImGui.TextUnformatted($"Merge {_modMerger.MergeFromMod.Name} into ");
+ ImGui.SameLine();
+ DrawCombo();
+
+ ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
+ ImGui.InputTextWithHint("##optionGroupInput", "Target Option Group", ref _modMerger.OptionGroupName, 64);
+ ImGui.SameLine();
+ ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
+ ImGui.InputTextWithHint("##optionInput", "Target Option Name", ref _modMerger.OptionName, 64);
+
+ if (ImGuiUtil.DrawDisabledButton("Merge", Vector2.Zero, string.Empty, !_modMerger.CanMerge))
+ _modMerger.Merge();
+
+ ImGui.Dummy(Vector2.One);
+ ImGui.Separator();
+ ImGui.Dummy(Vector2.One);
+ using (var table = ImRaii.Table("##options", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY, ImGui.GetContentRegionAvail() with { Y = 6 * ImGui.GetFrameHeightWithSpacing()}))
+ {
+ foreach (var (option, idx) in _modMerger.MergeFromMod.AllSubMods.WithIndex())
+ {
+ using var id = ImRaii.PushId(idx);
+ var selected = _modMerger.SelectedOptions.Contains(option);
+ ImGui.TableNextColumn();
+ if (ImGui.Checkbox("##check", ref selected))
+ {
+ if (selected)
+ _modMerger.SelectedOptions.Add(option);
+ else
+ _modMerger.SelectedOptions.Remove(option);
+ }
+
+ if (option.IsDefault)
+ {
+ ImGuiUtil.DrawTableColumn(option.FullName);
+ ImGui.TableNextColumn();
+ }
+ else
+ {
+ ImGuiUtil.DrawTableColumn(option.ParentMod.Groups[option.GroupIdx].Name);
+ ImGuiUtil.DrawTableColumn(option.Name);
+ }
+
+ ImGuiUtil.DrawTableColumn(option.FileData.Count.ToString());
+ ImGuiUtil.DrawTableColumn(option.FileSwapData.Count.ToString());
+ ImGuiUtil.DrawTableColumn(option.Manipulations.Count.ToString());
+
+ }
+ }
+ ImGui.InputTextWithHint("##newModInput", "New Mod Name...", ref _newModName, 64);
+ if (ImGuiUtil.DrawDisabledButton("Split Off", Vector2.Zero, string.Empty, _newModName.Length == 0 || _modMerger.SelectedOptions.Count == 0))
+ _modMerger.SplitIntoMod(_newModName);
+
+ if (_modMerger.Error != null)
+ {
+ ImGui.Separator();
+ ImGui.Dummy(Vector2.One);
+ using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder);
+ ImGuiUtil.TextWrapped(_modMerger.Error.ToString());
+ }
+ }
+
+ private void DrawCombo()
+ {
+ _modCombo.Draw("##ModSelection", _modCombo.CurrentSelection?.Name.Text ?? string.Empty, string.Empty,
+ 200 * ImGuiHelpers.GlobalScale, ImGui.GetTextLineHeight());
+ _modMerger.MergeToMod = _modCombo.CurrentSelection;
+ }
+}