diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index 0258b36b..fb90f374 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -60,6 +60,7 @@ + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.cs b/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.cs new file mode 100644 index 00000000..3a1ca335 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/SelectableImageCard/SelectableImageButton.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls; + +namespace StabilityMatrix.Avalonia.Controls.SelectableImageCard; + +public class SelectableImageButton : Button +{ + public static readonly StyledProperty IsSelectedProperty = + CheckBox.IsCheckedProperty.AddOwner(); + + public static readonly StyledProperty SourceProperty = + BetterAdvancedImage.SourceProperty.AddOwner(); + + public bool? IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + public string? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } +} diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 4b4d4599..068466b3 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -7,6 +7,7 @@ using System.IO; using System.Net.Http; using System.Text; using AvaloniaEdit.Utils; +using DynamicData.Binding; using Microsoft.Extensions.DependencyInjection; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls.CodeCompletion; @@ -20,6 +21,7 @@ using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Progress; using StabilityMatrix.Avalonia.ViewModels.Inference; +using StabilityMatrix.Avalonia.ViewModels.OutputsPage; using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Database; @@ -29,6 +31,7 @@ using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.Comfy; +using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Progress; using StabilityMatrix.Core.Python; @@ -336,8 +339,27 @@ public static class DesignData public static LaunchPageViewModel LaunchPageViewModel => Services.GetRequiredService(); - public static OutputsPageViewModel OutputsPageViewModel => - Services.GetRequiredService(); + public static OutputsPageViewModel OutputsPageViewModel + { + get + { + var vm = Services.GetRequiredService(); + vm.Outputs = new ObservableCollectionExtended + { + new( + new LocalImageFile + { + AbsolutePath = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg", + RelativePath = + "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/78fd2a0a-42b6-42b0-9815-81cb11bb3d05/00009-2423234823.jpeg", + ImageType = LocalImageFileType.TextToImage + } + ) + }; + return vm; + } + } public static PackageManagerViewModel PackageManagerViewModel { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index ab1c7e63..336977f1 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -257,6 +257,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to Open in Image Viewer. + /// + public static string Action_OpenInViewer { + get { + return ResourceManager.GetString("Action_OpenInViewer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open on CivitAI. /// @@ -1157,6 +1166,15 @@ namespace StabilityMatrix.Avalonia.Languages { } } + /// + /// Looks up a localized string similar to {0} images selected. + /// + public static string Label_NumImagesSelected { + get { + return ResourceManager.GetString("Label_NumImagesSelected", resourceCulture); + } + } + /// /// Looks up a localized string similar to Only available on Windows. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index a0fd01c3..05d6781e 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -687,4 +687,10 @@ Copy - \ No newline at end of file + + Open in Image Viewer + + + {0} images selected + + diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPage/OutputImageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPage/OutputImageViewModel.cs new file mode 100644 index 00000000..1102d0ab --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPage/OutputImageViewModel.cs @@ -0,0 +1,19 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Models.Database; + +namespace StabilityMatrix.Avalonia.ViewModels.OutputsPage; + +public partial class OutputImageViewModel : ViewModelBase +{ + public LocalImageFile ImageFile { get; } + + [ObservableProperty] + private bool isSelected; + + public OutputImageViewModel(LocalImageFile imageFile) + { + ImageFile = imageFile; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index 209fd099..4e07140d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -4,21 +4,24 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Reactive.Linq; +using System.Threading; using System.Threading.Tasks; using AsyncAwaitBestPractices; using AsyncImageLoader; +using Avalonia; using Avalonia.Controls; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using DynamicData; using DynamicData.Binding; using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.Logging; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; -using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Avalonia.ViewModels.OutputsPage; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Factory; @@ -32,21 +35,23 @@ using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels; -[View(typeof(OutputsPage))] +[View(typeof(Views.OutputsPage))] public partial class OutputsPageViewModel : PageViewModelBase { private readonly ISettingsManager settingsManager; private readonly INotificationService notificationService; private readonly INavigationService navigationService; + private readonly ILogger logger; public override string Title => "Outputs"; public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Grid, IsFilled = true }; - public SourceCache OutputsCache { get; } = new(p => p.AbsolutePath); + public SourceCache OutputsCache { get; } = + new(p => p.ImageFile.AbsolutePath); - public IObservableCollection Outputs { get; } = - new ObservableCollectionExtended(); + public IObservableCollection Outputs { get; set; } = + new ObservableCollectionExtended(); public IEnumerable OutputTypes { get; } = Enum.GetValues(); @@ -60,25 +65,35 @@ public partial class OutputsPageViewModel : PageViewModelBase [ObservableProperty] private SharedOutputType selectedOutputType; + [ObservableProperty] + private int numItemsSelected; + public bool CanShowOutputTypes => SelectedCategory.Name.Equals("Shared Output Folder"); public OutputsPageViewModel( ISettingsManager settingsManager, IPackageFactory packageFactory, INotificationService notificationService, - INavigationService navigationService + INavigationService navigationService, + ILogger logger ) { this.settingsManager = settingsManager; this.notificationService = notificationService; this.navigationService = navigationService; + this.logger = logger; OutputsCache .Connect() .DeferUntilLoaded() - .SortBy(x => x.CreatedAt, SortDirection.Descending) + .SortBy(x => x.ImageFile.CreatedAt, SortDirection.Descending) + .ObserveOn(SynchronizationContext.Current) .Bind(Outputs) - .Subscribe(); + .WhenPropertyChanged(p => p.IsSelected) + .Subscribe(_ => + { + NumItemsSelected = Outputs.Count(o => o.IsSelected); + }); if (!settingsManager.IsLibraryDirSet || Design.IsDesignMode) return; @@ -152,16 +167,29 @@ public partial class OutputsPageViewModel : PageViewModelBase GetOutputs(path); } - public async Task OnImageClick(LocalImageFile item) + public async Task OnImageClick(OutputImageViewModel item) + { + // Select image if we're in "select mode" + if (NumItemsSelected > 0) + { + item.IsSelected = !item.IsSelected; + } + else + { + await ShowImageDialog(item); + } + } + + public async Task ShowImageDialog(OutputImageViewModel item) { var currentIndex = Outputs.IndexOf(item); - var image = new ImageSource(new FilePath(item.AbsolutePath)); + var image = new ImageSource(new FilePath(item.ImageFile.AbsolutePath)); // Preload await image.GetBitmapAsync(); - var vm = new ImageViewerViewModel { ImageSource = image, LocalImageFile = item }; + var vm = new ImageViewerViewModel { ImageSource = image, LocalImageFile = item.ImageFile }; using var onNext = Observable .FromEventPattern( @@ -180,14 +208,14 @@ public partial class OutputsPageViewModel : PageViewModelBase { var newImage = Outputs[newIndex]; var newImageSource = new ImageSource( - new FilePath(newImage.AbsolutePath) + new FilePath(newImage.ImageFile.AbsolutePath) ); // Preload await newImageSource.GetBitmapAsync(); sender.ImageSource = newImageSource; - sender.LocalImageFile = newImage; + sender.LocalImageFile = newImage.ImageFile; currentIndex = newIndex; } @@ -207,9 +235,9 @@ public partial class OutputsPageViewModel : PageViewModelBase public async Task OpenImage(string imagePath) => await ProcessRunner.OpenFileBrowser(imagePath); - public async Task DeleteImage(LocalImageFile? item) + public async Task DeleteImage(OutputImageViewModel? item) { - if (item?.GetFullPath(settingsManager.ImagesDirectory) is not { } imagePath) + if (item?.ImageFile.GetFullPath(settingsManager.ImagesDirectory) is not { } imagePath) { return; } @@ -223,7 +251,7 @@ public partial class OutputsPageViewModel : PageViewModelBase return; } - Outputs.Remove(item); + OutputsCache.Remove(item); // Invalidate cache if (ImageLoader.AsyncImageLoader is FallbackRamCachedWebImageLoader loader) @@ -244,6 +272,14 @@ public partial class OutputsPageViewModel : PageViewModelBase EventManager.Instance.OnInferenceUpscaleRequested(image); } + public void ClearSelection() + { + foreach (var output in Outputs) + { + output.IsSelected = false; + } + } + private void GetOutputs(string directory) { if (!settingsManager.IsLibraryDirSet) @@ -257,9 +293,9 @@ public partial class OutputsPageViewModel : PageViewModelBase var list = Directory .EnumerateFiles(directory, "*.png", SearchOption.AllDirectories) - .Select(file => LocalImageFile.FromPath(file)) - .OrderByDescending(f => f.CreatedAt); + .Select(file => new OutputImageViewModel(LocalImageFile.FromPath(file))) + .OrderByDescending(f => f.ImageFile.CreatedAt); - OutputsCache.EditDiff(list, (x, y) => x.AbsolutePath == y.AbsolutePath); + OutputsCache.EditDiff(list, (x, y) => x.ImageFile.AbsolutePath == y.ImageFile.AbsolutePath); } } diff --git a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml index 1c200b5d..32a86148 100644 --- a/StabilityMatrix.Avalonia/Views/OutputsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/OutputsPage.axaml @@ -13,7 +13,8 @@ xmlns:models1="clr-namespace:StabilityMatrix.Avalonia.Models" xmlns:database="clr-namespace:StabilityMatrix.Core.Models.Database;assembly=StabilityMatrix.Core" xmlns:fluentAvalonia="clr-namespace:FluentIcons.FluentAvalonia;assembly=FluentIcons.FluentAvalonia" - xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" + xmlns:outputsPage="clr-namespace:StabilityMatrix.Avalonia.ViewModels.OutputsPage" + xmlns:selectableImageCard="clr-namespace:StabilityMatrix.Avalonia.Controls.SelectableImageCard" d:DataContext="{x:Static mocks:DesignData.OutputsPageViewModel}" d:DesignHeight="450" d:DesignWidth="700" @@ -24,8 +25,9 @@ - + @@ -48,13 +50,31 @@ - + + VerticalContentAlignment="Center" /> + + + + + + + + +