diff --git a/Penumbra/Mods/Editor/Mod.Editor.Duplicates.cs b/Penumbra/Mods/Editor/Mod.Editor.Duplicates.cs index 3f217fab..4722c353 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.Duplicates.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.Duplicates.cs @@ -14,8 +14,8 @@ public partial class Mod { public partial class Editor { - private readonly SHA256 _hasher = SHA256.Create(); - private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = new(); + private readonly SHA256 _hasher = SHA256.Create(); + private readonly List< (FullPath[] Paths, long Size, byte[] Hash) > _duplicates = new(); public IReadOnlyList< (FullPath[] Paths, long Size, byte[] Hash) > Duplicates => _duplicates; @@ -44,7 +44,8 @@ public partial class Mod HandleDuplicate( duplicate, remaining ); } } - _availableFiles.RemoveAll( p => !p.Item1.Exists ); + + _availableFiles.RemoveAll( p => !p.File.Exists ); _duplicates.Clear(); } @@ -82,7 +83,7 @@ public partial class Mod } changes = true; - PluginLog.Debug( "[DeleteDuplicates] Changing {GamePath:l} for {Mod:d}\n : {Old:l}\n -> {New:l}", key, _mod.Name, from, to); + PluginLog.Debug( "[DeleteDuplicates] Changing {GamePath:l} for {Mod:d}\n : {Old:l}\n -> {New:l}", key, _mod.Name, from, to ); return to; } @@ -92,26 +93,28 @@ public partial class Mod if( DuplicatesFinished ) { DuplicatesFinished = false; - Task.Run( CheckDuplicates ); + UpdateFiles(); + var files = _availableFiles.OrderByDescending(f => f.FileSize).ToArray(); + Task.Run( () => CheckDuplicates( files ) ); } } - private void CheckDuplicates() + private void CheckDuplicates( IReadOnlyList< FileRegistry > files ) { _duplicates.Clear(); SavedSpace = 0; var list = new List< FullPath >(); var lastSize = -1L; - foreach( var (p, size) in AvailableFiles ) + foreach( var file in files ) { if( DuplicatesFinished ) { return; } - if( size == lastSize ) + if( file.FileSize == lastSize ) { - list.Add( p ); + list.Add( file.File ); continue; } @@ -119,22 +122,25 @@ public partial class Mod { CheckMultiDuplicates( list, lastSize ); } - lastSize = size; + + lastSize = file.FileSize; list.Clear(); - list.Add( p ); + list.Add( file.File ); } + if( list.Count >= 2 ) { CheckMultiDuplicates( list, lastSize ); } + _duplicates.Sort( ( a, b ) => a.Size != b.Size ? b.Size.CompareTo( a.Size ) : a.Paths[ 0 ].CompareTo( b.Paths[ 0 ] ) ); DuplicatesFinished = true; } private void CheckMultiDuplicates( IReadOnlyList< FullPath > list, long size ) { - var hashes = list.Select( f => (f, ComputeHash(f)) ).ToList(); + var hashes = list.Select( f => ( f, ComputeHash( f ) ) ).ToList(); while( hashes.Count > 0 ) { if( DuplicatesFinished ) @@ -157,10 +163,10 @@ public partial class Mod } } - hashes.RemoveAll( p => set.Contains(p.Item1) ); + hashes.RemoveAll( p => set.Contains( p.Item1 ) ); if( set.Count > 1 ) { - _duplicates.Add( (set.OrderBy( f => f.FullName.Length ).ToArray(), size, hash.Item2) ); + _duplicates.Add( ( set.OrderBy( f => f.FullName.Length ).ToArray(), size, hash.Item2 ) ); SavedSpace += ( set.Count - 1 ) * size; } } @@ -169,27 +175,35 @@ public partial class Mod private static unsafe bool CompareFilesDirectly( FullPath f1, FullPath f2 ) { if( !f1.Exists || !f2.Exists ) + { return false; + } using var s1 = File.OpenRead( f1.FullName ); using var s2 = File.OpenRead( f2.FullName ); var buffer1 = stackalloc byte[256]; var buffer2 = stackalloc byte[256]; - var span1 = new Span< byte >( buffer1, 256 ); - var span2 = new Span< byte >( buffer2, 256 ); + var span1 = new Span< byte >( buffer1, 256 ); + var span2 = new Span< byte >( buffer2, 256 ); while( true ) { - var bytes1 = s1.Read( span1 ); - var bytes2 = s2.Read( span2 ); + var bytes1 = s1.Read( span1 ); + var bytes2 = s2.Read( span2 ); if( bytes1 != bytes2 ) + { return false; + } if( !span1[ ..bytes1 ].SequenceEqual( span2[ ..bytes2 ] ) ) + { return false; + } if( bytes1 < 256 ) + { return true; + } } } diff --git a/Penumbra/Mods/Editor/Mod.Editor.Edit.cs b/Penumbra/Mods/Editor/Mod.Editor.Edit.cs index 35d41003..1e702b67 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.Edit.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.Edit.cs @@ -18,38 +18,52 @@ public partial class Mod public ISubMod CurrentOption => _subMod; - public readonly Dictionary< Utf8GamePath, FullPath > CurrentFiles = new(); - public readonly Dictionary< Utf8GamePath, FullPath > CurrentSwaps = new(); + public readonly Dictionary< Utf8GamePath, FullPath > CurrentSwaps = new(); public void SetSubMod( int groupIdx, int optionIdx ) { GroupIdx = groupIdx; OptionIdx = optionIdx; - if( groupIdx >= 0 ) + if( groupIdx >= 0 && groupIdx < _mod.Groups.Count && optionIdx >= 0 && optionIdx < _mod.Groups[ groupIdx ].Count ) { _modGroup = _mod.Groups[ groupIdx ]; _subMod = ( SubMod )_modGroup![ optionIdx ]; } else { + GroupIdx = -1; + OptionIdx = 0; _modGroup = null; _subMod = _mod._default; } - RevertFiles(); + UpdateFiles(); RevertSwaps(); RevertManipulations(); } - public void ApplyFiles() + public int ApplyFiles() { - Penumbra.ModManager.OptionSetFiles( _mod, GroupIdx, OptionIdx, CurrentFiles.ToDictionary( kvp => kvp.Key, kvp => kvp.Value ) ); + var dict = new Dictionary< Utf8GamePath, FullPath >(); + var num = 0; + foreach( var file in _availableFiles ) + { + foreach( var path in file.SubModUsage.Where( p => p.Item1 == CurrentOption ) ) + { + num += dict.TryAdd( path.Item2, file.File ) ? 0 : 1; + } + } + + Penumbra.ModManager.OptionSetFiles( _mod, GroupIdx, OptionIdx, dict ); + if( num > 0 ) + RevertFiles(); + else + FileChanges = false; + return num; } public void RevertFiles() - { - CurrentFiles.SetTo( _subMod.Files ); - } + => UpdateFiles(); public void ApplySwaps() { diff --git a/Penumbra/Mods/Editor/Mod.Editor.Files.cs b/Penumbra/Mods/Editor/Mod.Editor.Files.cs index e823be02..5d56c871 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.Files.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.Files.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using Dalamud.Logging; @@ -11,68 +12,75 @@ public partial class Mod { public partial class Editor { + public class FileRegistry : IEquatable< FileRegistry > + { + public readonly List< (ISubMod, Utf8GamePath) > SubModUsage = new(); + public FullPath File { get; private init; } + public Utf8RelPath RelPath { get; private init; } + public long FileSize { get; private init; } + public int CurrentUsage; + + public static bool FromFile( Mod mod, FileInfo file, [NotNullWhen( true )] out FileRegistry? registry ) + { + var fullPath = new FullPath( file.FullName ); + if( !fullPath.ToRelPath( mod.ModPath, out var relPath ) ) + { + registry = null; + return false; + } + + registry = new FileRegistry + { + File = fullPath, + RelPath = relPath, + FileSize = file.Length, + CurrentUsage = 0, + }; + return true; + } + + public bool Equals( FileRegistry? other ) + { + if( ReferenceEquals( null, other ) ) + { + return false; + } + + return ReferenceEquals( this, other ) || File.Equals( other.File ); + } + + public override bool Equals( object? obj ) + { + if( ReferenceEquals( null, obj ) ) + { + return false; + } + + if( ReferenceEquals( this, obj ) ) + { + return true; + } + + return obj.GetType() == GetType() && Equals( ( FileRegistry )obj ); + } + + public override int GetHashCode() + => File.GetHashCode(); + } + // All files in subdirectories of the mod directory. - public IReadOnlyList< (FullPath, long) > AvailableFiles + public IReadOnlyList< FileRegistry > AvailableFiles => _availableFiles; - private readonly List< (FullPath, long) > _availableFiles; - - // All files that are available but not currently used in any option. - private readonly SortedSet< FullPath > _unusedFiles; - - public IReadOnlySet< FullPath > UnusedFiles - => _unusedFiles; - - // All paths that are used in any option in the mod. - private readonly SortedSet< FullPath > _usedPaths; - - public IReadOnlySet< FullPath > UsedPaths - => _usedPaths; + public bool FileChanges { get; private set; } + private List< FileRegistry > _availableFiles = null!; + private readonly HashSet< Utf8GamePath > _usedPaths = new(); // All paths that are used in - private readonly SortedSet< FullPath > _missingPaths; + private readonly SortedSet< FullPath > _missingFiles = new(); - public IReadOnlySet< FullPath > MissingPaths - => _missingPaths; - - // Adds all currently unused paths, relative to the mod directory, to the replacements. - public void AddUnusedPathsToDefault() - { - var dict = new Dictionary< Utf8GamePath, FullPath >( UnusedFiles.Count ); - foreach( var file in UnusedFiles ) - { - var gamePath = file.ToGamePath( _mod.ModPath, out var g ) ? g : Utf8GamePath.Empty; - if( !gamePath.IsEmpty && !dict.ContainsKey( gamePath ) ) - { - dict.Add( gamePath, file ); - PluginLog.Debug( "[AddUnusedPaths] Adding {GamePath} -> {File} to default option of {Mod}.", gamePath, file, _mod.Name ); - } - } - - Penumbra.ModManager.OptionAddFiles( _mod, -1, 0, dict ); - _usedPaths.UnionWith( _mod.Default.Files.Values ); - _unusedFiles.RemoveWhere( f => _mod.Default.Files.Values.Contains( f ) ); - } - - // Delete all currently unused paths from your filesystem. - public void DeleteUnusedPaths() - { - foreach( var file in UnusedFiles ) - { - try - { - File.Delete( file.FullName ); - PluginLog.Debug( "[DeleteUnusedPaths] Deleted {File} from {Mod}.", file, _mod.Name ); - } - catch( Exception e ) - { - PluginLog.Error($"[DeleteUnusedPaths] Could not delete {file} from {_mod.Name}:\n{e}" ); - } - } - - _unusedFiles.RemoveWhere( f => !f.Exists ); - _availableFiles.RemoveAll( p => !p.Item1.Exists ); - } + public IReadOnlySet< FullPath > MissingFiles + => _missingFiles; // Remove all path redirections where the pointed-to file does not exist. public void RemoveMissingPaths() @@ -88,13 +96,12 @@ public partial class Mod } ApplyToAllOptions( _mod, HandleSubMod ); - _usedPaths.RemoveWhere( _missingPaths.Contains ); - _missingPaths.Clear(); + _missingFiles.Clear(); } private bool CheckAgainstMissing( FullPath file, Utf8GamePath key ) { - if( !_missingPaths.Contains( file ) ) + if( !_missingFiles.Contains( file ) ) { return true; } @@ -104,9 +111,157 @@ public partial class Mod } - private static List<(FullPath, long)> GetAvailablePaths( Mod mod ) - => mod.ModPath.EnumerateDirectories() - .SelectMany( d => d.EnumerateFiles( "*.*", SearchOption.AllDirectories ).Select( f => (new FullPath( f ), f.Length) ) ) - .OrderBy( p => -p.Length ).ToList(); + // Fetch all files inside subdirectories of the main mod directory. + // Then check which options use them and how often. + private void UpdateFiles() + { + _availableFiles = _mod.ModPath.EnumerateDirectories() + .SelectMany( d => d.EnumerateFiles( "*.*", SearchOption.AllDirectories ) + .Select( f => FileRegistry.FromFile( _mod, f, out var r ) ? r : null ) + .OfType< FileRegistry >() ) + .ToList(); + + _usedPaths.Clear(); + FileChanges = false; + foreach( var subMod in _mod.AllSubMods ) + { + foreach( var (gamePath, file) in subMod.Files ) + { + if( !file.Exists ) + { + _missingFiles.Add( file ); + if( subMod == _subMod ) + { + _usedPaths.Add( gamePath ); + } + } + else + { + var registry = _availableFiles.FirstOrDefault( x => x.File.Equals( file ) ); + if( registry != null ) + { + if( subMod == _subMod ) + { + ++registry.CurrentUsage; + _usedPaths.Add( gamePath ); + } + + registry.SubModUsage.Add( ( subMod, gamePath ) ); + } + } + } + } + } + + // Return whether the given path is already used in the current option. + public bool CanAddGamePath( Utf8GamePath path ) + => !_usedPaths.Contains( path ); + + // Try to set a given path for a given file. + // Returns false if this is not possible. + // If path is empty, it will be deleted instead. + // If pathIdx is equal to the total number of paths, path will be added, otherwise replaced. + public bool SetGamePath( int fileIdx, int pathIdx, Utf8GamePath path ) + { + if( _usedPaths.Contains( path ) || fileIdx < 0 || fileIdx > _availableFiles.Count || pathIdx < 0 ) + { + return false; + } + + var registry = _availableFiles[ fileIdx ]; + if( pathIdx > registry.SubModUsage.Count ) + { + return false; + } + + if( pathIdx == registry.SubModUsage.Count ) + { + registry.SubModUsage.Add( ( CurrentOption, path ) ); + ++registry.CurrentUsage; + _usedPaths.Add( path ); + } + else + { + _usedPaths.Remove( registry.SubModUsage[ pathIdx ].Item2 ); + if( path.IsEmpty ) + { + registry.SubModUsage.RemoveAt( pathIdx ); + --registry.CurrentUsage; + } + else + { + registry.SubModUsage[ pathIdx ] = ( registry.SubModUsage[ pathIdx ].Item1, path ); + } + } + + FileChanges = true; + + return true; + } + + // Transform a set of files to the appropriate game paths with the given number of folders skipped, + // and add them to the given option. + public int AddPathsToSelected( IEnumerable< FileRegistry > files, int skipFolders = 0 ) + { + var failed = 0; + foreach( var file in files ) + { + var gamePath = file.RelPath.ToGamePath( skipFolders ); + if( gamePath.IsEmpty ) + { + ++failed; + continue; + } + + if( CanAddGamePath( gamePath ) ) + { + ++file.CurrentUsage; + file.SubModUsage.Add( ( CurrentOption, gamePath ) ); + _usedPaths.Add( gamePath ); + FileChanges = true; + } + else + { + ++failed; + } + } + + return failed; + } + + // Remove all paths in the current option from the given files. + public void RemovePathsFromSelected( IEnumerable< FileRegistry > files ) + { + foreach( var file in files ) + { + file.CurrentUsage = 0; + FileChanges |= file.SubModUsage.RemoveAll( p => p.Item1 == CurrentOption && _usedPaths.Remove( p.Item2 ) ) > 0; + } + } + + // Delete all given files from your filesystem + public void DeleteFiles( IEnumerable< FileRegistry > files ) + { + var deletions = 0; + foreach( var file in files ) + { + try + { + File.Delete( file.File.FullName ); + PluginLog.Debug( "[DeleteFiles] Deleted {File} from {Mod}.", file.File.FullName, _mod.Name ); + ++deletions; + } + catch( Exception e ) + { + PluginLog.Error( $"[DeleteFiles] Could not delete {file.File.FullName} from {_mod.Name}:\n{e}" ); + } + } + + if( deletions > 0 ) + { + _mod.Reload( out _ ); + UpdateFiles(); + } + } } } \ No newline at end of file diff --git a/Penumbra/Mods/Editor/Mod.Editor.MdlMaterials.cs b/Penumbra/Mods/Editor/Mod.Editor.MdlMaterials.cs index 5e87b2dd..81417fdb 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.MdlMaterials.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.MdlMaterials.cs @@ -72,22 +72,22 @@ public partial class Mod private void ScanModels() { _modelFiles.Clear(); - foreach( var (file, _) in AvailableFiles.Where( f => f.Item1.Extension == ".mdl" ) ) + foreach( var file in AvailableFiles.Where( f => f.File.Extension == ".mdl" ) ) { try { - var bytes = File.ReadAllBytes( file.FullName ); + var bytes = File.ReadAllBytes( file.File.FullName ); var mdlFile = new MdlFile( bytes ); var materials = mdlFile.Materials.WithIndex().Where( p => MaterialRegex.IsMatch( p.Item1 ) ) .Select( p => p.Item2 ).ToArray(); if( materials.Length > 0 ) { - _modelFiles.Add( new MaterialInfo( file, mdlFile, materials ) ); + _modelFiles.Add( new MaterialInfo( file.File, mdlFile, materials ) ); } } catch( Exception e ) { - PluginLog.Error( $"Unexpected error scanning {_mod.Name}'s {file.FullName} for materials:\n{e}" ); + PluginLog.Error( $"Unexpected error scanning {_mod.Name}'s {file.File.FullName} for materials:\n{e}" ); } } } diff --git a/Penumbra/Mods/Editor/Mod.Editor.cs b/Penumbra/Mods/Editor/Mod.Editor.cs index d69c32f0..74158a4e 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.cs @@ -13,14 +13,13 @@ public partial class Mod { private readonly Mod _mod; - public Editor( Mod mod ) + public Editor( Mod mod, int groupIdx, int optionIdx ) { - _mod = mod; - _availableFiles = GetAvailablePaths( mod ); - _usedPaths = new SortedSet< FullPath >( mod.AllFiles ); - _missingPaths = new SortedSet< FullPath >( UsedPaths.Where( f => !f.Exists ) ); - _unusedFiles = new SortedSet< FullPath >( AvailableFiles.Where( p => !UsedPaths.Contains( p.Item1 ) ).Select( p => p.Item1 ) ); - _subMod = _mod._default; + _mod = mod; + SetSubMod( groupIdx, optionIdx ); + GroupIdx = groupIdx; + _subMod = _mod._default; + UpdateFiles(); ScanModels(); } diff --git a/Penumbra/UI/Classes/ModEditWindow.Files.cs b/Penumbra/UI/Classes/ModEditWindow.Files.cs new file mode 100644 index 00000000..47a2178c --- /dev/null +++ b/Penumbra/UI/Classes/ModEditWindow.Files.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Dalamud.Interface; +using Dalamud.Logging; +using ImGuiNET; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using Penumbra.GameData.ByteString; +using Penumbra.Mods; +using Penumbra.Util; + +namespace Penumbra.UI.Classes; + +public partial class ModEditWindow +{ + private readonly HashSet< Mod.Editor.FileRegistry > _selectedFiles = new(256); + private LowerString _fileFilter = LowerString.Empty; + private bool _showGamePaths = true; + private string _gamePathEdit = string.Empty; + private int _fileIdx = -1; + private int _pathIdx = -1; + private int _folderSkip = 0; + + private bool CheckFilter( Mod.Editor.FileRegistry registry ) + => _fileFilter.IsEmpty || registry.File.FullName.Contains( _fileFilter.Lower, StringComparison.InvariantCultureIgnoreCase ); + + private bool CheckFilter( (Mod.Editor.FileRegistry, int) p ) + => CheckFilter( p.Item1 ); + + private void DrawFileTab() + { + using var tab = ImRaii.TabItem( "File Redirections" ); + if( !tab ) + { + return; + } + + DrawOptionSelectHeader(); + DrawButtonHeader(); + + using var child = ImRaii.Child( "##files", -Vector2.One, true ); + if( !child ) + { + return; + } + + using var list = ImRaii.Table( "##table", 1 ); + if( !list ) + { + return; + } + + foreach( var (registry, i) in _editor!.AvailableFiles.WithIndex().Where( CheckFilter ) ) + { + using var id = ImRaii.PushId( i ); + ImGui.TableNextColumn(); + + DrawSelectable( registry ); + + if( !_showGamePaths ) + { + continue; + } + + using var indent = ImRaii.PushIndent( 50f ); + for( var j = 0; j < registry.SubModUsage.Count; ++j ) + { + var (subMod, gamePath) = registry.SubModUsage[ j ]; + if( subMod != _editor.CurrentOption ) + { + continue; + } + + PrintGamePath( i, j, registry, subMod, gamePath ); + } + + PrintNewGamePath( i, registry, _editor.CurrentOption ); + } + } + + private string DrawFileTooltip( Mod.Editor.FileRegistry registry, ColorId color ) + { + (string, int) GetMulti() + { + var groups = registry.SubModUsage.GroupBy( s => s.Item1 ).ToArray(); + return ( string.Join( "\n", groups.Select( g => g.Key.Name ) ), groups.Length ); + } + + var (text, groupCount) = color switch + { + ColorId.ConflictingMod => ( string.Empty, 0 ), + ColorId.NewMod => ( registry.SubModUsage[ 0 ].Item1.Name, 1 ), + ColorId.InheritedMod => GetMulti(), + _ => ( string.Empty, 0 ), + }; + + if( text.Length > 0 && ImGui.IsItemHovered() ) + { + ImGui.SetTooltip( text ); + } + + + return ( groupCount, registry.SubModUsage.Count ) switch + { + (0, 0) => "(unused)", + (1, 1) => "(used 1 time)", + (1, > 1) => $"(used {registry.SubModUsage.Count} times in 1 group)", + _ => $"(used {registry.SubModUsage.Count} times over {groupCount} groups)", + }; + } + + private void DrawSelectable( Mod.Editor.FileRegistry registry ) + { + var selected = _selectedFiles.Contains( registry ); + var color = registry.SubModUsage.Count == 0 ? ColorId.ConflictingMod : + registry.CurrentUsage == registry.SubModUsage.Count ? ColorId.NewMod : ColorId.InheritedMod; + using var c = ImRaii.PushColor( ImGuiCol.Text, color.Value() ); + if( ConfigWindow.Selectable( registry.RelPath.Path, selected ) ) + { + if( selected ) + { + _selectedFiles.Remove( registry ); + } + else + { + _selectedFiles.Add( registry ); + } + } + + var rightText = DrawFileTooltip( registry, color ); + + ImGui.SameLine(); + ImGuiUtil.RightAlign( rightText ); + } + + private void PrintGamePath( int i, int j, Mod.Editor.FileRegistry registry, ISubMod subMod, Utf8GamePath gamePath ) + { + using var id = ImRaii.PushId( j ); + ImGui.TableNextColumn(); + var tmp = _fileIdx == i && _pathIdx == j ? _gamePathEdit : gamePath.ToString(); + + ImGui.SetNextItemWidth( -1 ); + if( ImGui.InputText( string.Empty, ref tmp, Utf8GamePath.MaxGamePathLength ) ) + { + _fileIdx = i; + _pathIdx = j; + _gamePathEdit = tmp; + } + + ImGuiUtil.HoverTooltip( "Clear completely to remove the path from this mod." ); + + if( ImGui.IsItemDeactivatedAfterEdit() ) + { + _fileIdx = -1; + _pathIdx = -1; + if( _gamePathEdit.Length == 0 ) + { + registry.SubModUsage.RemoveAt( j-- ); + --registry.CurrentUsage; + } + else if( Utf8GamePath.FromString( _gamePathEdit, out var path, false ) ) + { + registry.SubModUsage[ j ] = ( subMod, path ); + } + } + } + + private void PrintNewGamePath( int i, Mod.Editor.FileRegistry registry, ISubMod subMod ) + { + var tmp = _fileIdx == i && _pathIdx == -1 ? _gamePathEdit : string.Empty; + ImGui.SetNextItemWidth( -1 ); + if( ImGui.InputTextWithHint( "##new", "Add New Path...", ref tmp, Utf8GamePath.MaxGamePathLength ) ) + { + _fileIdx = i; + _pathIdx = -1; + _gamePathEdit = tmp; + } + + if( ImGui.IsItemDeactivatedAfterEdit() ) + { + _fileIdx = -1; + _pathIdx = -1; + if( Utf8GamePath.FromString( _gamePathEdit, out var path, false ) && !path.IsEmpty ) + { + registry.SubModUsage.Add( ( subMod, path ) ); + ++registry.CurrentUsage; + } + } + } + + private void DrawButtonHeader() + { + ImGui.NewLine(); + + using var spacing = ImRaii.PushStyle( ImGuiStyleVar.ItemSpacing, new Vector2( 3 * ImGuiHelpers.GlobalScale, 0 ) ); + ImGui.SetNextItemWidth( 30 * ImGuiHelpers.GlobalScale ); + ImGui.DragInt( "##skippedFolders", ref _folderSkip, 0.01f, 0, 10 ); + ImGuiUtil.HoverTooltip( "Skip the first N folders when automatically constructing the game path from the file path." ); + ImGui.SameLine(); + spacing.Pop( ); + if( ImGui.Button( "Add Paths" ) ) + { + _editor!.AddPathsToSelected( _editor!.AvailableFiles.Where( _selectedFiles.Contains ), _folderSkip ); + } + ImGuiUtil.HoverTooltip( "Add the file path converted to a game path to all selected files for the current option, optionally skipping the first N folders." ); + + + ImGui.SameLine(); + if( ImGui.Button( "Remove Paths" ) ) + { + _editor!.RemovePathsFromSelected( _editor!.AvailableFiles.Where( _selectedFiles.Contains ) ); + } + ImGuiUtil.HoverTooltip( "Remove all game paths associated with the selected files in the current option." ); + + + ImGui.SameLine(); + if( ImGui.Button( "Delete Selected Files" ) ) + { + _editor!.DeleteFiles( _editor!.AvailableFiles.Where( _selectedFiles.Contains ) ); + } + ImGuiUtil.HoverTooltip( "Delete all selected files entirely from your filesystem, but not their file associations in the mod, if there are any.\n!!!This can not be reverted!!!" ); + ImGui.SameLine(); + var changes = _editor!.FileChanges; + var tt = changes ? "Apply the current file setup to the currently selected option." : "No changes made."; + if( ImGuiUtil.DrawDisabledButton( "Apply Changes", Vector2.Zero, tt, !changes ) ) + { + var failedFiles = _editor!.ApplyFiles(); + PluginLog.Information( $"Failed to apply {failedFiles} file redirections to {_editor.CurrentOption.Name}." ); + } + + + ImGui.SameLine(); + var label = changes ? "Revert Changes" : "Reload Files"; + var length = new Vector2( ImGui.CalcTextSize( "Revert Changes" ).X, 0 ); + if( ImGui.Button( label, length ) ) + { + _editor!.RevertFiles(); + } + ImGuiUtil.HoverTooltip( "Revert all revertible changes since the last file or option reload or data refresh." ); + + ImGui.SetNextItemWidth( 250 * ImGuiHelpers.GlobalScale ); + LowerString.InputWithHint( "##filter", "Filter paths...", ref _fileFilter, Utf8GamePath.MaxGamePathLength ); + ImGui.SameLine(); + ImGui.Checkbox( "Show Game Paths", ref _showGamePaths ); + ImGui.SameLine(); + if( ImGui.Button( "Unselect All" ) ) + { + _selectedFiles.Clear(); + } + + ImGui.SameLine(); + if( ImGui.Button( "Select Visible" ) ) + { + _selectedFiles.UnionWith( _editor!.AvailableFiles.Where( CheckFilter ) ); + } + + ImGui.SameLine(); + if( ImGui.Button( "Select Unused" ) ) + { + _selectedFiles.UnionWith( _editor!.AvailableFiles.Where( f => f.SubModUsage.Count == 0 ) ); + } + + ImGui.SameLine(); + if( ImGui.Button( "Select Used Here" ) ) + { + _selectedFiles.UnionWith( _editor!.AvailableFiles.Where( f => f.CurrentUsage > 0 ) ); + } + + ImGui.SameLine(); + + ImGuiUtil.RightAlign( $"{_selectedFiles.Count} / {_editor!.AvailableFiles.Count} Files Selected" ); + } +} \ No newline at end of file diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs index 4f902467..67c3b183 100644 --- a/Penumbra/UI/Classes/ModEditWindow.cs +++ b/Penumbra/UI/Classes/ModEditWindow.cs @@ -29,7 +29,7 @@ public partial class ModEditWindow : Window, IDisposable } _editor?.Dispose(); - _editor = new Mod.Editor( mod ); + _editor = new Mod.Editor( mod, -1, 0 ); _mod = mod; WindowName = $"{mod.Name}{WindowBaseLabel}"; SizeConstraints = new WindowSizeConstraints @@ -37,6 +37,7 @@ public partial class ModEditWindow : Window, IDisposable MinimumSize = ImGuiHelpers.ScaledVector2( 1000, 600 ), MaximumSize = 4000 * Vector2.One, }; + _selectedFiles.Clear(); } public void ChangeOption( int groupIdx, int optionIdx ) @@ -58,7 +59,6 @@ public partial class ModEditWindow : Window, IDisposable DrawMetaTab(); DrawSwapTab(); DrawMissingFilesTab(); - DrawUnusedFilesTab(); DrawDuplicatesTab(); DrawMaterialChangeTab(); } @@ -233,7 +233,7 @@ public partial class ModEditWindow : Window, IDisposable return; } - if( _editor!.MissingPaths.Count == 0 ) + if( _editor!.MissingFiles.Count == 0 ) { ImGui.NewLine(); ImGui.TextUnformatted( "No missing files detected." ); @@ -257,7 +257,7 @@ public partial class ModEditWindow : Window, IDisposable return; } - foreach( var path in _editor.MissingPaths ) + foreach( var path in _editor.MissingFiles ) { ImGui.TableNextColumn(); ImGui.TextUnformatted( path.FullName ); @@ -421,90 +421,6 @@ public partial class ModEditWindow : Window, IDisposable } } - private void DrawUnusedFilesTab() - { - using var tab = ImRaii.TabItem( "Unused Files" ); - if( !tab ) - { - return; - } - - if( ImGui.Button( "Refresh" ) ) - { - _editor!.Dispose(); - _editor = new Mod.Editor( _mod! ); - } - - if( _editor!.UnusedFiles.Count == 0 ) - { - ImGui.NewLine(); - ImGui.TextUnformatted( "No unused files detected." ); - } - else - { - ImGui.SameLine(); - if( ImGui.Button( "Add Unused Files to Default" ) ) - { - _editor.AddUnusedPathsToDefault(); - } - - ImGui.SameLine(); - if( ImGui.Button( "Delete Unused Files from Filesystem" ) ) - { - _editor.DeleteUnusedPaths(); - } - - using var child = ImRaii.Child( "##unusedFiles", -Vector2.One, true ); - if( !child ) - { - return; - } - - using var table = ImRaii.Table( "##table", 1, ImGuiTableFlags.RowBg ); - if( !table ) - { - return; - } - - foreach( var path in _editor.UnusedFiles ) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted( path.FullName ); - } - } - } - - - private void DrawFileTab() - { - using var tab = ImRaii.TabItem( "File Redirections" ); - if( !tab ) - { - return; - } - - DrawOptionSelectHeader(); - using var child = ImRaii.Child( "##files", -Vector2.One, true ); - if( !child ) - { - return; - } - - using var list = ImRaii.Table( "##table", 2 ); - if( !list ) - { - return; - } - - foreach( var (gamePath, file) in _editor!.CurrentFiles ) - { - ImGui.TableNextColumn(); - ConfigWindow.Text( gamePath.Path ); - ImGui.TableNextColumn(); - ImGui.TextUnformatted( file.FullName ); - } - } - private string _newSwapKey = string.Empty; private string _newSwapValue = string.Empty; diff --git a/Penumbra/UI/ConfigWindow.Misc.cs b/Penumbra/UI/ConfigWindow.Misc.cs index d9ab33e9..17cc424d 100644 --- a/Penumbra/UI/ConfigWindow.Misc.cs +++ b/Penumbra/UI/ConfigWindow.Misc.cs @@ -28,6 +28,12 @@ public partial class ConfigWindow private static unsafe void Text( ResourceHandle* resource ) => Text( resource->FileName(), resource->FileNameLength ); + // Draw a Utf8String as a selectable. + internal static unsafe bool Selectable( Utf8String s, bool selected ) + { + var tmp = ( byte )( selected ? 1 : 0 ); + return ImGuiNative.igSelectable_Bool( s.Path, tmp, ImGuiSelectableFlags.None, Vector2.Zero ) != 0; + } // Apply Changed Item Counters to the Name if necessary. private static string ChangedItemName( string name, object? data )