From ac22227859200b4836f1a2bf8ea911a170403736 Mon Sep 17 00:00:00 2001 From: Stoia <23234609+StoiaCode@users.noreply.github.com> Date: Tue, 5 May 2026 16:08:48 +0200 Subject: [PATCH 01/40] Implement Container Peeking --- Penumbra/Config/Configuration.cs | 1 + Penumbra/Services/FileWatcher.cs | 376 ++++++++++++++++++++++-- Penumbra/UI/ManagementTab/CleanupTab.cs | 9 +- Penumbra/UI/Tabs/SettingsTab.cs | 3 + 4 files changed, 356 insertions(+), 33 deletions(-) diff --git a/Penumbra/Config/Configuration.cs b/Penumbra/Config/Configuration.cs index 71c81bb99..2efe4ddea 100644 --- a/Penumbra/Config/Configuration.cs +++ b/Penumbra/Config/Configuration.cs @@ -77,6 +77,7 @@ public bool EnableMods public bool DefaultTemporaryMode { get; set; } = false; public bool EnableDirectoryWatch { get; set; } = false; public bool EnableAutomaticModImport { get; set; } = false; + public bool EnableContainerPeeking { get; set; } = true; public bool AutoDismissModImportSuccessReports { get; set; } = true; public bool AlwaysShowDetailedModImport { get; set; } = false; public bool PreventExportLoopback { get; set; } = true; diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index b2eab1e1a..4c4ed2316 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -1,30 +1,49 @@ -using ImSharp; +using System.Diagnostics; +using ImSharp; using Luna; using Penumbra.Mods.Manager; +using SharpCompress.Archives; +using SharpCompress.Archives.Rar; +using SharpCompress.Archives.SevenZip; +using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; namespace Penumbra.Services; public sealed class FileWatcher : IDisposable, IService { - private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _ignored = new(StringComparer.OrdinalIgnoreCase); - private readonly ModImportManager _modImportManager; - private readonly MessageService _messageService; - private readonly Configuration _config; + private readonly ConcurrentDictionary _extractedArchives = new(StringComparer.OrdinalIgnoreCase); + private readonly ModImportManager _modImportManager; + private readonly MessageService _messageService; + private readonly Configuration _config; - private bool _pausedConsumer; - private FileSystemWatcher? _fsw; + private bool _pausedConsumer; + private FileSystemWatcher? _fsw; private CancellationTokenSource? _cts = new(); - private Task? _consumer; + private Task? _consumer; /// The time-to-live of ignore entries, in the same unit as , namely milliseconds. private const long IgnoreTimeToLive = 60000L; + private static readonly HashSet ModExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".pmp", ".pcp", ".ttmp", ".ttmp2" }; + + private static readonly HashSet ContainerExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".zip", ".rar", ".7z" }; + + /// + /// Subdirectory under the system temp directory used for extracted archive entries. + /// + private static readonly string TempRoot = Path.Combine(Path.GetTempPath(), "Penumbra-FileWatcher"); + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { _modImportManager = modImportManager; - _messageService = messageService; - _config = config; + _messageService = messageService; + _config = config; + + WipeTempRoot(); if (_config.EnableDirectoryWatch) { @@ -52,12 +71,38 @@ public void Toggle(bool value) } } + public void ToggleContainerPeeking(bool value) + { + if (_config.EnableContainerPeeking == value) + return; + + _config.EnableContainerPeeking = value; + _config.Save(); + + // Re-create the FSW so its filter list reflects the new state. + if (_config.EnableDirectoryWatch && _fsw is not null) + SetupFileWatcher(_config.WatchDirectory); + } + public void IgnoreFile(string fullPath) { if (_config.EnableDirectoryWatch) _ignored[fullPath] = Environment.TickCount64 + IgnoreTimeToLive; } + /// + /// Deletes every extracted archive directory tracked by this instance. Called from the debug drawer. + /// + public void CleanExtracted() + { + foreach (var dir in _extractedArchives.Keys.ToList()) + { + if (TryDeleteDirectory(dir)) + _extractedArchives.TryRemove(dir, out _); + } + Penumbra.Log.Verbose("[FileWatcher] Manual cleanup of extracted archives requested."); + } + private void EndFileWatcher() { if (_fsw is null) @@ -73,16 +118,23 @@ private void SetupFileWatcher(string directory) _fsw = new FileSystemWatcher { IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, - InternalBufferSize = 32 * 1024, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024, }; - // Only wake us for the exact patterns we care about + // Only wake us for the exact patterns we care about. _fsw.Filters.Add("*.pmp"); _fsw.Filters.Add("*.pcp"); _fsw.Filters.Add("*.ttmp"); _fsw.Filters.Add("*.ttmp2"); + if (_config.EnableContainerPeeking) + { + _fsw.Filters.Add("*.zip"); + _fsw.Filters.Add("*.rar"); + _fsw.Filters.Add("*.7z"); + } + _fsw.Created += OnPath; _fsw.Renamed += OnPath; UpdateDirectory(directory); @@ -127,15 +179,21 @@ public void UpdateDirectory(string newPath) } else { - _fsw.Path = newPath; + _fsw.Path = newPath; _fsw.EnableRaisingEvents = true; } } private void OnPath(object? sender, FileSystemEventArgs e) { - if (!_ignored.TryRemove(e.FullPath, out var expiresAtTickCount) || expiresAtTickCount <= Environment.TickCount64) - _pending.TryAdd(e.FullPath); + if (_ignored.TryRemove(e.FullPath, out var expiresAtTickCount) && expiresAtTickCount > Environment.TickCount64) + { + Penumbra.Log.Verbose($"[FileWatcher] FSW event for '{e.FullPath}' suppressed by ignore list."); + return; + } + + if (_pending.TryAdd(e.FullPath)) + Penumbra.Log.Verbose($"[FileWatcher] FSW event for '{e.FullPath}' enqueued."); } private async Task ConsumerLoopAsync(CancellationToken token) @@ -143,6 +201,7 @@ private async Task ConsumerLoopAsync(CancellationToken token) while (true) { GarbageCollectIgnored(); + var path = _pending.FirstOrDefault(); if (path is null || _pausedConsumer) { @@ -150,9 +209,23 @@ private async Task ConsumerLoopAsync(CancellationToken token) continue; } + var totalSw = Stopwatch.StartNew(); + Penumbra.Log.Verbose($"[FileWatcher] Picked up '{path}' from queue."); + try { - await ProcessOneAsync(path, token).ConfigureAwait(false); + var ext = Path.GetExtension(path); + if (ContainerExtensions.Contains(ext)) + { + if (_config.EnableContainerPeeking) + await ProcessContainerAsync(path, token).ConfigureAwait(false); + // else: peeking was toggled off after the event was queued; drop silently. + } + else if (ModExtensions.Contains(ext)) + { + await ProcessOneAsync(path, token).ConfigureAwait(false); + } + // else: extension we don't recognise (shouldn't happen given filters); drop silently. } catch (OperationCanceledException) { @@ -165,6 +238,7 @@ private async Task ConsumerLoopAsync(CancellationToken token) finally { _pending.TryRemove(path); + Penumbra.Log.Verbose($"[FileWatcher] Finished '{path}' in {totalSw.ElapsedMilliseconds}ms."); } } } @@ -180,44 +254,255 @@ private void GarbageCollectIgnored() private async Task ProcessOneAsync(string path, CancellationToken token) { - // Downloads often finish via rename; file may be locked briefly. - // Wait until it exists and is readable; also require two stable size checks. + if (!await WaitForStableAsync(path, token).ConfigureAwait(false)) + return; + + Penumbra.Log.Verbose($"[FileWatcher] Triggering import for '{path}'."); + TriggerImport(path); + } + + /// + /// Polls the file until two consecutive size readings match, indicating the writer is done. + /// Returns false if the file never settled within the retry budget or the token was canceled. + /// + private static async Task WaitForStableAsync(string path, CancellationToken token) + { const int maxTries = 40; - long lastLen = -1; + long lastLen = -1; + var sw = Stopwatch.StartNew(); for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++) { if (!File.Exists(path)) { - await Task.Delay(100, token); + await Task.Delay(100, token).ConfigureAwait(false); continue; } try { - var fi = new FileInfo(path); - var len = fi.Length; + var len = new FileInfo(path).Length; if (len > 0 && len == lastLen) { - if (_config.EnableAutomaticModImport) - _modImportManager.AddUnpack(path); - else - _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); - return; + Penumbra.Log.Verbose( + $"[FileWatcher] '{path}' stable at {len} bytes after {sw.ElapsedMilliseconds}ms ({i + 1} polls)."); + return true; } lastLen = len; } catch (IOException) { - Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); + Penumbra.Log.Debug("[FileWatcher] File is still being written to."); } catch (UnauthorizedAccessException) { - Penumbra.Log.Debug($"[FileWatcher] File is locked."); + Penumbra.Log.Debug("[FileWatcher] File is locked."); } - await Task.Delay(150, token); + await Task.Delay(150, token).ConfigureAwait(false); + } + + Penumbra.Log.Verbose($"[FileWatcher] '{path}' did not stabilize within {sw.ElapsedMilliseconds}ms."); + return false; + } + + private void TriggerImport(string path) + { + if (_config.EnableAutomaticModImport) + _modImportManager.AddUnpack(path); + else + _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); + } + + /// + /// Opens an archive, scans entries for mod files (by entry-name extension only), extracts matches + /// into a per-archive subdirectory of , then queues each extracted file via + /// . Per-archive subdirectory keeps the original filename intact for the UI. + /// + private async Task ProcessContainerAsync(string path, CancellationToken token) + { + if (!await WaitForStableAsync(path, token).ConfigureAwait(false)) + return; + + var ext = Path.GetExtension(path); + string? archiveDir = null; + var extractedNow = new List(); + + try + { + var openSw = Stopwatch.StartNew(); + Penumbra.Log.Verbose($"[FileWatcher] Opening container '{path}'."); + using var archive = OpenArchive(path, ext); + if (archive is null) + return; + Penumbra.Log.Verbose( + $"[FileWatcher] Opened container '{path}' in {openSw.ElapsedMilliseconds}ms."); + + var enumSw = Stopwatch.StartNew(); + var candidates = archive.Entries + .Where(e => !e.IsDirectory + && e.Key is { Length: > 0 } key + && ModExtensions.Contains(Path.GetExtension(key))) + .ToList(); + Penumbra.Log.Verbose( + $"[FileWatcher] Enumerated entries of '{path}' in {enumSw.ElapsedMilliseconds}ms; {candidates.Count} mod entries found."); + + // Silent ignore for archives that contain nothing relevant + if (candidates.Count == 0) + return; + + Directory.CreateDirectory(TempRoot); + archiveDir = Path.Combine(TempRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(archiveDir); + + foreach (var entry in candidates) + { + token.ThrowIfCancellationRequested(); + + if (entry.IsEncrypted) + { + Penumbra.Log.Warning( + $"[FileWatcher] Skipping encrypted entry '{entry.Key}' in container '{path}'."); + continue; + } + + var safeName = Path.GetFileName(entry.Key!); + var tempPath = Path.Combine(archiveDir, safeName); + + if (File.Exists(tempPath)) + { + Penumbra.Log.Warning( + $"[FileWatcher] Duplicate entry name '{safeName}' in container '{path}'; skipping later occurrence."); + continue; + } + + try + { + var extractSw = Stopwatch.StartNew(); + await using (var input = entry.OpenEntryStream()) + await using (var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 81920, useAsync: true)) + { + await input.CopyToAsync(output, 81920, token).ConfigureAwait(false); + } + + Penumbra.Log.Verbose( + $"[FileWatcher] Extracted '{safeName}' ({new FileInfo(tempPath).Length} bytes) in {extractSw.ElapsedMilliseconds}ms."); + extractedNow.Add(tempPath); + } + catch (OperationCanceledException) + { + TryDelete(tempPath); + throw; + } + catch (Exception ex) + { + Penumbra.Log.Warning( + $"[FileWatcher] Failed to extract '{entry.Key}' from '{path}': {ex.Message}"); + TryDelete(tempPath); + } + + // Yield between entries so a large archive doesn't monopolise the worker thread + // while the game is rendering. + await Task.Yield(); + } + + if (extractedNow.Count > 0) + _extractedArchives[archiveDir] = Environment.TickCount64; + else + TryDeleteDirectory(archiveDir); + } + catch (OperationCanceledException) + { + if (archiveDir is not null) + TryDeleteDirectory(archiveDir); + throw; + } + catch (Exception ex) + { + Penumbra.Log.Warning($"[FileWatcher] Failed to read container '{path}': {ex.Message}"); + if (archiveDir is not null) + TryDeleteDirectory(archiveDir); + return; + } + + // Hand each extracted file off as if it were a fresh drop. The freshly-closed stream means + // we can skip WaitForStableAsync here and call TriggerImport directly. + foreach (var tempPath in extractedNow) + { + try + { + Penumbra.Log.Verbose($"[FileWatcher] Triggering import for extracted '{tempPath}'."); + TriggerImport(tempPath); + } + catch (Exception ex) + { + Penumbra.Log.Warning( + $"[FileWatcher] Failed to trigger import for extracted file '{tempPath}': {ex.Message}"); + } + } + } + + private static IArchive? OpenArchive(string path, string extension) + => extension.ToLowerInvariant() switch + { + ".zip" => ZipArchive.Open(path), + ".rar" => RarArchive.Open(path), + ".7z" => SevenZipArchive.Open(path), + _ => null, + }; + + private static void WipeTempRoot() + { + try + { + if (Directory.Exists(TempRoot)) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(TempRoot)) + { + if (Directory.Exists(entry)) + TryDeleteDirectory(entry); + else + TryDelete(entry); + } + } + else + { + Directory.CreateDirectory(TempRoot); + } + } + catch (Exception ex) + { + Penumbra.Log.Warning($"[FileWatcher] Could not prepare temp root '{TempRoot}': {ex.Message}"); + } + } + + private static bool TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + return true; + } + catch + { + return false; + } + } + + private static bool TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + return true; + } + catch + { + return false; } } @@ -226,6 +511,8 @@ public void Dispose() { EndConsumerTask(); EndFileWatcher(); + // Cleanup of extracted files is intentionally skipped here. WipeTempRoot() on the next + // construction handles leftovers without blocking shutdown. } public sealed class FileWatcherDrawer(Configuration config, FileWatcher fileWatcher) : IUiService @@ -246,9 +533,15 @@ public void Draw() table.DrawColumn("Automatic Import"u8); table.DrawColumn($"{config.EnableAutomaticModImport}"); + table.DrawColumn("Container Peeking"u8); + table.DrawColumn($"{config.EnableContainerPeeking}"); + table.DrawColumn("Watched Directory"u8); table.DrawColumn(config.WatchDirectory); + table.DrawColumn("Temp Root"u8); + table.DrawColumn(TempRoot); + table.DrawColumn("File Watcher Path"u8); table.DrawColumn(fileWatcher._fsw?.Path ?? ""); @@ -274,9 +567,28 @@ public void Draw() table.DrawColumn(StringU8.Join((byte)'\n', fileWatcher._ignored.Select(entry => (entry.Value - Environment.TickCount64) switch { - <= 0 => $" {entry.Key}", + <= 0 => $" {entry.Key}", var ttl => $"<{ttl}ms> {entry.Key}", }).ToList())); + + table.DrawColumn("Extracted Archives"u8); + table.DrawColumn(StringU8.Join((byte)'\n', fileWatcher._extractedArchives.Select(entry => + { + var ageSec = (Environment.TickCount64 - entry.Value) / 1000; + var fileCount = TryCountFiles(entry.Key); + return $"<{ageSec}s, {fileCount} files> {entry.Key}"; + }).ToList())); + + table.DrawColumn("Clean Extracted"u8); + table.NextColumn(); + if (Im.SmallButton("Delete All Extracted"u8)) + fileWatcher.CleanExtracted(); + } + + private static int TryCountFiles(string dir) + { + try { return Directory.Exists(dir) ? Directory.EnumerateFiles(dir).Count() : 0; } + catch { return 0; } } } } diff --git a/Penumbra/UI/ManagementTab/CleanupTab.cs b/Penumbra/UI/ManagementTab/CleanupTab.cs index b33618e0a..2133e2070 100644 --- a/Penumbra/UI/ManagementTab/CleanupTab.cs +++ b/Penumbra/UI/ManagementTab/CleanupTab.cs @@ -4,7 +4,7 @@ namespace Penumbra.UI.ManagementTab; -public sealed class CleanupTab(CleanupService cleanup, Configuration config) : ITab +public sealed class CleanupTab(CleanupService cleanup, FileWatcher fileWatcher, Configuration config) : ITab { public ReadOnlySpan Label => "General Cleanup"u8; @@ -52,5 +52,12 @@ public void DrawContent() cleanup.CleanupAllUnusedSettings(); if (!enabled) Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} while clicking to remove settings."); + + if (ImEx.Button("Clear Extracted Archive Files"u8, default, + "Delete all temporary files extracted from archives by the File Watcher. Extracted files that have not yet been imported will be lost."u8, + !enabled || cleanup.IsRunning)) + fileWatcher.CleanExtracted(); + if (!enabled) + Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} while clicking to delete files."); } } diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 8cb903a4d..2f68b5cbd 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -624,6 +624,9 @@ private void DrawModHandlingSettings() Checkbox("Enable Directory Watcher"u8, "Enables a File Watcher that automatically listens for Mod files that enter a specified directory, causing Penumbra to open a popup to import these mods."u8, _config.EnableDirectoryWatch, _fileWatcher.Toggle); + Checkbox("Enable Archive Peeking"u8, + "Enables the File Watcher to Peek inside .rar .zip and .7z archives, extracting mods inside and causing Penumbra to open a popup to import these mods."u8, + _config.EnableContainerPeeking, _fileWatcher.ToggleContainerPeeking); Checkbox("Enable Fully Automatic Import"u8, "Uses the File Watcher in order to skip the query popup and automatically import any new mods."u8, _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); From 91d63c437fc1d1de0770b328ff453df4c85b5bd8 Mon Sep 17 00:00:00 2001 From: Stoia <23234609+StoiaCode@users.noreply.github.com> Date: Tue, 5 May 2026 16:16:37 +0200 Subject: [PATCH 02/40] Fix Spacing --- Penumbra/Services/FileWatcher.cs | 39 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index 4c4ed2316..ea1034fae 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using ImSharp; using Luna; using Penumbra.Mods.Manager; @@ -11,17 +10,17 @@ namespace Penumbra.Services; public sealed class FileWatcher : IDisposable, IService { - private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentSet _pending = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _ignored = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _extractedArchives = new(StringComparer.OrdinalIgnoreCase); - private readonly ModImportManager _modImportManager; - private readonly MessageService _messageService; - private readonly Configuration _config; + private readonly ModImportManager _modImportManager; + private readonly MessageService _messageService; + private readonly Configuration _config; - private bool _pausedConsumer; - private FileSystemWatcher? _fsw; + private bool _pausedConsumer; + private FileSystemWatcher? _fsw; private CancellationTokenSource? _cts = new(); - private Task? _consumer; + private Task? _consumer; /// The time-to-live of ignore entries, in the same unit as , namely milliseconds. private const long IgnoreTimeToLive = 60000L; @@ -40,8 +39,8 @@ public sealed class FileWatcher : IDisposable, IService public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) { _modImportManager = modImportManager; - _messageService = messageService; - _config = config; + _messageService = messageService; + _config = config; WipeTempRoot(); @@ -118,8 +117,8 @@ private void SetupFileWatcher(string directory) _fsw = new FileSystemWatcher { IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, - InternalBufferSize = 32 * 1024, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024, }; // Only wake us for the exact patterns we care about. @@ -179,7 +178,7 @@ public void UpdateDirectory(string newPath) } else { - _fsw.Path = newPath; + _fsw.Path = newPath; _fsw.EnableRaisingEvents = true; } } @@ -268,8 +267,8 @@ private async Task ProcessOneAsync(string path, CancellationToken token) private static async Task WaitForStableAsync(string path, CancellationToken token) { const int maxTries = 40; - long lastLen = -1; - var sw = Stopwatch.StartNew(); + long lastLen = -1; + var sw = Stopwatch.StartNew(); for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++) { @@ -325,9 +324,9 @@ private async Task ProcessContainerAsync(string path, CancellationToken token) if (!await WaitForStableAsync(path, token).ConfigureAwait(false)) return; - var ext = Path.GetExtension(path); + var ext = Path.GetExtension(path); string? archiveDir = null; - var extractedNow = new List(); + var extractedNow = new List(); try { @@ -449,7 +448,7 @@ private async Task ProcessContainerAsync(string path, CancellationToken token) { ".zip" => ZipArchive.Open(path), ".rar" => RarArchive.Open(path), - ".7z" => SevenZipArchive.Open(path), + ".7z" => SevenZipArchive.Open(path), _ => null, }; @@ -567,14 +566,14 @@ public void Draw() table.DrawColumn(StringU8.Join((byte)'\n', fileWatcher._ignored.Select(entry => (entry.Value - Environment.TickCount64) switch { - <= 0 => $" {entry.Key}", + <= 0 => $" {entry.Key}", var ttl => $"<{ttl}ms> {entry.Key}", }).ToList())); table.DrawColumn("Extracted Archives"u8); table.DrawColumn(StringU8.Join((byte)'\n', fileWatcher._extractedArchives.Select(entry => { - var ageSec = (Environment.TickCount64 - entry.Value) / 1000; + var ageSec = (Environment.TickCount64 - entry.Value) / 1000; var fileCount = TryCountFiles(entry.Key); return $"<{ageSec}s, {fileCount} files> {entry.Key}"; }).ToList())); From 1c3d54851d619b0c8a5f9acda410497d89f59da5 Mon Sep 17 00:00:00 2001 From: Stoia <23234609+StoiaCode@users.noreply.github.com> Date: Tue, 5 May 2026 16:29:01 +0200 Subject: [PATCH 03/40] Remove redundant FileInfo call --- Penumbra/Services/FileWatcher.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index ea1034fae..756ff6457 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -379,15 +379,17 @@ private async Task ProcessContainerAsync(string path, CancellationToken token) try { var extractSw = Stopwatch.StartNew(); + long bytesWritten; await using (var input = entry.OpenEntryStream()) await using (var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true)) { await input.CopyToAsync(output, 81920, token).ConfigureAwait(false); + bytesWritten = output.Position; } Penumbra.Log.Verbose( - $"[FileWatcher] Extracted '{safeName}' ({new FileInfo(tempPath).Length} bytes) in {extractSw.ElapsedMilliseconds}ms."); + $"[FileWatcher] Extracted '{safeName}' ({bytesWritten} bytes) in {extractSw.ElapsedMilliseconds}ms."); extractedNow.Add(tempPath); } catch (OperationCanceledException) From 59b25f32f895eb80163ce9653fc9c09e2a011619 Mon Sep 17 00:00:00 2001 From: Exter-N Date: Sat, 9 May 2026 08:00:03 +0200 Subject: [PATCH 04/40] Make mod imports awaitable --- Penumbra/Import/TexToolsImport.cs | 21 +++++++++-------- Penumbra/Mods/Manager/ModImportManager.cs | 28 ++++++++++++++--------- Penumbra/Mods/Manager/ModImportResult.cs | 3 +++ Penumbra/Services/FileWatcher.cs | 10 +++++--- Penumbra/Services/InstallNotification.cs | 18 +++++++++++++-- Penumbra/UI/GlobalModImporter.cs | 2 +- Penumbra/UI/Tabs/Debug/DebugTab.cs | 2 +- 7 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 Penumbra/Mods/Manager/ModImportResult.cs diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs index 7981b4472..8061a5224 100644 --- a/Penumbra/Import/TexToolsImport.cs +++ b/Penumbra/Import/TexToolsImport.cs @@ -26,8 +26,8 @@ public partial class TexToolsImporter : IDisposable private readonly CancellationTokenSource _cancellation = new(); private readonly CancellationToken _token; - public ImporterState State { get; private set; } - public readonly List<(FileInfo File, DirectoryInfo? Mod, Exception? Error)> ExtractedMods; + public ImporterState State { get; private set; } + public readonly List ExtractedMods; private readonly Configuration _config; private readonly DuplicateManager _duplicates; @@ -37,8 +37,9 @@ public partial class TexToolsImporter : IDisposable private readonly MigrationManager _migrationManager; public TexToolsImporter(int count, IEnumerable modPackFiles, Action handler, - Configuration config, DuplicateManager duplicates, ModNormalizer modNormalizer, ModManager modManager, FileCompactor compactor, - MigrationManager migrationManager, TexToolsImporter? previous) + TaskCompletionSource taskCompletionSource, Configuration config, DuplicateManager duplicates, + ModNormalizer modNormalizer, ModManager modManager, FileCompactor compactor, MigrationManager migrationManager, + TexToolsImporter? previous) { if (previous is not null) { @@ -58,7 +59,7 @@ public TexToolsImporter(int count, IEnumerable modPackFiles, Action(count + _previousModPackCount); + ExtractedMods = new List(count + _previousModPackCount); _token = _cancellation.Token; if (previous is not null) ExtractedMods.AddRange(previous.ExtractedMods); @@ -68,7 +69,9 @@ public TexToolsImporter(int count, IEnumerable modPackFiles, Action { taskCompletionSource.SetResult(ExtractedMods.Skip(_previousModPackCount).ToArray()); }, + TaskScheduler.Default); } private void CloseStreams() @@ -100,14 +103,14 @@ private void ImportFiles() _currentModDirectory = null; if (_token.IsCancellationRequested) { - ExtractedMods.Add((file, null, new TaskCanceledException("Task canceled by user."))); + ExtractedMods.Add(new ModImportResult(file, null, new TaskCanceledException("Task canceled by user."))); continue; } try { var directory = VerifyVersionAndImport(file); - ExtractedMods.Add((file, directory, null)); + ExtractedMods.Add(new ModImportResult(file, directory, null)); if (_config.AutoDeduplicateOnImport) { State = ImporterState.DeduplicatingFiles; @@ -116,7 +119,7 @@ private void ImportFiles() } catch (Exception e) { - ExtractedMods.Add((file, _currentModDirectory, e)); + ExtractedMods.Add(new ModImportResult(file, _currentModDirectory, e)); _currentNumOptions = 0; _currentOptionIdx = 0; _currentFileIdx = 0; diff --git a/Penumbra/Mods/Manager/ModImportManager.cs b/Penumbra/Mods/Manager/ModImportManager.cs index 006fb473d..13ad17308 100644 --- a/Penumbra/Mods/Manager/ModImportManager.cs +++ b/Penumbra/Mods/Manager/ModImportManager.cs @@ -16,7 +16,7 @@ public class ModImportManager( FileCompactor compactor) : IDisposable, IService { private readonly Dictionary _uniqueModsToUnpack = new(StringComparer.OrdinalIgnoreCase); - internal readonly Queue> ModsToUnpack = new(); + internal readonly Queue ModsToUnpack = new(); /// Mods need to be added thread-safely outside of iteration. private readonly ConcurrentQueue _modsToAdd = new(); @@ -31,14 +31,14 @@ public void TryUnpacking() if (Importing && _import!.State is not ImporterState.Done) return; - List newMods; + UnpackRequest newMods; lock (ModsToUnpack) { - if (!ModsToUnpack.TryDequeue(out newMods!)) + if (!ModsToUnpack.TryDequeue(out newMods)) return; } - var files = newMods.Where(s => + var files = newMods.Paths.Where(s => { if (File.Exists(s)) return true; @@ -50,10 +50,13 @@ public void TryUnpacking() Penumbra.Log.Debug($"Unpacking mods: {string.Join("\n\t", files.Select(f => f.FullName))}."); if (files.Length == 0) + { + newMods.TaskCompletionSource.SetResult([]); return; + } - _import = new TexToolsImporter(files.Length, files, AddNewMod, config, duplicates, modNormalizer, modManager, compactor, - migrationManager, _import); + _import = new TexToolsImporter(files.Length, files, AddNewMod, newMods.TaskCompletionSource, config, duplicates, modNormalizer, + modManager, compactor, migrationManager, _import); } public bool Importing @@ -65,10 +68,7 @@ public bool IsImporting([NotNullWhen(true)] out TexToolsImporter? importer) return _import != null; } - public void AddUnpack(IEnumerable paths) - => AddUnpack(paths.ToList()); - - public void AddUnpack(params List paths) + public Task AddUnpack(params List paths) { lock (ModsToUnpack) { @@ -95,9 +95,13 @@ public void AddUnpack(params List paths) if (paths.Count > 0) { Penumbra.Log.Debug($"Adding mods to install: {string.Join("\n\t", paths)}"); - ModsToUnpack.Enqueue(paths); + var tcs = new TaskCompletionSource(); + ModsToUnpack.Enqueue(new UnpackRequest(paths, tcs)); + return tcs.Task; } } + + return Task.FromResult(Array.Empty()); } public void ClearImport() @@ -156,4 +160,6 @@ private void AddNewMod(FileInfo file, DirectoryInfo? dir, Exception? error) _modsToAdd.Enqueue(dir); } } + + internal readonly record struct UnpackRequest(List Paths, TaskCompletionSource TaskCompletionSource); } diff --git a/Penumbra/Mods/Manager/ModImportResult.cs b/Penumbra/Mods/Manager/ModImportResult.cs new file mode 100644 index 000000000..5350544cb --- /dev/null +++ b/Penumbra/Mods/Manager/ModImportResult.cs @@ -0,0 +1,3 @@ +namespace Penumbra.Mods.Manager; + +public readonly record struct ModImportResult(FileInfo File, DirectoryInfo? Mod, Exception? Error); diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs index 756ff6457..638996e00 100644 --- a/Penumbra/Services/FileWatcher.cs +++ b/Penumbra/Services/FileWatcher.cs @@ -306,12 +306,16 @@ private static async Task WaitForStableAsync(string path, CancellationToke return false; } - private void TriggerImport(string path) + private Task TriggerImport(string path) { if (_config.EnableAutomaticModImport) - _modImportManager.AddUnpack(path); + return _modImportManager.AddUnpack(path); else - _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); + { + var tcs = new TaskCompletionSource(); + _messageService.AddMessage(new InstallNotification(_modImportManager, path, tcs), false); + return tcs.Task; + } } /// diff --git a/Penumbra/Services/InstallNotification.cs b/Penumbra/Services/InstallNotification.cs index 71c6884f1..44c665ca0 100644 --- a/Penumbra/Services/InstallNotification.cs +++ b/Penumbra/Services/InstallNotification.cs @@ -6,8 +6,11 @@ namespace Penumbra.Services; -public class InstallNotification(ModImportManager modImportManager, string filePath) : Luna.IMessage +public class InstallNotification(ModImportManager modImportManager, string filePath, TaskCompletionSource tcs) + : Luna.INotificationAwareMessage { + private bool _reportCancellationOnDismissal = true; + public NotificationType NotificationType => NotificationType.Info; @@ -37,7 +40,9 @@ public void OnNotificationActions(INotificationDrawArgs args) var buttonSize = new Vector2((region.X - Im.Style.ItemSpacing.X) / 2, 0); if (Im.Button("Install"u8, buttonSize)) { - modImportManager.AddUnpack(filePath); + _reportCancellationOnDismissal = false; + modImportManager.AddUnpack(filePath) + .ContinueWith(tcs.SetFromTask); args.Notification.DismissNow(); } @@ -45,4 +50,13 @@ public void OnNotificationActions(INotificationDrawArgs args) if (Im.Button("Ignore"u8, buttonSize)) args.Notification.DismissNow(); } + + public void OnNotificationCreated(IActiveNotification notification) + { + notification.Dismiss += _ => + { + if (_reportCancellationOnDismissal) + tcs.SetResult([]); + }; + } } diff --git a/Penumbra/UI/GlobalModImporter.cs b/Penumbra/UI/GlobalModImporter.cs index 2e63cd238..4c99ecb12 100644 --- a/Penumbra/UI/GlobalModImporter.cs +++ b/Penumbra/UI/GlobalModImporter.cs @@ -46,7 +46,7 @@ public void Dispose() } private void ImportFiles(IReadOnlyList files, IReadOnlyList _) - => _importManager.AddUnpack(files.Where(f => ValidModExtensions.Contains(Path.GetExtension(f)))); + => _importManager.AddUnpack(files.Where(f => ValidModExtensions.Contains(Path.GetExtension(f))).ToList()); private static bool ValidExtension(IDragDropManager manager) => manager.Extensions.Any(ValidModExtensions.Contains); diff --git a/Penumbra/UI/Tabs/Debug/DebugTab.cs b/Penumbra/UI/Tabs/Debug/DebugTab.cs index c7146ae6d..9706e683d 100644 --- a/Penumbra/UI/Tabs/Debug/DebugTab.cs +++ b/Penumbra/UI/Tabs/Debug/DebugTab.cs @@ -384,7 +384,7 @@ private void DrawDebugTabGeneral() { foreach (var (index, batch) in _modImporter.ModsToUnpack.Index()) { - foreach (var mod in batch) + foreach (var mod in batch.Paths) table.DrawDataPair($"{index}", mod); } } From 9ed3c19e9ba9a24b6bdcd68ed300a39db1e1121d Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Sat, 16 May 2026 22:18:28 +0200 Subject: [PATCH 05/40] Current state. --- Luna | 2 +- Penumbra/Communication/ModOptionChanged.cs | 7 +- Penumbra/Mods/DebugUtilities.cs | 180 ++++++++++++++++++ Penumbra/Mods/Groups/CombiningModGroup.cs | 3 +- Penumbra/Mods/Groups/IModGroup.cs | 41 ++-- Penumbra/Mods/Groups/ImcModGroup.cs | 7 +- Penumbra/Mods/Groups/ModGroupEditUpdater.cs | 109 ----------- Penumbra/Mods/Groups/ModSaveGroup.cs | 1 - Penumbra/Mods/Groups/ModSettingContext.cs | 49 +++-- Penumbra/Mods/Groups/MultiModGroup.cs | 3 +- Penumbra/Mods/Groups/SingleModGroup.cs | 3 +- Penumbra/Mods/Manager/ModDataEditor.cs | 1 + .../OptionEditor/CombiningModGroupEditor.cs | 2 +- .../Manager/OptionEditor/ImcModGroupEditor.cs | 16 +- .../Manager/OptionEditor/ModGroupEditor.cs | 33 ++-- .../Manager/OptionEditor/ModOptionEditor.cs | 12 +- .../OptionEditor/MultiModGroupEditor.cs | 4 +- .../OptionEditor/SingleModGroupEditor.cs | 2 +- Penumbra/Mods/Mod.cs | 6 +- Penumbra/Mods/ModCreator.cs | 3 + Penumbra/Mods/ModMeta.cs | 23 ++- Penumbra/Mods/SubMods/CombiningSubMod.cs | 1 + Penumbra/Mods/SubMods/IModOption.cs | 21 +- Penumbra/Mods/SubMods/ImcSubMod.cs | 6 +- Penumbra/Mods/SubMods/OptionSubMod.cs | 6 +- Penumbra/UI/Combos/Combos.cs | 2 +- Penumbra/UI/ModsTab/DescriptionEditPopup.cs | 63 ++---- Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs | 92 +++++---- .../UI/ModsTab/Groups/ModGroupEditDrawer.cs | 7 +- .../UI/ModsTab/Groups/ModSettingsCache.cs | 90 ++++++--- Penumbra/UI/ModsTab/LayoutEditPopup.cs | 63 ++++++ Penumbra/UI/ModsTab/ModPanelEditTab.cs | 2 + 32 files changed, 525 insertions(+), 335 deletions(-) create mode 100644 Penumbra/Mods/DebugUtilities.cs delete mode 100644 Penumbra/Mods/Groups/ModGroupEditUpdater.cs create mode 100644 Penumbra/UI/ModsTab/LayoutEditPopup.cs diff --git a/Luna b/Luna index 91417405d..bc9e6636f 160000 --- a/Luna +++ b/Luna @@ -1 +1 @@ -Subproject commit 91417405d327f89d4bb3a802fea2ab185f1e9962 +Subproject commit bc9e6636f8c9b88e8b4ec75e2d22f4dc2560d08f diff --git a/Penumbra/Communication/ModOptionChanged.cs b/Penumbra/Communication/ModOptionChanged.cs index ba5b25838..6143782cb 100644 --- a/Penumbra/Communication/ModOptionChanged.cs +++ b/Penumbra/Communication/ModOptionChanged.cs @@ -28,9 +28,6 @@ public enum Priority /// CollectionStorage = 100, - - /// - ModGroupEditUpdater = 1000, } /// The arguments for a ModOptionChanged event. @@ -39,7 +36,7 @@ public enum Priority /// The changed group inside the mod, if any. /// The changed option inside the group or null if it does not concern a specific option. /// The changed data container inside the group or null if it does not concern a specific data container. - /// The old name of the group or option for and . + /// The GUID of the changed object if available, empty otherwise. /// The index of the group or option moved or deleted from. public readonly record struct Arguments( ModOptionChangeType Type, @@ -47,6 +44,6 @@ public readonly record struct Arguments( IModGroup? Group, IModOption? Option, IModDataContainer? Container, - string? OldName, + Guid Id, int DeletedIndex); } diff --git a/Penumbra/Mods/DebugUtilities.cs b/Penumbra/Mods/DebugUtilities.cs new file mode 100644 index 000000000..30d5e7013 --- /dev/null +++ b/Penumbra/Mods/DebugUtilities.cs @@ -0,0 +1,180 @@ +using Luna; +using Penumbra.Mods.Groups; +using Penumbra.Mods.SubMods; +using Penumbra.Util; + +namespace Penumbra.Mods; + +public static class DebugUtilities +{ + /// Compare two mods for exact equality in all their parsed properties. + public static bool CompareMod(Mod lhs, Mod rhs, bool checkLocal) + { + // Meta + // Do not check StableIdentifier due to NewGuid(). + if (lhs.Name != rhs.Name) + return false; + if (lhs.Author != rhs.Author) + return false; + if (lhs.Description != rhs.Description) + return false; + if (lhs.Version != rhs.Version) + return false; + if (lhs.Website != rhs.Website) + return false; + if (lhs.Image != rhs.Image) + return false; + if (!lhs.ModTags.SequenceEqual(rhs.ModTags)) + return false; + if (!lhs.DefaultPreferredItems.SetEquals(rhs.DefaultPreferredItems)) + return false; + if (lhs.RequiredFeatures != rhs.RequiredFeatures) + return false; + + // Local + if (checkLocal) + { + if (lhs.Path.Folder != rhs.Path.Folder) + return false; // Skip node because duplication. + if (lhs.ImportDate != rhs.ImportDate) + return false; + if (lhs.LastConfigEdit != rhs.LastConfigEdit) + return false; + if (lhs.IgnoreLastConfig != rhs.IgnoreLastConfig) + return false; + if (!lhs.LocalTags.SequenceEqual(rhs.LocalTags)) + return false; + if (lhs.Note != rhs.Note) + return false; + if (!lhs.PreferredChangedItems.SetEquals(rhs.PreferredChangedItems)) + return false; + if (lhs.Favorite != rhs.Favorite) + return false; + } + + // Data + if (!CheckContainer(lhs.Default, rhs.Default)) + return false; + + if (lhs.Groups.Count != rhs.Groups.Count) + return false; + + foreach (var (lGroup, rGroup) in lhs.Groups.Zip(rhs.Groups)) + { + if (!CheckGroup(lGroup, rGroup)) + return false; + } + + return true; + } + + private static bool CheckSubObject(IModObject lhs, IModObject rhs) + { + // Skipped ID because of NewGuid(). + if (lhs.Name != rhs.Name) + return false; + if (lhs.Description != rhs.Description) + return false; + if (lhs.Layout != rhs.Layout) + return false; + if (!EqualityComparer>.Default.Equals(lhs.Condition, rhs.Condition)) + return false; + + return true; + } + + private static bool CheckGroup(IModGroup lGroup, IModGroup rGroup) + { + if (lGroup.Type != rGroup.Type) + return false; + if (!CheckSubObject(lGroup, rGroup)) + return false; + + if (lGroup.DefaultSettings != rGroup.DefaultSettings) + return false; + if (lGroup.Image != rGroup.Image) + return false; + if (lGroup.Page != rGroup.Page) + return false; + if (lGroup.Priority != rGroup.Priority) + return false; + if (lGroup.DefaultSettings != rGroup.DefaultSettings) + return false; + if (lGroup.ParentSetting != rGroup.ParentSetting) + return false; + + if (lGroup is ImcModGroup l) + { + if (rGroup is not ImcModGroup r) + return false; + + if (l.AllVariants != r.AllVariants) + return false; + if (l.CanBeDisabled != r.CanBeDisabled) + return false; + if (l.OnlyAttributes != r.OnlyAttributes) + return false; + if (!l.Identifier.Equals(r.Identifier)) + return false; + if (!l.DefaultEntry.Equals(r.DefaultEntry)) + return false; + } + + if (lGroup.Options.Count != rGroup.Options.Count) + return false; + if (lGroup.DataContainers.Count != rGroup.DataContainers.Count) + return false; + + foreach (var (lOption, rOption) in lGroup.Options.Zip(rGroup.Options)) + { + if (!CheckOption(lOption, rOption)) + return false; + } + + foreach (var (lContainer, rContainer) in lGroup.DataContainers.Zip(rGroup.DataContainers)) + { + if (!CheckContainer(lContainer, rContainer)) + return false; + } + + return true; + } + + private static bool CheckOption(IModOption lhs, IModOption rhs) + { + if (!CheckSubObject(lhs, rhs)) + return false; + + switch (lhs, rhs) + { + case (MultiSubMod l, MultiSubMod r): return l.Priority == r.Priority; + case (ImcSubMod l, ImcSubMod r): + if (l.IsDisableSubMod != r.IsDisableSubMod) + return false; + + return l.AttributeMask == r.AttributeMask; + } + + return true; + } + + private static bool CheckContainer(IModDataContainer lhs, IModDataContainer rhs) + { + if (!lhs.Files.SetEquals(rhs.Files)) + return false; + if (!lhs.FileSwaps.SetEquals(rhs.FileSwaps)) + return false; + if (!lhs.Manipulations.Equals(rhs.Manipulations)) + return false; + + if (lhs is CombinedDataContainer l) + { + if (rhs is not CombinedDataContainer r) + return false; + + return l.Name == r.Name; + } + + return true; + } +} diff --git a/Penumbra/Mods/Groups/CombiningModGroup.cs b/Penumbra/Mods/Groups/CombiningModGroup.cs index ac71a0cf2..2f79d4bfb 100644 --- a/Penumbra/Mods/Groups/CombiningModGroup.cs +++ b/Penumbra/Mods/Groups/CombiningModGroup.cs @@ -23,6 +23,7 @@ public GroupDrawBehaviour Behaviour => GroupDrawBehaviour.MultiSelection; public Mod Mod { get; } + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "Group"; public string Description { get; set; } = string.Empty; public string Image { get; set; } = string.Empty; @@ -30,7 +31,7 @@ public GroupDrawBehaviour Behaviour public int Page { get; set; } public Setting DefaultSettings { get; set; } public ModSettingsLayout Layout { get; set; } - public ParentSetting ParentSetting { get; set; } = ParentSetting.None; + public Guid ParentSetting { get; set; } = Guid.Empty; public ICondition? Condition { get; set; } public readonly List OptionData = []; public List Data { get; private set; } diff --git a/Penumbra/Mods/Groups/IModGroup.cs b/Penumbra/Mods/Groups/IModGroup.cs index 95ddc54fd..0d3d9ccc3 100644 --- a/Penumbra/Mods/Groups/IModGroup.cs +++ b/Penumbra/Mods/Groups/IModGroup.cs @@ -21,37 +21,26 @@ public enum GroupDrawBehaviour MultiSelection, } -public readonly struct ParentSetting +public interface IModObject { - public readonly string? Group; - public readonly string? Option; + public Mod Mod { get; } + public IModGroup Group { get; } + public Guid Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public ModSettingsLayout Layout { get; set; } + public ICondition? Condition { get; set; } - public static readonly ParentSetting None = new(null); - - public bool IsNone - => Group is null; - - public ParentSetting(string? group = null) - { - Group = group; - Option = null; - } - - public ParentSetting(string group, string? option = null) - { - Group = group; - Option = option; - } + public int GetIndex(); } -public interface IModGroup +public interface IModGroup : IModObject { public const int MaxMultiOptions = 32; public const int MaxCombiningOptions = 8; - public Mod Mod { get; } - public string Name { get; set; } - public string Description { get; set; } + IModGroup IModObject.Group + => this; /// Unused in Penumbra but for better TexTools interop. public string Image { get; set; } @@ -65,9 +54,7 @@ public interface IModGroup public Setting DefaultSettings { get; set; } - public ModSettingsLayout Layout { get; set; } - public ParentSetting ParentSetting { get; set; } - public ICondition? Condition { get; set; } + public Guid ParentSetting { get; set; } public FullPath? FindBestMatch(Utf8GamePath gamePath); public IModOption? AddOption(string name, string description = ""); @@ -76,8 +63,6 @@ public interface IModGroup public IReadOnlyList DataContainers { get; } public bool IsOption { get; } - public int GetIndex(); - public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer); public void AddData(ModSettings settings, Setting setting, Dictionary redirections, MetaDictionary manipulations); diff --git a/Penumbra/Mods/Groups/ImcModGroup.cs b/Penumbra/Mods/Groups/ImcModGroup.cs index acc188db0..4713dbd5d 100644 --- a/Penumbra/Mods/Groups/ImcModGroup.cs +++ b/Penumbra/Mods/Groups/ImcModGroup.cs @@ -17,6 +17,7 @@ namespace Penumbra.Mods.Groups; public class ImcModGroup(Mod mod) : IModGroup { public Mod Mod { get; } = mod; + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "Option"; public string Description { get; set; } = string.Empty; public string Image { get; set; } = string.Empty; @@ -31,7 +32,7 @@ public GroupDrawBehaviour Behaviour public int Page { get; set; } public Setting DefaultSettings { get; set; } = Setting.Zero; public ModSettingsLayout Layout { get; set; } - public ParentSetting ParentSetting { get; set; } = ParentSetting.None; + public Guid ParentSetting { get; set; } = Guid.Empty; public ICondition? Condition { get; set; } public ImcIdentifier Identifier; @@ -239,7 +240,7 @@ private bool IsDisabled(in ModSettingContext context, Setting setting) var idx = OptionData.IndexOf(m => m.IsDisableSubMod); if (idx >= 0) { - if (OptionData[idx].Condition is not {} condition || condition.Evaluate(context)) + if (OptionData[idx].Condition is not { } condition || condition.Evaluate(context)) return setting.HasFlag(idx); return false; @@ -258,7 +259,7 @@ private ushort GetCurrentMask(in ModSettingContext context, Setting setting) continue; var option = OptionData[i]; - if(option.Condition is null || option.Condition.Evaluate(context)) + if (option.Condition is null || option.Condition.Evaluate(context)) mask ^= option.AttributeMask; } diff --git a/Penumbra/Mods/Groups/ModGroupEditUpdater.cs b/Penumbra/Mods/Groups/ModGroupEditUpdater.cs deleted file mode 100644 index 5e66f6215..000000000 --- a/Penumbra/Mods/Groups/ModGroupEditUpdater.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Luna; -using Penumbra.Communication; -using Penumbra.Mods.Manager.OptionEditor; -using Penumbra.Services; - -namespace Penumbra.Mods.Groups; - -public sealed class ModGroupEditUpdater : IRequiredService, IDisposable -{ - private readonly CommunicatorService _communicator; - - public ModGroupEditUpdater(CommunicatorService communicator) - { - _communicator = communicator; - _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.ModGroupEditUpdater); - } - - private static void OnModOptionChange(in ModOptionChanged.Arguments arguments) - { - switch (arguments.Type) - { - case ModOptionChangeType.GroupRenamed: - foreach (var group in arguments.Mod.Groups) - { - if (group.ParentSetting.Group == arguments.OldName) - group.ParentSetting = new ParentSetting(arguments.Group!.Name, group.ParentSetting.Option); - - Fix(group.Condition, arguments.OldName!, arguments.Group!.Name, true); - foreach (var option in group.Options) - Fix(option.Condition, arguments.OldName!, arguments.Group!.Name, true); - } - - break; - case ModOptionChangeType.OptionRenamed: - foreach (var group in arguments.Mod.Groups) - { - if (group.ParentSetting.Option == arguments.OldName) - group.ParentSetting = new ParentSetting(group.ParentSetting.Group!, arguments.Option!.Name); - - Fix(group.Condition, arguments.OldName!, arguments.Option!.Name, false); - foreach (var option in group.Options) - Fix(option.Condition, arguments.OldName!, arguments.Option!.Name, false); - } - - break; - case ModOptionChangeType.PrepareGroupDeletion: - foreach (var group in arguments.Mod.Groups) - { - if (group.ParentSetting.Group == arguments.Group!.Name) - group.ParentSetting = ParentSetting.None; - } - - break; - case ModOptionChangeType.OptionDeleted: - foreach (var group in arguments.Mod.Groups) - { - if (group.ParentSetting.Option == arguments.OldName) - group.ParentSetting = new ParentSetting(group.ParentSetting.Group); - } - - break; - } - } - - private static void Fix(ICondition? condition, string oldText, string newText, bool group) - { - switch (condition) - { - case MultiSettingCondition all when group: - if (all.Group == oldText) - all.Group = newText; - break; - case MultiSettingCondition all: - for (var i = 0; i < all.Count; ++i) - { - if (all[i] == oldText) - all[i] = newText; - } - - break; - case SingleSettingCondition single when group: - if (single.Group == oldText) - single.Group = newText; - break; - case SingleSettingCondition single: - if (single.Option == oldText) - single.Option = newText; - break; - } - } - - private static IEnumerable> GetAllConditions(Mod mod) - { - foreach (var group in mod.Groups) - { - if (group.Condition is not null) - yield return group.Condition; - - foreach (var option in group.Options) - { - if (option.Condition is not null) - yield return option.Condition; - } - } - } - - public void Dispose() - => _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); -} diff --git a/Penumbra/Mods/Groups/ModSaveGroup.cs b/Penumbra/Mods/Groups/ModSaveGroup.cs index 0b4134469..ebbf55cfb 100644 --- a/Penumbra/Mods/Groups/ModSaveGroup.cs +++ b/Penumbra/Mods/Groups/ModSaveGroup.cs @@ -3,7 +3,6 @@ using Penumbra.Files; using Penumbra.Mods.Settings; using Penumbra.Mods.SubMods; -using Penumbra.Services; namespace Penumbra.Mods.Groups; diff --git a/Penumbra/Mods/Groups/ModSettingContext.cs b/Penumbra/Mods/Groups/ModSettingContext.cs index 83da17072..fa5a1a740 100644 --- a/Penumbra/Mods/Groups/ModSettingContext.cs +++ b/Penumbra/Mods/Groups/ModSettingContext.cs @@ -7,7 +7,7 @@ namespace Penumbra.Mods.Groups; public readonly record struct ModSettingContext(Mod Mod, ModSettings Settings); -public sealed class MultiSettingAllCondition(string group, params IReadOnlyCollection options) : MultiSettingCondition(group, options) +public sealed class MultiSettingAllCondition(Guid group, params IReadOnlyCollection options) : MultiSettingCondition(group, options) { protected override ReadOnlySpan Type => "MultiSettingAll"u8; @@ -20,7 +20,7 @@ protected override bool EvaluateGroup(IModGroup group, Setting settings) var matches = 0; foreach (var option in this) { - var optionIndex = group.Options.IndexOf(o => o.Name == option); + var optionIndex = group.Options.IndexOf(o => o.Id == option); if (optionIndex < 0) break; @@ -37,9 +37,16 @@ protected override bool EvaluateGroup(IModGroup group, Setting settings) return matches == Count; } + + public override bool Equals(ICondition? other) + => other is MultiSettingAllCondition s && Group == s.Group && s.SequenceEqual(this); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + => this.Aggregate(HashCode.Combine(typeof(MultiSettingAllCondition).GetHashCode(), Group), HashCode.Combine); } -public sealed class MultiSettingAnyCondition(string group, params IReadOnlyCollection options) : MultiSettingCondition(group, options) +public sealed class MultiSettingAnyCondition(Guid group, params IReadOnlyCollection options) : MultiSettingCondition(group, options) { protected override ReadOnlySpan Type => "MultiSettingAny"u8; @@ -51,7 +58,7 @@ protected override bool EvaluateGroup(IModGroup group, Setting settings) { foreach (var option in this) { - var optionIndex = group.Options.IndexOf(o => o.Name == option); + var optionIndex = group.Options.IndexOf(o => o.Id == option); if (optionIndex < 0) break; @@ -68,22 +75,29 @@ protected override bool EvaluateGroup(IModGroup group, Setting settings) return false; } + + public override bool Equals(ICondition? other) + => other is MultiSettingAnyCondition s && Group == s.Group && s.SequenceEqual(this); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + => this.Aggregate(HashCode.Combine(typeof(MultiSettingAnyCondition).GetHashCode(), Group), HashCode.Combine); } -public sealed class SingleSettingCondition(string group, string option) : ICondition +public sealed class SingleSettingCondition(Guid group, Guid option) : ICondition { - public string Group = group; - public string Option = option; + public Guid Group = group; + public Guid Option = option; public bool Evaluate(in ModSettingContext context) { foreach (var (index, group) in context.Mod.Groups.Index()) { - if (group.Name != Group) + if (group.Id != Group) continue; var settings = context.Settings.IsEmpty ? group.DefaultSettings : context.Settings.Settings[index]; - var option = group.Options.IndexOf(o => o.Name == Option); + var option = group.Options.IndexOf(o => o.Id == Option); if (option < 0) continue; @@ -120,18 +134,25 @@ public IEnumerable> Subconditions public int RemoveSubconditions(Func, bool> predicate) => 0; + + public bool Equals(ICondition? other) + => other is SingleSettingCondition s && Group == s.Group && Option == s.Option; + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] + public override int GetHashCode() + => HashCode.Combine(typeof(SingleSettingCondition).GetHashCode(), Group, Option); } -public abstract class MultiSettingCondition(string group) : List, ICondition +public abstract class MultiSettingCondition(Guid group) : List, ICondition { - protected MultiSettingCondition(string group, IReadOnlyCollection options) + protected MultiSettingCondition(Guid group, IReadOnlyCollection options) : this(group) { EnsureCapacity(options.Count); AddRange(options); } - public string Group = group; + public Guid Group = group; public abstract ICondition DeepCopy(); protected abstract ReadOnlySpan Type { get; } @@ -144,7 +165,7 @@ public bool Evaluate(in ModSettingContext context) foreach (var (index, group) in context.Mod.Groups.Index()) { - if (group.Name != Group) + if (group.Id != Group) continue; if (group.Type is GroupType.Single && Count > 1) @@ -181,4 +202,6 @@ public IEnumerable> Subconditions public int RemoveSubconditions(Func, bool> predicate) => 0; + + public abstract bool Equals(ICondition? other); } diff --git a/Penumbra/Mods/Groups/MultiModGroup.cs b/Penumbra/Mods/Groups/MultiModGroup.cs index 00102ab55..6db81f912 100644 --- a/Penumbra/Mods/Groups/MultiModGroup.cs +++ b/Penumbra/Mods/Groups/MultiModGroup.cs @@ -23,6 +23,7 @@ public GroupDrawBehaviour Behaviour => GroupDrawBehaviour.MultiSelection; public Mod Mod { get; } = mod; + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "Group"; public string Description { get; set; } = string.Empty; public string Image { get; set; } = string.Empty; @@ -30,7 +31,7 @@ public GroupDrawBehaviour Behaviour public int Page { get; set; } public Setting DefaultSettings { get; set; } public ModSettingsLayout Layout { get; set; } - public ParentSetting ParentSetting { get; set; } = ParentSetting.None; + public Guid ParentSetting { get; set; } = Guid.Empty; public ICondition? Condition { get; set; } public readonly List OptionData = []; diff --git a/Penumbra/Mods/Groups/SingleModGroup.cs b/Penumbra/Mods/Groups/SingleModGroup.cs index 67994bd7f..0ec0627fd 100644 --- a/Penumbra/Mods/Groups/SingleModGroup.cs +++ b/Penumbra/Mods/Groups/SingleModGroup.cs @@ -22,6 +22,7 @@ public GroupDrawBehaviour Behaviour => GroupDrawBehaviour.SingleSelection; public Mod Mod { get; } = mod; + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "Option"; public string Description { get; set; } = string.Empty; public string Image { get; set; } = string.Empty; @@ -29,7 +30,7 @@ public GroupDrawBehaviour Behaviour public int Page { get; set; } public Setting DefaultSettings { get; set; } public ModSettingsLayout Layout { get; set; } - public ParentSetting ParentSetting { get; set; } = ParentSetting.None; + public Guid ParentSetting { get; set; } = Guid.Empty; public ICondition? Condition { get; set; } public readonly List OptionData = []; diff --git a/Penumbra/Mods/Manager/ModDataEditor.cs b/Penumbra/Mods/Manager/ModDataEditor.cs index 2dba04818..a999802ed 100644 --- a/Penumbra/Mods/Manager/ModDataEditor.cs +++ b/Penumbra/Mods/Manager/ModDataEditor.cs @@ -30,6 +30,7 @@ public enum ModDataChangeType : uint FileSystemFolder = 0x010000, FileSystemSortOrder = 0x020000, LastConfigEdit = 0x040000, + Identifier = 0x080000, } public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData, LocalModDatabase database) diff --git a/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs index f8d21a92e..b7682682a 100644 --- a/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs @@ -43,6 +43,6 @@ public void SetDisplayName(CombinedDataContainer container, string name, SaveTyp container.Name = name; SaveService.Save(saveType, new ModSaveGroup(container.Group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, container.Group.Mod, container.Group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, container.Group.Mod, container.Group, null, null, Guid.Empty, -1)); } } diff --git a/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs index 7f8adfc1a..d7e4da082 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs @@ -24,7 +24,7 @@ public sealed class ImcModGroupEditor(CommunicatorService communicator, SaveServ var group = CreateGroup(mod, newName, identifier, defaultEntry, maxPriority); mod.Groups.Add(group); SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupAdded, mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupAdded, mod, group, null, null, group.Id, -1)); return group; } @@ -42,7 +42,7 @@ public sealed class ImcModGroupEditor(CommunicatorService communicator, SaveServ }; group.OptionData.Add(subMod); SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, subMod, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, subMod, null, subMod.Id, -1)); return subMod; } @@ -56,7 +56,7 @@ public void ChangeDefaultAttribute(ImcModGroup group, in ImcAttributeCache cache return; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, group.Id, -1)); } public void ChangeDefaultEntry(ImcModGroup group, in ImcEntry newEntry, SaveType saveType = SaveType.Queue) @@ -67,7 +67,7 @@ public void ChangeDefaultEntry(ImcModGroup group, in ImcEntry newEntry, SaveType group.DefaultEntry = entry; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, group.Id, -1)); } public void ChangeOptionAttribute(ImcSubMod option, in ImcAttributeCache cache, int idx, bool value, SaveType saveType = SaveType.Queue) @@ -76,7 +76,7 @@ public void ChangeOptionAttribute(ImcSubMod option, in ImcAttributeCache cache, return; SaveService.Save(saveType, new ModSaveGroup(option.Group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, option.Mod, option.Group, option, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, option.Mod, option.Group, option, null, option.Id, -1)); } public void ChangeAllVariants(ImcModGroup group, bool allVariants, SaveType saveType = SaveType.Queue) @@ -86,7 +86,7 @@ public void ChangeAllVariants(ImcModGroup group, bool allVariants, SaveType save group.AllVariants = allVariants; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, group.Id, -1)); } public void ChangeOnlyAttributes(ImcModGroup group, bool onlyAttributes, SaveType saveType = SaveType.Queue) @@ -96,7 +96,7 @@ public void ChangeOnlyAttributes(ImcModGroup group, bool onlyAttributes, SaveTyp group.OnlyAttributes = onlyAttributes; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, group.Id, -1)); } public void ChangeCanBeDisabled(ImcModGroup group, bool canBeDisabled, SaveType saveType = SaveType.Queue) @@ -106,7 +106,7 @@ public void ChangeCanBeDisabled(ImcModGroup group, bool canBeDisabled, SaveType group.CanBeDisabled = canBeDisabled; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, group.Id, -1)); } protected override ImcModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) diff --git a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs index be6ff416e..aa0b6e39f 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs @@ -64,7 +64,7 @@ public void ChangeModGroupDefaultOption(IModGroup group, Setting defaultOption) group.DefaultSettings = defaultOption; saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DefaultOptionChanged, group.Mod, group, null, null, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DefaultOptionChanged, group.Mod, group, null, null, group.Id, -1)); } /// Rename an option group if possible. @@ -76,7 +76,7 @@ public void RenameModGroup(IModGroup group, string newName) saveService.ImmediateDelete(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); group.Name = newName; - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupRenamed, group.Mod, group, null, null, oldName, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupRenamed, group.Mod, group, null, null, group.Id, -1)); saveService.ImmediateSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); } @@ -85,11 +85,10 @@ public void DeleteModGroup(IModGroup group) { var mod = group.Mod; var idx = group.GetIndex(); - var oldName = group.Name; - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareGroupDeletion, mod, group, null, null, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareGroupDeletion, mod, group, null, null, group.Id, -1)); mod.Groups.RemoveAt(idx); saveService.SaveAllOptionGroups(mod, false, config.ReplaceNonAsciiOnImport); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupDeleted, mod, null, null, null, oldName, idx)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupDeleted, mod, null, null, null, group.Id, idx)); } /// Move the index of a given option group. @@ -101,7 +100,7 @@ public void MoveModGroup(IModGroup group, int groupIdxTo) return; saveService.SaveAllOptionGroups(mod, false, config.ReplaceNonAsciiOnImport); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupMoved, mod, group, null, null, null, idxFrom)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupMoved, mod, group, null, null, group.Id, idxFrom)); } /// Change the internal priority of the given option group. @@ -112,7 +111,7 @@ public void ChangeGroupPriority(IModGroup group, ModPriority newPriority) group.Priority = newPriority; saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PriorityChanged, group.Mod, group, null, null, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PriorityChanged, group.Mod, group, null, null, group.Id, -1)); } /// Change the description of the given option group. @@ -123,7 +122,7 @@ public void ChangeGroupDescription(IModGroup group, string newDescription) group.Description = newDescription; saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, group.Mod, group, null, null, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, group.Mod, group, null, null, group.Id, -1)); } /// Rename the given option. @@ -135,7 +134,7 @@ public void RenameOption(IModOption option, string newName) option.Name = newName; saveService.QueueSave(new ModSaveGroup(option.Group, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionRenamed, option.Mod, option.Group, option, null, oldName, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionRenamed, option.Mod, option.Group, option, null, option.Id, -1)); } /// Change the description of the given option. @@ -146,7 +145,7 @@ public void ChangeOptionDescription(IModOption option, string newDescription) option.Description = newDescription; saveService.QueueSave(new ModSaveGroup(option.Group, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, option.Mod, option.Group, option, null, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.DisplayChange, option.Mod, option.Group, option, null, option.Id, -1)); } /// Set the meta manipulations for a given option. Replaces existing manipulations. @@ -155,10 +154,10 @@ public void SetManipulations(IModDataContainer subMod, MetaDictionary manipulati if (subMod.Manipulations.Equals(manipulations)) return; - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareChange, (Mod)subMod.Mod, subMod.Group, null, subMod, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareChange, (Mod)subMod.Mod, subMod.Group, null, subMod, Guid.Empty, -1)); subMod.Manipulations.SetTo(manipulations); saveService.Save(saveType, new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); - communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, (Mod)subMod.Mod, subMod.Group, null, subMod, null, -1)); + communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMetaChanged, (Mod)subMod.Mod, subMod.Group, null, subMod, Guid.Empty, -1)); } /// Set the file redirections for a given option. Replaces existing redirections. @@ -167,10 +166,10 @@ public void SetFiles(IModDataContainer subMod, IReadOnlyDictionary Forces a file save of the given container's group. @@ -185,7 +184,7 @@ public void AddFiles(IModDataContainer subMod, IReadOnlyDictionary Verify that a new option group name is unique in this mod. diff --git a/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs index d4e57f3bb..77651257b 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs @@ -29,7 +29,7 @@ public abstract class ModOptionEditor( var group = CreateGroup(mod, newName, maxPriority); mod.Groups.Add(group); SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupAdded, mod, group, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupAdded, mod, group, null, null, group.Id, -1)); return group; } @@ -58,7 +58,7 @@ public abstract class ModOptionEditor( return null; SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, option, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, option, null, option.Id, -1)); return option; } @@ -86,7 +86,7 @@ public abstract class ModOptionEditor( return null; SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, clonedOption, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionAdded, group.Mod, group, clonedOption, null, option.Id, -1)); return clonedOption; } @@ -97,10 +97,10 @@ public void DeleteOption(TOption option) var group = option.Group; var optionIdx = option.GetIndex(); var oldName = option.Name; - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareChange, mod, group, option, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PrepareChange, mod, group, option, null, option.Id, -1)); RemoveOption((TGroup)group, optionIdx); SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionDeleted, mod, group, null, null, oldName, optionIdx)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionDeleted, mod, group, null, null, option.Id, optionIdx)); } /// Move an option inside the given option group. @@ -112,7 +112,7 @@ public void MoveOption(TOption option, int optionIdxTo) return; SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, null, idx)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, option.Id, idx)); } protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync); diff --git a/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs index 671b5ce22..7328a55f0 100644 --- a/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs @@ -17,7 +17,7 @@ public void ChangeToSingle(MultiModGroup group) var singleGroup = group.ConvertToSingle(); group.Mod.Groups[idx] = singleGroup; SaveService.QueueSave(new ModSaveGroup(singleGroup, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupTypeChanged, singleGroup.Mod, singleGroup, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupTypeChanged, singleGroup.Mod, singleGroup, null, null, group.Id, -1)); } /// Change the internal priority of the given option. @@ -28,7 +28,7 @@ public void ChangeOptionPriority(MultiSubMod option, ModPriority newPriority) option.Priority = newPriority; SaveService.QueueSave(new ModSaveGroup(option.Group, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PriorityChanged, option.Mod, option.Group, option, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.PriorityChanged, option.Mod, option.Group, option, null, option.Id, -1)); } protected override MultiModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) diff --git a/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs index 592a57ce3..af0c1c947 100644 --- a/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs @@ -17,7 +17,7 @@ public void ChangeToMulti(SingleModGroup group) var multiGroup = group.ConvertToMulti(); group.Mod.Groups[idx] = multiGroup; SaveService.QueueSave(new ModSaveGroup(multiGroup, Config.ReplaceNonAsciiOnImport)); - Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupTypeChanged, multiGroup.Mod, multiGroup, null, null, null, -1)); + Communicator.ModOptionChanged.Invoke(new ModOptionChanged.Arguments(ModOptionChangeType.GroupTypeChanged, multiGroup.Mod, multiGroup, null, null, group.Id, -1)); } protected override SingleModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) diff --git a/Penumbra/Mods/Mod.cs b/Penumbra/Mods/Mod.cs index 2a6c55b58..2a42447e4 100644 --- a/Penumbra/Mods/Mod.cs +++ b/Penumbra/Mods/Mod.cs @@ -56,6 +56,7 @@ public override string ToString() => Name; // Meta Data + public Guid StableIdentifier { get; internal set; } = Guid.NewGuid(); public string Name { get; internal set; } = "New Mod"; public string Author { get; internal set; } = string.Empty; public string Description { get; internal set; } = string.Empty; @@ -78,8 +79,9 @@ public override string ToString() public bool Favorite { get; internal set; } // Options - public readonly DefaultSubMod Default; - public readonly List Groups = []; + public readonly Dictionary SubObjects = []; + public readonly DefaultSubMod Default; + public readonly List Groups = []; /// Compute the required feature flags for this mod. public FeatureFlags ComputeRequiredFeatures() diff --git a/Penumbra/Mods/ModCreator.cs b/Penumbra/Mods/ModCreator.cs index e7c5401bb..5054a02b9 100644 --- a/Penumbra/Mods/ModCreator.cs +++ b/Penumbra/Mods/ModCreator.cs @@ -109,6 +109,9 @@ public void LoadAllGroups(Mod mod) || saveService.FileNames.OptionGroupFile(mod.ModPath.FullName, mod.Groups.Count, group.Name, true) != Path.Combine(file.DirectoryName!, file.Name.ReplaceBadXivSymbols(true)); mod.Groups.Add(group); + mod.SubObjects.TryAdd(group.Id, group); + foreach (var option in group.Options) + mod.SubObjects.TryAdd(option.Id, option); } else { diff --git a/Penumbra/Mods/ModMeta.cs b/Penumbra/Mods/ModMeta.cs index 7153f1dab..17153a18f 100644 --- a/Penumbra/Mods/ModMeta.cs +++ b/Penumbra/Mods/ModMeta.cs @@ -21,12 +21,13 @@ public void Save(Stream stream) j.WriteStartObject(); j.WriteNumber("FileVersion"u8, CurrentFileVersion); + j.WriteString("Identifier"u8, mod.StableIdentifier); j.WriteString("Name"u8, mod.Name); - j.WriteString("Author"u8, mod.Author); - j.WriteString("Description"u8, mod.Description); - j.WriteString("Image"u8, mod.Image); - j.WriteString("Version"u8, mod.Version); - j.WriteString("Website"u8, mod.Website); + j.WriteNonEmptyString("Author"u8, mod.Author); + j.WriteNonEmptyString("Description"u8, mod.Description); + j.WriteNonEmptyString("Image"u8, mod.Image); + j.WriteNonEmptyString("Version"u8, mod.Version); + j.WriteNonEmptyString("Website"u8, mod.Website); if (mod.ModTags.Count > 0) { @@ -90,6 +91,7 @@ public static ModDataChangeType Load(ModDataEditor editor, ModCreator creator, M public struct Dto { public uint? FileVersion; + public Guid? StableIdentifier; public string? Name; public string? Author; public string? Description; @@ -109,6 +111,15 @@ public ModDataChangeType Apply(ModDataEditor editor, ModCreator creator, Mod mod mod.Name = Name ?? string.Empty; } + if (mod.StableIdentifier != StableIdentifier) + { + if (StableIdentifier.HasValue) + mod.StableIdentifier = StableIdentifier.Value; + else + mod.StableIdentifier = Guid.NewGuid(); + changes |= ModDataChangeType.Identifier; + } + if (mod.Author != Author) { changes |= ModDataChangeType.Author; @@ -194,6 +205,8 @@ public static Dto Read(ref Utf8JsonReader reader) if (reader.CheckPropertyValue("FileVersion"u8)) ret.FileVersion = reader.TryReadNumber(out uint fv) ? fv : throw new JsonException(); + else if (reader.CheckPropertyValue("Identifier"u8)) + ret.StableIdentifier = reader.TryGetGuid(out var guid) ? guid : null; else if (reader.CheckPropertyValue("Name"u8)) ret.Name = reader.GetString(); else if (reader.CheckPropertyValue("Author"u8)) diff --git a/Penumbra/Mods/SubMods/CombiningSubMod.cs b/Penumbra/Mods/SubMods/CombiningSubMod.cs index 563e3a585..8f6f9a2bd 100644 --- a/Penumbra/Mods/SubMods/CombiningSubMod.cs +++ b/Penumbra/Mods/SubMods/CombiningSubMod.cs @@ -11,6 +11,7 @@ public class CombiningSubMod(IModGroup group) : IModOption public Mod Mod => Group.Mod; + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "Option"; public string Description { get; set; } = string.Empty; public ModSettingsLayout Layout { get; set; } diff --git a/Penumbra/Mods/SubMods/IModOption.cs b/Penumbra/Mods/SubMods/IModOption.cs index e5dc1f8c1..4a6f95f59 100644 --- a/Penumbra/Mods/SubMods/IModOption.cs +++ b/Penumbra/Mods/SubMods/IModOption.cs @@ -1,4 +1,3 @@ -using Luna; using Penumbra.Mods.Groups; namespace Penumbra.Mods.SubMods; @@ -6,21 +5,13 @@ namespace Penumbra.Mods.SubMods; [Flags] public enum ModSettingsLayout : ulong { - None = 0, - Disable = 0x01, // Disable the option or group instead of hiding it when the conditions are not fulfilled. - Indent = 0x02, // Indent the group if it is placed under another option or group. + None = 0, + Disable = 0x01, // Disable the option or group instead of hiding it when the conditions are not fulfilled. + Indent = 0x02, // Indent the group if it is placed under another option or group. + ParentHeader = 0x04, // Show the groups name or just its options if it is placed under another option or group. } -public interface IModOption +public interface IModOption : IModObject { - public Mod Mod { get; } - public IModGroup Group { get; } - - public string Name { get; set; } - public string FullName { get; } - public string Description { get; set; } - public ModSettingsLayout Layout { get; set; } - public ICondition? Condition { get; set; } - - public int GetIndex(); + public string FullName { get; } } diff --git a/Penumbra/Mods/SubMods/ImcSubMod.cs b/Penumbra/Mods/SubMods/ImcSubMod.cs index 6e2bce4bd..096e97a5e 100644 --- a/Penumbra/Mods/SubMods/ImcSubMod.cs +++ b/Penumbra/Mods/SubMods/ImcSubMod.cs @@ -31,12 +31,14 @@ public Mod Mod public ushort AttributeMask; public bool IsDisableSubMod { get; private init; } - Mod IModOption.Mod + Mod IModObject.Mod => Mod; - IModGroup IModOption.Group + IModGroup IModObject.Group => Group; + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = "Part"; public string FullName diff --git a/Penumbra/Mods/SubMods/OptionSubMod.cs b/Penumbra/Mods/SubMods/OptionSubMod.cs index 143d97112..0d8d6b7d0 100644 --- a/Penumbra/Mods/SubMods/OptionSubMod.cs +++ b/Penumbra/Mods/SubMods/OptionSubMod.cs @@ -22,7 +22,7 @@ public Mod Mod public string FullName => $"{Group.Name}: {Name}"; - Mod IModOption.Mod + Mod IModObject.Mod => Mod; IMod IModDataContainer.Mod @@ -31,7 +31,9 @@ IMod IModDataContainer.Mod IModGroup IModDataContainer.Group => Group; - IModGroup IModOption.Group + public Guid Id { get; set; } = Guid.NewGuid(); + + IModGroup IModObject.Group => Group; public Dictionary Files { get; set; } = []; diff --git a/Penumbra/UI/Combos/Combos.cs b/Penumbra/UI/Combos/Combos.cs index 9ad45bb88..802baedd5 100644 --- a/Penumbra/UI/Combos/Combos.cs +++ b/Penumbra/UI/Combos/Combos.cs @@ -7,7 +7,7 @@ namespace Penumbra.UI; public static class Combos { - public static readonly EnumCombo ModelRace = new(ModelRaceExtensions.ToNameU8, ModelRaceExtensions.ToName); + public static readonly EnumCombo ModelRace = new(ModelRaceExtensions.ToNameU8, ModelRaceExtensions.ToName, null, GameData.Enums.ModelRace.Values.Skip(1).ToArray()); public static readonly EnumCombo TailedRace = new(ModelRaceExtensions.ToNameU8, ModelRaceExtensions.ToName, null, [GameData.Enums.ModelRace.Miqote, GameData.Enums.ModelRace.AuRa, GameData.Enums.ModelRace.Hrothgar]); public static readonly EnumCombo Gender = new(GenderExtensions.ToNameU8, GenderExtensions.ToName); diff --git a/Penumbra/UI/ModsTab/DescriptionEditPopup.cs b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs index 0489f4e10..9925229b1 100644 --- a/Penumbra/UI/ModsTab/DescriptionEditPopup.cs +++ b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs @@ -1,4 +1,5 @@ using ImSharp; +using Luna; using Penumbra.Mods; using Penumbra.Mods.Groups; using Penumbra.Mods.Manager; @@ -6,65 +7,43 @@ namespace Penumbra.UI.ModsTab; -public class DescriptionEditPopup(ModManager modManager) : Luna.IUiService +public sealed class + DescriptionEditPopup(ModManager modManager) : ObjectEditPopup, IUiService { - private static ReadOnlySpan PopupId + protected override ReadOnlySpan PopupId => "EditDesc"u8; - private bool _hasBeenEdited; private StringU8 _description = StringU8.Empty; - private object? _current; - private bool _opened; - public void Open(Mod mod) { - _current = mod; - _opened = true; - _hasBeenEdited = false; - _description = new StringU8(mod.Description); + Open((object)mod); + _description = new StringU8(mod.Description); } public void Open(IModGroup group) { - _current = group; - _opened = true; - _hasBeenEdited = false; - _description = new StringU8(group.Description); + Open((object)group); + _description = new StringU8(group.Description); } public void Open(IModOption option) { - _current = option; - _opened = true; - _hasBeenEdited = false; - _description = new StringU8(option.Description); + Open((object)option); + _description = new StringU8(option.Description); } - public void Draw() + protected override void DrawInternal() { - if (_current is null) - return; - - if (_opened) - { - _opened = false; - Im.Popup.Open(PopupId); - } - - var inputSize = ImEx.ScaledVector(800); - using var popup = Im.Popup.Begin(PopupId); - if (!popup) - return; - if (Im.Window.Appearing) Im.Keyboard.SetFocusHere(); + var inputSize = ImEx.ScaledVector(800); if (Im.Input.MultiLine("##editDescription"u8, ref _description, inputSize)) - _hasBeenEdited = true; + Edited = true; UiHelpers.DefaultLineSpace(); - var buttonSize = new Vector2(Im.Style.GlobalScale * 100, 0); + var buttonSize = ImEx.ScaledVectorX(100); var width = 2 * buttonSize.X + 4 * Im.Style.FramePadding.X @@ -78,19 +57,18 @@ public void Draw() private void DrawSaveButton(Vector2 buttonSize) { - if (!ImEx.Button("Save"u8, buttonSize, _hasBeenEdited ? StringU8.Empty : "No changes made yet."u8, !_hasBeenEdited)) + if (!ImEx.Button("Save"u8, buttonSize, Edited ? StringU8.Empty : "No changes made yet."u8, !Edited)) return; - switch (_current) + switch (Current) { case Mod mod: modManager.DataEditor.ChangeModDescription(mod, _description.ToString()); break; case IModGroup group: modManager.OptionEditor.ChangeGroupDescription(group, _description.ToString()); break; case IModOption option: modManager.OptionEditor.ChangeOptionDescription(option, _description.ToString()); break; } - _description = StringU8.Empty; - _hasBeenEdited = false; - Im.Popup.CloseCurrent(); + _description = StringU8.Empty; + Close(); } private void DrawCancelButton(Vector2 buttonSize) @@ -98,8 +76,7 @@ private void DrawCancelButton(Vector2 buttonSize) if (!Im.Button("Cancel"u8, buttonSize) && !Im.Keyboard.IsPressed(Key.Escape)) return; - _description = StringU8.Empty; - _hasBeenEdited = false; - Im.Popup.CloseCurrent(); + _description = StringU8.Empty; + Close(); } } diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs index 6306fc78f..9b72d194b 100644 --- a/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs @@ -5,7 +5,6 @@ using Penumbra.Mods; using Penumbra.Mods.Groups; using Penumbra.Mods.Settings; -using Penumbra.Mods.SubMods; using Penumbra.Services; namespace Penumbra.UI.ModsTab.Groups; @@ -21,7 +20,7 @@ public sealed class ModGroupDrawer( private bool _temporary; private bool _locked; private TemporaryModSettings? _tempSettings; - private ModSettings? _settings; + private ModSettingContext _context; public void Draw(Mod mod, ModSettings settings, TemporaryModSettings? tempSettings) { @@ -29,36 +28,40 @@ public void Draw(Mod mod, ModSettings settings, TemporaryModSettings? tempSettin if (cache.Count is 0) return; - _settings = settings; + _context = new ModSettingContext(mod, tempSettings ?? settings); _tempSettings = tempSettings; _temporary = tempSettings is not null; _locked = (tempSettings?.Lock ?? 0) > 0; Im.Dummy(UiHelpers.DefaultSpace); - foreach (var single in cache.SingleGroups) - DrawSingleGroupCombo(single.Group, single.Index, settings.IsEmpty ? single.Group.DefaultSettings : settings.Settings[single.Index]); + foreach (var group in cache.Groups) + DrawGroup(group); + } - if (cache.MultiGroups.Count > 0) - Im.Dummy(UiHelpers.DefaultSpace); - foreach (var multi in cache.MultiGroups) - { - var option = settings.IsEmpty ? multi.Group.DefaultSettings : settings.Settings[multi.Index]; - if (multi.Behaviour is GroupDrawBehaviour.MultiSelection) - DrawMultiGroup(multi.Group, multi.Index, option); - else - DrawSingleGroupRadio(multi.Group, multi.Index, option); - } + private void DrawGroup(ModSettingsCache.ModGroupCache group) + { + using var indent = IndentGroup(group.Indented); + var setting = _context.Settings.IsEmpty ? group.Group.DefaultSettings : _context.Settings.Settings[group.Index]; + if (group.IsCombo) + DrawSingleGroupCombo(group, setting); + else if (group.Behaviour is GroupDrawBehaviour.MultiSelection) + DrawMultiGroup(group, setting); + else + DrawSingleGroupRadio(group, setting); + + foreach (var child in group.Children) + DrawGroup(child); } /// /// Draw a single group selector as a combo box. /// If a description is provided, add a help marker besides it. /// - private void DrawSingleGroupCombo(IModGroup group, int groupIdx, Setting setting) + private void DrawSingleGroupCombo(ModSettingsCache.ModGroupCache group, Setting setting) { - using var id = Im.Id.Push(groupIdx); + using var id = Im.Id.Push(group.Index); using var disabled = Im.Disabled(_locked); - combo.Draw(this, (SingleModGroup)group, groupIdx, setting); + combo.Draw(this, (SingleModGroup)group.Group, group.Index, setting); if (group.Description.Length > 0) { LunaStyle.DrawHelpMarkerLabel(group.Name, group.Description); @@ -74,13 +77,20 @@ private void DrawSingleGroupCombo(IModGroup group, int groupIdx, Setting setting /// Draw a single group selector as a set of radio buttons. /// If a description is provided, add a help marker besides it. /// - private void DrawSingleGroupRadio(IModGroup group, int groupIdx, Setting setting) + private void DrawSingleGroupRadio(ModSettingsCache.ModGroupCache group, Setting setting) { - using var id = Im.Id.Push(groupIdx); + using var id = Im.Id.Push(group.Index); var options = group.Options; var selectedOption = setting.AsIndex; - using var g = ImEx.FramedGroup(group.Name, LunaStyle.HelpMarker, group.Description); - DrawCollapseHandling(options, g.MinimumWidth, DrawOptions); + if (group.HideHeader) + { + DrawOptions(); + } + else + { + using var g = ImEx.FramedGroup(group.Name, LunaStyle.HelpMarker, group.Description); + DrawCollapseHandling(options, g.MinimumWidth, DrawOptions); + } return; @@ -92,13 +102,16 @@ void DrawOptions() using var i = Im.Id.Push(idx); var option = options[idx]; if (Im.RadioButton(option.Name, selectedOption == idx)) - SetModSetting(group, groupIdx, Setting.Single(idx)); + SetModSetting(group.Group, group.Index, Setting.Single(idx)); if (option.Description.Length is 0) continue; Im.Line.SameInner(); LunaStyle.DrawAlignedHelpMarker(option.Description, treatAsHovered: Im.Item.Hovered()); + + foreach (var childGroup in option.Children) + DrawGroup(childGroup); } } } @@ -107,20 +120,25 @@ void DrawOptions() /// Draw a multi group selector as a bordered set of checkboxes. /// If a description is provided, add a help marker in the title. /// - private void DrawMultiGroup(IModGroup group, int groupIdx, Setting setting) + private void DrawMultiGroup(ModSettingsCache.ModGroupCache group, Setting setting) { - using var id = Im.Id.Push(groupIdx); + using var id = Im.Id.Push(group.Index); var options = group.Options; - using (var g = ImEx.FramedGroup(group.Name, LunaStyle.HelpMarker, group.Description)) + if (group.HideHeader) { + DrawOptions(); + } + else + { + using var g = ImEx.FramedGroup(group.Name, LunaStyle.HelpMarker, group.Description); DrawCollapseHandling(options, g.MinimumWidth, DrawOptions); } - var label = new StringU8($"##multi{groupIdx}"); + var label = new InlineStringU8($"##m{group.Index:D4}"); if (Im.Item.RightClicked()) Im.Popup.Open(label); - DrawMultiPopup(group, groupIdx, label); + DrawMultiPopup(group, group.Index, label.GetBytes()); return; void DrawOptions() @@ -133,18 +151,21 @@ void DrawOptions() var enabled = setting.HasFlag(idx); if (Im.Checkbox(option.Name, ref enabled)) - SetModSetting(group, groupIdx, setting.SetBit(idx, enabled)); + SetModSetting(group.Group, group.Index, setting.SetBit(idx, enabled)); if (option.Description.Length > 0) { Im.Line.SameInner(); LunaStyle.DrawAlignedHelpMarker(option.Description, treatAsHovered: Im.Item.Hovered()); } + + foreach (var childGroup in option.Children) + DrawGroup(childGroup); } } } - private void DrawMultiPopup(IModGroup group, int groupIdx, StringU8 label) + private void DrawMultiPopup(ModSettingsCache.ModGroupCache group, int groupIdx, ReadOnlySpan label) { using var style = ImStyleSingle.PopupBorderThickness.Push(Im.Style.GlobalScale); using var popup = Im.Popup.Begin(label); @@ -155,13 +176,13 @@ private void DrawMultiPopup(IModGroup group, int groupIdx, StringU8 label) using var disabled = Im.Disabled(_locked); Im.Separator(); if (Im.Selectable("Enable All"u8)) - SetModSetting(group, groupIdx, Setting.AllBits(group.Options.Count)); + SetModSetting(group.Group, groupIdx, Setting.AllBits(group.Options.Count)); if (Im.Selectable("Disable All"u8)) - SetModSetting(group, groupIdx, Setting.Zero); + SetModSetting(group.Group, groupIdx, Setting.Zero); } - private void DrawCollapseHandling(IReadOnlyList options, float minWidth, Action draw) + private void DrawCollapseHandling(IReadOnlyList options, float minWidth, Action draw) { if (options.Count <= config.OptionGroupCollapsibleMin) { @@ -214,7 +235,7 @@ internal void SetModSetting(IModGroup group, int groupIdx, Setting setting) { if (_temporary || config.DefaultTemporaryMode) { - _tempSettings ??= new TemporaryModSettings(group.Mod, _settings); + _tempSettings ??= new TemporaryModSettings(group.Mod, _context.Settings); _tempSettings!.ForceInherit = false; _tempSettings!.Settings[groupIdx] = setting; collectionManager.Editor.SetTemporarySettings(Current, group.Mod, _tempSettings); @@ -224,4 +245,7 @@ internal void SetModSetting(IModGroup group, int groupIdx, Setting setting) collectionManager.Editor.SetModSetting(Current, group.Mod, groupIdx, setting); } } + + private static Im.IndentDisposable? IndentGroup(bool indent) + => indent ? Im.Indent(Im.Style.FrameHeight + Im.Style.ItemInnerSpacing.X) : null; } diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs index b3ed5198e..8aed92021 100644 --- a/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs @@ -19,6 +19,7 @@ public sealed class ModGroupEditDrawer( Configuration config, FilenameService filenames, DescriptionEditPopup descriptionPopup, + LayoutEditPopup layoutPopup, ImcChecker imcChecker, ModGroupConditionDrawer conditionDrawer) : IUiService { @@ -149,7 +150,7 @@ private void DrawGroupDescription(IModGroup group) private void DrawGroupLayout(IModGroup group) { if (ImEx.Icon.Button(LunaStyle.LayoutIcon, "Edit group layout settings."u8)) - descriptionPopup.Open(group); + layoutPopup.Open(group); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -157,7 +158,7 @@ private void DrawGroupConditions(IModGroup group) { if (ImEx.Icon.Button(LunaStyle.ConditionIcon, "Edit group conditions."u8, textColor: group.Condition is not null ? LunaStyle.FavoriteColor : ColorParameter.Default)) - descriptionPopup.Open(group); + layoutPopup.Open(group); } private void DrawGroupMoveButtons(IModGroup group, int idx) @@ -252,7 +253,7 @@ private void DrawOptionDescription(IModOption option) private void DrawOptionLayout(IModOption option) { if (ImEx.Icon.Button(LunaStyle.LayoutIcon, "Edit option layout settings."u8)) - descriptionPopup.Open(option); + layoutPopup.Open(option); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Penumbra/UI/ModsTab/Groups/ModSettingsCache.cs b/Penumbra/UI/ModsTab/Groups/ModSettingsCache.cs index e86af64b6..cc17e326d 100644 --- a/Penumbra/UI/ModsTab/Groups/ModSettingsCache.cs +++ b/Penumbra/UI/ModsTab/Groups/ModSettingsCache.cs @@ -14,27 +14,39 @@ public sealed class ModSettingsCache : BasicCache { public sealed class ModGroupCache { + public record Option( + IModOption Data, + StringU8 Name, + StringU8 Description, + Setting Value, + float Width, + bool Disabled, + List Children); + public GroupDrawBehaviour Behaviour => Group.Behaviour; - public IModGroup Group = null!; - public int Index; - public bool IsCombo; - public StringU8 Name; - public StringU8 Description; - public float NameWidth; - public float ComboWidth; - public readonly List<(StringU8 Option, StringU8 Description, Setting Value, float Width, bool Disabled)> Options = []; - public bool Disabled; + public IModGroup Group = null!; + public StringU8 Name; + public StringU8 Description; + public readonly List