diff --git a/StabilityMatrix/CheckpointManagerPage.xaml b/StabilityMatrix/CheckpointManagerPage.xaml index 58ae9128..051698a0 100644 --- a/StabilityMatrix/CheckpointManagerPage.xaml +++ b/StabilityMatrix/CheckpointManagerPage.xaml @@ -5,6 +5,8 @@ d:DesignHeight="1000" d:DesignWidth="650" mc:Ignorable="d" + ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" + ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}" x:Class="StabilityMatrix.CheckpointManagerPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:converters="clr-namespace:StabilityMatrix.Converters" @@ -13,6 +15,7 @@ xmlns:i="http://schemas.microsoft.com/xaml/behaviors" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="clr-namespace:StabilityMatrix.Models" + xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> @@ -26,6 +29,10 @@ + + + + + + + + + + + - + - + - + + + - + - + - + + + + - + + + + + + + + + + + + + + + + + + + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> - - + + - - - - - - - - + + + + + + + + - + + Margin="8" + Padding="8,8,8,16" + Visibility="{Binding IsCategoryEnabled, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Visible}"> @@ -281,17 +359,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + Margin="8,0,8,8" /> diff --git a/StabilityMatrix/DesignData/MockCheckpointFolder.cs b/StabilityMatrix/DesignData/MockCheckpointFolder.cs new file mode 100644 index 00000000..e2dab9ff --- /dev/null +++ b/StabilityMatrix/DesignData/MockCheckpointFolder.cs @@ -0,0 +1,10 @@ +using StabilityMatrix.Models; + +namespace StabilityMatrix.DesignData; + +public class MockCheckpointFolder : CheckpointFolder +{ + public MockCheckpointFolder() : base(null!, null!, useCategoryVisibility: false) + { + } +} diff --git a/StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs b/StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs index 9c2ed59e..416cfd31 100644 --- a/StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs +++ b/StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs @@ -7,26 +7,26 @@ namespace StabilityMatrix.DesignData; [DesignOnly(true)] public class MockCheckpointManagerViewModel : CheckpointManagerViewModel { - public MockCheckpointManagerViewModel() : base(null!, null!) + public MockCheckpointManagerViewModel() : base(null!, null!, null!) { CheckpointFolders = new() { - new() + new MockCheckpointFolder { Title = "Stable Diffusion", CheckpointFiles = new() { - new() + new(null!) { Title = "Stable Diffusion v1.5", FilePath = "v1-5-pruned-emaonly.safetensors", }, - new() + new(null!) { Title = "Scenery Mix", FilePath = "scenery-mix.pt", }, - new() + new(null!) { Title = "Some Model", FilePath = "exr-v3.safetensors", @@ -42,13 +42,13 @@ public class MockCheckpointManagerViewModel : CheckpointManagerViewModel } } }, - new() + new(null!) { Title = "Painting e12", FilePath = "painting-e12.pt", ConnectedModel = new() { - ModelName = "Long Name Model (Stuff)", + ModelName = "Long Name Model (Stuff / More Content)", VersionName = "v42-Advanced-Hybrid", ModelDescription = "Example Description", BaseModel = "SD 2.0", @@ -60,18 +60,18 @@ public class MockCheckpointManagerViewModel : CheckpointManagerViewModel }, } }, - new() + new MockCheckpointFolder { Title = "Lora", IsCurrentDragTarget = true, CheckpointFiles = new() { - new() + new(null!) { Title = "Detail Tweaker LoRA", FilePath = "add_detail.safetensors", }, - new() + new(null!) { Title = "Armor Suit LoRa", FilePath = "ArmorSuit_v1.safetensors", diff --git a/StabilityMatrix/Helper/DialogFactory.cs b/StabilityMatrix/Helper/DialogFactory.cs index df532ad5..fb7b7c79 100644 --- a/StabilityMatrix/Helper/DialogFactory.cs +++ b/StabilityMatrix/Helper/DialogFactory.cs @@ -1,8 +1,15 @@ using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; using StabilityMatrix.Models; using StabilityMatrix.Services; using StabilityMatrix.ViewModels; using Wpf.Ui.Contracts; +using Wpf.Ui.Controls; +using Wpf.Ui.Controls.ContentDialogControl; +using TextBox = Wpf.Ui.Controls.TextBox; namespace StabilityMatrix.Helper; @@ -37,6 +44,84 @@ public class DialogFactory : IDialogFactory return new LaunchOptionsDialog(contentDialogService, launchOptionsDialogViewModel); } + /// + /// Creates a dialog that allows the user to enter text for each field name. + /// Return a list of strings that correspond to the field names. + /// If cancel is pressed, return null. + /// List of (fieldName, placeholder) + /// + public async Task?> ShowTextEntryDialog(string title, + IEnumerable<(string, string)> fields, + string closeButtonText = "Cancel", + string saveButtonText = "Save") + { + var dialog = contentDialogService.CreateDialog(); + dialog.Title = title; + dialog.PrimaryButtonAppearance = ControlAppearance.Primary; + dialog.CloseButtonText = closeButtonText; + dialog.PrimaryButtonText = saveButtonText; + dialog.IsPrimaryButtonEnabled = true; + + var textBoxes = new List(); + var stackPanel = new StackPanel(); + dialog.Content = stackPanel; + + foreach (var (fieldName, fieldPlaceholder) in fields) + { + var textBox = new TextBox + { + PlaceholderText = fieldPlaceholder, + PlaceholderEnabled = true, + MinWidth = 200, + }; + textBoxes.Add(textBox); + stackPanel.Children.Add(new Card + { + Content = new StackPanel + { + Children = + { + new TextBlock + { + Text = fieldName, + Margin = new Thickness(0, 0, 0, 4) + }, + textBox + } + }, + Margin = new Thickness(16) + }); + } + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + return textBoxes.Select(x => x.Text).ToList(); + } + return null; + } + + /// + /// Creates and shows a confirmation dialog. + /// Return true if the user clicks the primary button. + /// + public async Task ShowConfirmationDialog(string title, string message, string closeButtonText = "Cancel", string primaryButtonText = "Confirm") + { + var dialog = contentDialogService.CreateDialog(); + dialog.Title = title; + dialog.PrimaryButtonAppearance = ControlAppearance.Primary; + dialog.CloseButtonText = closeButtonText; + dialog.PrimaryButtonText = primaryButtonText; + dialog.IsPrimaryButtonEnabled = true; + dialog.Content = new TextBlock + { + Text = message, + Margin = new Thickness(16) + }; + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary; + } + public OneClickInstallDialog CreateOneClickInstallDialog() { return new OneClickInstallDialog(contentDialogService, oneClickInstallViewModel); diff --git a/StabilityMatrix/Helper/IDialogFactory.cs b/StabilityMatrix/Helper/IDialogFactory.cs index 16b5ea22..9276f52e 100644 --- a/StabilityMatrix/Helper/IDialogFactory.cs +++ b/StabilityMatrix/Helper/IDialogFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using StabilityMatrix.Models; namespace StabilityMatrix.Helper; @@ -8,4 +9,6 @@ public interface IDialogFactory LaunchOptionsDialog CreateLaunchOptionsDialog(IEnumerable definitions, InstalledPackage installedPackage); InstallerWindow CreateInstallerWindow(); OneClickInstallDialog CreateOneClickInstallDialog(); + Task?> ShowTextEntryDialog(string title, IEnumerable<(string, string)> fieldNames, + string closeButtonText = "Cancel", string saveButtonText = "Save"); } diff --git a/StabilityMatrix/Helper/ISettingsManager.cs b/StabilityMatrix/Helper/ISettingsManager.cs index 4c5537d2..b9c72db1 100644 --- a/StabilityMatrix/Helper/ISettingsManager.cs +++ b/StabilityMatrix/Helper/ISettingsManager.cs @@ -40,4 +40,6 @@ public interface ISettingsManager void SetModelsDirectory(string? directory); void SetFirstLaunchSetupComplete(bool firstLaunchSetupCompleted); void SetModelBrowserNsfwEnabled(bool value); + void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible); + bool IsSharedFolderCategoryVisible(SharedFolderType type); } diff --git a/StabilityMatrix/Models/CheckpointFile.cs b/StabilityMatrix/Models/CheckpointFile.cs index 3a47c641..caaa29f8 100644 --- a/StabilityMatrix/Models/CheckpointFile.cs +++ b/StabilityMatrix/Models/CheckpointFile.cs @@ -1,60 +1,83 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Diagnostics; +using System.Drawing; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Windows; using System.Windows.Media.Imaging; +using System.Windows.Threading; +using AsyncAwaitBestPractices; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NLog; using StabilityMatrix.Extensions; +using StabilityMatrix.Helper; namespace StabilityMatrix.Models; public partial class CheckpointFile : ObservableObject { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private readonly IDialogFactory dialogFactory; + // Event for when this file is deleted public event EventHandler? Deleted; /// /// Absolute path to the checkpoint file. /// - public string FilePath { get; init; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FileName))] + private string filePath = string.Empty; /// /// Custom title for UI. /// - [ObservableProperty] private string title = string.Empty; + [ObservableProperty] + private string title = string.Empty; public string? PreviewImagePath { get; set; } public BitmapImage? PreviewImage { get; set; } public bool IsPreviewImageLoaded => PreviewImage != null; - [ObservableProperty] private ConnectedModelInfo? connectedModel; + [ObservableProperty] + private ConnectedModelInfo? connectedModel; public bool IsConnectedModel => ConnectedModel != null; - - [ObservableProperty] private string fpType = string.Empty; - [ObservableProperty] private string baseModel = string.Empty; [ObservableProperty] private bool isLoading; public string FileName => Path.GetFileName(FilePath); + public ObservableCollection Badges { get; set; } = new(); + private static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", "bin" }; private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg" }; private static readonly string[] SupportedMetadataExtensions = { ".json" }; + + public CheckpointFile(IDialogFactory dialogFactory) + { + this.dialogFactory = dialogFactory; + } partial void OnConnectedModelChanged(ConnectedModelInfo? value) { - if (value == null) return; // Update title, first check user defined, then connected model name - Title = value.UserTitle ?? value.ModelName; - // Update fp type and base model - FpType = value.FileMetadata.Fp?.GetStringValue().ToUpperInvariant() ?? ""; - BaseModel = value.BaseModel ?? ""; + Title = value?.UserTitle ?? value?.ModelName ?? string.Empty; + // Update badges + Badges.Clear(); + var fpType = value.FileMetadata.Fp?.GetStringValue().ToUpperInvariant(); + if (fpType != null) + { + Badges.Add(fpType); + } + if (!string.IsNullOrWhiteSpace(value.BaseModel)) + { + Badges.Add(value.BaseModel); + } } [RelayCommand] @@ -62,33 +85,69 @@ public partial class CheckpointFile : ObservableObject { if (File.Exists(FilePath)) { - // Start progress ring IsLoading = true; - var timer = Stopwatch.StartNew(); try { + await using var delay = new MinimumDelay(200, 500); await Task.Run(() => File.Delete(FilePath)); if (PreviewImagePath != null && File.Exists(PreviewImagePath)) { await Task.Run(() => File.Delete(PreviewImagePath)); } - // If it was too fast, wait a bit to show progress ring - var targetDelay = new Random().Next(200, 500); - var elapsed = timer.ElapsedMilliseconds; - if (elapsed < targetDelay) - { - await Task.Delay(targetDelay - (int) elapsed); - } } - catch (IOException e) + catch (IOException ex) { - Logger.Error(e, $"Failed to delete checkpoint file: {FilePath}"); - IsLoading = false; + Logger.Warn($"Failed to delete checkpoint file {FilePath}: {ex.Message}"); return; // Don't delete from collection } + finally + { + IsLoading = false; + } } Deleted?.Invoke(this, this); - } + } + + [RelayCommand] + private async Task RenameAsync() + { + var responses = await dialogFactory.ShowTextEntryDialog("Rename Model", new [] + { + ("File Name", FileName) + }); + var name = responses?.FirstOrDefault(); + if (name == null) return; + + // Rename file in OS + try + { + var newFilePath = Path.Combine(Path.GetDirectoryName(FilePath) ?? "", name); + File.Move(FilePath, newFilePath); + FilePath = newFilePath; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + // Loads image from path + private async Task LoadPreviewImage() + { + if (PreviewImagePath == null) return; + var bytes = await File.ReadAllBytesAsync(PreviewImagePath); + await Application.Current.Dispatcher.InvokeAsync(() => + { + var bitmap = new BitmapImage(); + using var ms = new MemoryStream(bytes); + bitmap.BeginInit(); + bitmap.StreamSource = ms; + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + PreviewImage = bitmap; + }); + } /// /// Indexes directory and yields all checkpoint files. @@ -97,7 +156,7 @@ public partial class CheckpointFile : ObservableObject /// - {filename}.preview.{image-extensions} (preview image) /// - {filename}.cm-info.json (connected model info) /// - public static IEnumerable FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly) + public static IEnumerable FromDirectoryIndex(IDialogFactory dialogFactory, string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly) { // Get all files with supported extensions var allExtensions = SupportedCheckpointExtensions @@ -110,7 +169,7 @@ public partial class CheckpointFile : ObservableObject foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k)))) { - var checkpointFile = new CheckpointFile + var checkpointFile = new CheckpointFile(dialogFactory) { Title = Path.GetFileNameWithoutExtension(file), FilePath = Path.Combine(directory, file), @@ -137,6 +196,7 @@ public partial class CheckpointFile : ObservableObject if (previewImage != null) { checkpointFile.PreviewImagePath = Path.Combine(directory, previewImage); + checkpointFile.LoadPreviewImage().SafeFireAndForget(); } yield return checkpointFile; @@ -146,11 +206,11 @@ public partial class CheckpointFile : ObservableObject /// /// Index with progress reporting. /// - public static IEnumerable FromDirectoryIndex(string directory, IProgress progress, + public static IEnumerable FromDirectoryIndex(IDialogFactory dialogFactory, string directory, IProgress progress, SearchOption searchOption = SearchOption.TopDirectoryOnly) { var current = 0ul; - foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption)) + foreach (var checkpointFile in FromDirectoryIndex(dialogFactory, directory, searchOption)) { current++; progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName)); diff --git a/StabilityMatrix/Models/CheckpointFolder.cs b/StabilityMatrix/Models/CheckpointFolder.cs index 705c808c..65c6567a 100644 --- a/StabilityMatrix/Models/CheckpointFolder.cs +++ b/StabilityMatrix/Models/CheckpointFolder.cs @@ -15,15 +15,33 @@ namespace StabilityMatrix.Models; public partial class CheckpointFolder : ObservableObject { + private readonly IDialogFactory dialogFactory; + private readonly ISettingsManager settingsManager; + // ReSharper disable once FieldCanBeMadeReadOnly.Local + private bool useCategoryVisibility; + /// /// Absolute path to the folder. /// public string DirectoryPath { get; init; } = string.Empty; - + /// /// Custom title for UI. /// - public string Title { get; init; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FolderType))] + [NotifyPropertyChangedFor(nameof(TitleWithFilesCount))] + private string title = string.Empty; + + private SharedFolderType FolderType => Enum.TryParse(Title, out SharedFolderType type) + ? type + : new SharedFolderType(); + + /// + /// True if the category is enabled for the manager page. + /// + [ObservableProperty] + private bool isCategoryEnabled = true; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))] @@ -32,24 +50,51 @@ public partial class CheckpointFolder : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))] private bool isImportInProgress; - + public bool IsDragBlurEnabled => IsCurrentDragTarget || IsImportInProgress; + public string TitleWithFilesCount => CheckpointFiles.Any() ? $"{Title} ({CheckpointFiles.Count})" : Title; public ProgressViewModel Progress { get; } = new(); - public ObservableCollection CheckpointFiles { get; set; } = new(); + public ObservableCollection CheckpointFiles { get; init; } = new(); public RelayCommand OnPreviewDragEnterCommand => new(() => IsCurrentDragTarget = true); public RelayCommand OnPreviewDragLeaveCommand => new(() => IsCurrentDragTarget = false); - public CheckpointFolder() + public CheckpointFolder(IDialogFactory dialogFactory, ISettingsManager settingsManager, bool useCategoryVisibility = true) { + this.dialogFactory = dialogFactory; + this.settingsManager = settingsManager; + this.useCategoryVisibility = useCategoryVisibility; CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged; } + /// + /// When title is set, set the category enabled state from settings. + /// + // ReSharper disable once UnusedParameterInPartialMethod + partial void OnTitleChanged(string value) + { + if (!useCategoryVisibility) return; + IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType); + } + + /// + /// When toggling the category enabled state, save it to settings. + /// + partial void OnIsCategoryEnabledChanged(bool value) + { + if (!useCategoryVisibility) return; + if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType)) + { + settingsManager.SetSharedFolderCategoryVisible(FolderType, value); + } + } + // On collection changes private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e) { + OnPropertyChanged(nameof(TitleWithFilesCount)); if (e.NewItems == null) return; // On new added items, add event handler for deletion foreach (CheckpointFile item in e.NewItems) @@ -73,9 +118,8 @@ public partial class CheckpointFolder : ObservableObject { IsImportInProgress = true; IsCurrentDragTarget = false; - - var files = e.Data.GetData(DataFormats.FileDrop) as string[]; - if (files == null || files.Length < 1) + + if (e.Data.GetData(DataFormats.FileDrop) is not string[] files || files.Length < 1) { IsImportInProgress = false; return; @@ -129,8 +173,8 @@ public partial class CheckpointFolder : ObservableObject { var checkpointFiles = await (progress switch { - null => Task.Run(() => CheckpointFile.FromDirectoryIndex(DirectoryPath)), - _ => Task.Run(() => CheckpointFile.FromDirectoryIndex(DirectoryPath, progress)) + null => Task.Run(() => CheckpointFile.FromDirectoryIndex(dialogFactory, DirectoryPath)), + _ => Task.Run(() => CheckpointFile.FromDirectoryIndex(dialogFactory, DirectoryPath, progress)) }); CheckpointFiles.Clear(); diff --git a/StabilityMatrix/Models/Settings.cs b/StabilityMatrix/Models/Settings.cs index 4d716d9e..6ddb3f71 100644 --- a/StabilityMatrix/Models/Settings.cs +++ b/StabilityMatrix/Models/Settings.cs @@ -23,6 +23,11 @@ public class Settings public string ModelsDirectory { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix", "Models"); public bool ModelBrowserNsfwEnabled { get; set; } + public SharedFolderType? SharedFolderVisibleCategories { get; set; } = + SharedFolderType.StableDiffusion | + SharedFolderType.Lora | + SharedFolderType.LyCORIS; + public InstalledPackage? GetActiveInstalledPackage() { return InstalledPackages.FirstOrDefault(x => x.Id == ActiveInstalledPackage); diff --git a/StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs b/StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs index a4261c04..3fda6787 100644 --- a/StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs +++ b/StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs @@ -15,12 +15,14 @@ public partial class CheckpointManagerViewModel : ObservableObject private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly ISharedFolders sharedFolders; private readonly ISettingsManager settingsManager; + private readonly IDialogFactory dialogFactory; public ObservableCollection CheckpointFolders { get; set; } = new(); - public CheckpointManagerViewModel(ISharedFolders sharedFolders, ISettingsManager settingsManager) + public CheckpointManagerViewModel(ISharedFolders sharedFolders, ISettingsManager settingsManager, IDialogFactory dialogFactory) { this.sharedFolders = sharedFolders; this.settingsManager = settingsManager; + this.dialogFactory = dialogFactory; } public async Task OnLoaded() @@ -46,7 +48,11 @@ public partial class CheckpointManagerViewModel : ObservableObject // Index all folders var tasks = folders.Select(f => Task.Run(async () => { - var checkpointFolder = new CheckpointFolder {Title = Path.GetFileName(f), DirectoryPath = f}; + var checkpointFolder = new CheckpointFolder(dialogFactory, settingsManager) + { + Title = Path.GetFileName(f), + DirectoryPath = f + }; await checkpointFolder.IndexAsync(); indexedFolders.Add(checkpointFolder); })).ToList(); diff --git a/StabilityMatrix/ViewModels/TextToImageViewModel.cs b/StabilityMatrix/ViewModels/TextToImageViewModel.cs index 05790b40..6c4c52e1 100644 --- a/StabilityMatrix/ViewModels/TextToImageViewModel.cs +++ b/StabilityMatrix/ViewModels/TextToImageViewModel.cs @@ -103,7 +103,7 @@ public partial class TextToImageViewModel : ObservableObject logger.LogWarning("Skipped model folder index - {SdModelsDir} does not exist", sdModelsDir); return; } - DiffusionCheckpointFolder = new CheckpointFolder + DiffusionCheckpointFolder = new CheckpointFolder(null!, null!) // TODO: refactor to not use view models { Title = Path.GetFileName(sdModelsDir), DirectoryPath = sdModelsDir