From 6d0d331937e7939de021af83d08dc56acdceefad Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 13 Mar 2024 19:22:58 -0400 Subject: [PATCH 001/239] Add PenPath models --- .../Controls/Models/PenPath.cs | 18 ++++++++++++++++++ .../Controls/Models/PenPoint.cs | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Controls/Models/PenPath.cs create mode 100644 StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs new file mode 100644 index 000000000..fe39096bc --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using SkiaSharp; + +namespace StabilityMatrix.Avalonia.Controls.Models; + +public readonly record struct PenPath +{ + public PenPath(SKPath path) + { + Path = path; + } + + public SKPath Path { get; init; } + + public SKColor FillColor { get; init; } + + public List Points { get; init; } = []; +} diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs new file mode 100644 index 000000000..e86d7b43f --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs @@ -0,0 +1,19 @@ +using SkiaSharp; + +namespace StabilityMatrix.Avalonia.Controls.Models; + +public readonly record struct PenPoint +{ + public PenPoint(SKPoint point, double radius = 1, double? pressure = null) + { + Point = point; + Radius = radius; + Pressure = pressure; + } + + public SKPoint Point { get; init; } + + public double Radius { get; init; } + + public double? Pressure { get; init; } +} From 30a15d4d28dc4bcb3a9b7bd8069f8a68cf910dc5 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 13 Mar 2024 19:28:18 -0400 Subject: [PATCH 002/239] Add SkiaCustomCanvas --- .../Controls/SkiaCustomCanvas.axaml | 7 ++ .../Controls/SkiaCustomCanvas.axaml.cs | 72 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml create mode 100644 StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml.cs diff --git a/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml b/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml new file mode 100644 index 000000000..03f0bc853 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml @@ -0,0 +1,7 @@ + + diff --git a/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml.cs b/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml.cs new file mode 100644 index 000000000..0d826aaed --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/SkiaCustomCanvas.axaml.cs @@ -0,0 +1,72 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; + +namespace StabilityMatrix.Avalonia.Controls; + +public partial class SkiaCustomCanvas : UserControl +{ + private readonly RenderingLogic renderingLogic; + + public event Action? RenderSkia; + + public SkiaCustomCanvas() + { + InitializeComponent(); + + Background = Brushes.Transparent; + + renderingLogic = new RenderingLogic(); + renderingLogic.RenderCall += canvas => RenderSkia?.Invoke(canvas); + } + + public override void Render(DrawingContext context) + { + renderingLogic.Bounds = new Rect(0, 0, Bounds.Width, Bounds.Height); + + context.Custom(renderingLogic); + } + + private class RenderingLogic : ICustomDrawOperation + { + public Action? RenderCall; + + public Rect Bounds { get; set; } + + public void Dispose() { } + + public bool Equals(ICustomDrawOperation? other) + { + return other == this; + } + + /// + public bool HitTest(Point p) + { + return false; + } + + /// + public void Render(ImmediateDrawingContext context) + { + var skia = context.TryGetFeature(); + + using var lease = skia?.Lease(); + + if (lease?.SkCanvas is { } skCanvas) + { + Render(skCanvas); + } + } + + private void Render(SKCanvas canvas) + { + RenderCall?.Invoke(canvas); + } + } +} From a41b7b299b449de6050cbd7de740d1edaa8a514c Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 13 Mar 2024 19:28:35 -0400 Subject: [PATCH 003/239] Add skip namespace for Controls/Painting --- .../StabilityMatrix.Avalonia.csproj.DotSettings | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings index ef2549252..e6b33dfed 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj.DotSettings @@ -2,4 +2,5 @@ Yes Pessimistic UI + True True From 51116c6a531f638e3106bf7f9659d9c3e486472e Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 13 Mar 2024 19:30:06 -0400 Subject: [PATCH 004/239] Add PaintCanvas --- StabilityMatrix.Avalonia/App.axaml | 1 + .../Controls/Painting/PaintCanvas.axaml | 57 ++++ .../Controls/Painting/PaintCanvas.axaml.cs | 319 ++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml create mode 100644 StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index fa6699623..3eab3efaa 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -81,6 +81,7 @@ + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs new file mode 100644 index 000000000..68fcf8b08 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs @@ -0,0 +1,319 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Skia; +using Avalonia.Threading; +using SkiaSharp; +using StabilityMatrix.Avalonia.Controls.Models; + +namespace StabilityMatrix.Avalonia.Controls; + +public class PaintCanvas : TemplatedControl +{ + private readonly ConcurrentDictionary temporaryPaths = new(); + private ImmutableList paths = []; + + private bool isPenDown; + private SKColor currentBrushColor = Colors.White.ToSKColor(); + + private SkiaCustomCanvas? MainCanvas { get; set; } + + public static readonly StyledProperty PaintBrushColorProperty = AvaloniaProperty.Register< + PaintCanvas, + Color? + >(nameof(PaintBrushColor), Colors.White); + + public Color? PaintBrushColor + { + get => GetValue(PaintBrushColorProperty); + set => SetValue(PaintBrushColorProperty, value); + } + + public static readonly StyledProperty CurrentPenPressureProperty = AvaloniaProperty.Register< + PaintCanvas, + float + >("CurrentPenPressure"); + + public float CurrentPenPressure + { + get => GetValue(CurrentPenPressureProperty); + set => SetValue(CurrentPenPressureProperty, value); + } + + static PaintCanvas() + { + AffectsRender(BoundsProperty); + } + + public PaintCanvas() + { + // AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == PaintBrushColorProperty) + { + currentBrushColor = (PaintBrushColor ?? Colors.Transparent).ToSKColor(); + } + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + MainCanvas = e.NameScope.Find("PART_MainCanvas"); + + Debug.Assert(MainCanvas != null); + + if (MainCanvas is not null) + { + MainCanvas.RenderSkia += OnRenderSkia; + } + } + + private void HandlePointerEvent(PointerEventArgs e) + { + var lastPointer = e.GetCurrentPoint(this); + + if (e.RoutedEvent == PointerReleasedEvent && e.Pointer.Type == PointerType.Touch) + { + temporaryPaths.TryRemove(e.Pointer.Id, out _); + return; + } + + // if (e.Pointer.Type != PointerType.Pen || lastPointer.Properties.Pressure > 0) + if (true) + { + e.Handled = true; + + // Must have this or stylus inputs lost after a while + // https://github.com/AvaloniaUI/Avalonia/issues/12289#issuecomment-1695620412 + + e.PreventGestureRecognition(); + + var currentPoint = e.GetCurrentPoint(this); + + if (e.RoutedEvent == PointerPressedEvent) + { + // Ignore if mouse and not left button + if (e.Pointer.Type == PointerType.Mouse && !currentPoint.Properties.IsLeftButtonPressed) + { + return; + } + + isPenDown = true; + + var cursorPosition = e.GetPosition(MainCanvas); + + // Start a new path + var path = new SKPath(); + path.MoveTo(cursorPosition.ToSKPoint()); + + temporaryPaths[e.Pointer.Id] = new PenPath(path) { FillColor = currentBrushColor }; + } + else if (e.RoutedEvent == PointerReleasedEvent) + { + if (isPenDown) + { + isPenDown = false; + } + + if (temporaryPaths.TryGetValue(e.Pointer.Id, out var path)) + { + paths = paths.Add(path); + } + + temporaryPaths.TryRemove(e.Pointer.Id, out _); + } + else + { + // Moved event + if (!isPenDown || currentPoint.Properties.Pressure == 0) + { + return; + } + + // Use intermediate points to include past events we missed + var points = e.GetIntermediatePoints(MainCanvas); + + CurrentPenPressure = points.FirstOrDefault().Properties.Pressure; + + // Get existing temp path + if (temporaryPaths.TryGetValue(e.Pointer.Id, out var penPath)) + { + var cursorPosition = e.GetPosition(MainCanvas); + + // Add line for path + penPath.Path.LineTo(cursorPosition.ToSKPoint()); + + // Add points + foreach (var point in points) + { + var skCanvasPoint = point.Position.ToSKPoint(); + + // penPath.Path.LineTo(skCanvasPoint); + + var penPoint = new PenPoint(skCanvasPoint) { Pressure = point.Properties.Pressure }; + + penPath.Points.Add(penPoint); + } + } + } + + Dispatcher.UIThread.Post(() => MainCanvas!.InvalidateVisual(), DispatcherPriority.Render); + } + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + HandlePointerEvent(e); + base.OnPointerPressed(e); + } + + /// + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + HandlePointerEvent(e); + base.OnPointerReleased(e); + } + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + HandlePointerEvent(e); + base.OnPointerMoved(e); + } + + private Point GetRelativePosition(Point pt, Visual? relativeTo) + { + if (VisualRoot is not Visual visualRoot) + return default; + if (relativeTo == null) + return pt; + + return pt * visualRoot.TransformToVisual(relativeTo) ?? default; + } + + public void SaveCanvasToBitmap(Stream stream) + { + using var surface = SKSurface.Create(new SKImageInfo((int)Bounds.Width, (int)Bounds.Height)); + using var canvas = surface.Canvas; + + OnRenderSkia(canvas); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + data.SaveTo(stream); + } + + private static void RenderPenPath(SKCanvas canvas, PenPath penPath) + { + using var paint = new SKPaint(); + paint.Color = penPath.FillColor; + paint.IsDither = true; + paint.IsAntialias = true; + paint.StrokeWidth = 3; + paint.Style = SKPaintStyle.Stroke; + paint.StrokeCap = SKStrokeCap.Round; + + // Can't use foreach since this list may be modified during iteration + // ReSharper disable once ForCanBeConvertedToForeach + for (var i = 0; i < penPath.Points.Count; i++) + { + var penPoint = penPath.Points[i]; + + var pressure = penPoint.Pressure ?? 0.5; + var thickness = pressure * 10; + var radius = pressure * penPoint.Radius * 1.5; + + // Draw path + if (i < penPath.Points.Count - 1) + { + paint.StrokeWidth = (float)thickness; + canvas.DrawLine(penPoint.Point, penPath.Points[i + 1].Point, paint); + } + + // Only draw circle if pressure is high enough + if (penPoint.Pressure > 0.1) + { + canvas.DrawCircle(penPoint.Point, (float)radius, paint); + } + } + } + + public void OnRenderSkia(SKCanvas canvas) + { + // canvas.Clear(); + + SKPaint? paint = null; + + try + { + // Draw the paths + foreach (var penPath in temporaryPaths.Values) + { + RenderPenPath(canvas, penPath); + } + + foreach (var penPath in paths) + { + RenderPenPath(canvas, penPath); + } + + /*foreach (var penPath in temporaryPaths.Values) + { + if (paint?.Color != penPath.FillColor) + { + paint?.Dispose(); + + paint = new SKPaint(); + paint.Color = penPath.FillColor; + paint.IsDither = true; + paint.IsAntialias = true; + paint.StrokeWidth = 3; + paint.Style = SKPaintStyle.Stroke; + paint.StrokeCap = SKStrokeCap.Round; + } + + canvas.DrawPath(penPath.Path, paint); + } + + foreach (var penPath in paths) + { + if (paint?.Color != penPath.FillColor) + { + paint?.Dispose(); + + paint = new SKPaint(); + paint.Color = penPath.FillColor; + paint.IsDither = true; + paint.IsAntialias = true; + paint.StrokeWidth = 3; + paint.Style = SKPaintStyle.Stroke; + paint.StrokeCap = SKStrokeCap.Round; + } + + canvas.DrawPath(penPath.Path, paint); + }*/ + + canvas.Flush(); + } + finally + { + paint?.Dispose(); + } + } +} From 061bea05ec18d6e343d80baf7747cb0e85b41cad Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 13 Mar 2024 19:31:40 -0400 Subject: [PATCH 005/239] Fix binding --- StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml index bbcff0a7e..bab024dc1 100644 --- a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml +++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml @@ -22,7 +22,7 @@ - + - + + FontSize="20" /> From ae1180d4a299c39868c6e6fbec90401cab28e66b Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:01:20 -0400 Subject: [PATCH 067/239] CodeTimer output to console if not debug --- StabilityMatrix.Core/Helper/CodeTimer.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/StabilityMatrix.Core/Helper/CodeTimer.cs b/StabilityMatrix.Core/Helper/CodeTimer.cs index 204da6787..9eadd6bec 100644 --- a/StabilityMatrix.Core/Helper/CodeTimer.cs +++ b/StabilityMatrix.Core/Helper/CodeTimer.cs @@ -143,7 +143,11 @@ public void Stop(bool printOutput) // If we're a root timer, output all results if (printOutput) { +#if DEBUG OutputDebug(GetResult()); +#else + Console.WriteLine(GetResult()); +#endif SubTimers.Clear(); } } From 99c0b684b4c2dbdebe145f40d0f18b9f60498519 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:05:09 -0400 Subject: [PATCH 068/239] Move native file ops to conditional assembly --- .../StabilityMatrix.Core.csproj | 4 + .../INativeRecycleBinProvider.cs | 32 +++ .../NativeFileOperationFlags.cs | 47 ++++ ...StabilityMatrix.Native.Abstractions.csproj | 14 ++ .../FileOperations/FileOperationWrapper.cs | 222 ++++++++++++++++++ .../GlobalUsings.cs | 4 + .../Interop/ComReleaser.cs | 25 ++ .../Interop/FileOperationFlags.cs | 84 +++++++ .../Interop/FileOperationProgressSinkTcs.cs | 112 +++++++++ .../Interop/IFileOperation.cs | 53 +++++ .../Interop/IFileOperationProgressSink.cs | 73 ++++++ .../Interop/IShellItem.cs | 23 ++ .../Interop/IShellItemArray.cs | 37 +++ .../Interop/SIGDN.cs | 18 ++ .../NativeRecycleBinProvider.cs | 73 ++++++ .../StabilityMatrix.Native.Windows.csproj | 22 ++ .../NativeFileOperations.cs | 24 ++ .../StabilityMatrix.Native.csproj | 34 +++ .../Helper/WindowsFileOperationsTests.cs | 78 ------ .../Native/NativeRecycleBinProviderTests.cs | 124 ++++++++++ .../StabilityMatrix.Tests.csproj | 1 + StabilityMatrix.sln | 18 ++ 22 files changed, 1044 insertions(+), 78 deletions(-) create mode 100644 StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs create mode 100644 StabilityMatrix.Native.Abstractions/NativeFileOperationFlags.cs create mode 100644 StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj create mode 100644 StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs create mode 100644 StabilityMatrix.Native.Windows/GlobalUsings.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/ComReleaser.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/FileOperationFlags.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/FileOperationProgressSinkTcs.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/IFileOperation.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/IFileOperationProgressSink.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/IShellItem.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/IShellItemArray.cs create mode 100644 StabilityMatrix.Native.Windows/Interop/SIGDN.cs create mode 100644 StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs create mode 100644 StabilityMatrix.Native.Windows/StabilityMatrix.Native.Windows.csproj create mode 100644 StabilityMatrix.Native/NativeFileOperations.cs create mode 100644 StabilityMatrix.Native/StabilityMatrix.Native.csproj delete mode 100644 StabilityMatrix.Tests/Helper/WindowsFileOperationsTests.cs create mode 100644 StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 76fa56245..af920544e 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs b/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs new file mode 100644 index 000000000..1cb90f241 --- /dev/null +++ b/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs @@ -0,0 +1,32 @@ +namespace StabilityMatrix.Native.Abstractions; + +public interface INativeRecycleBinProvider +{ + /// + /// Moves a file to the recycle bin. + /// + /// The path of the file to be moved. + /// The flags to be used for the operation. + void MoveFileToRecycleBin(string path, NativeFileOperationFlags flags = default); + + /// + /// Moves the specified files to the recycle bin. + /// + /// The paths of the files to be moved. + /// The flags to be used for the operation. + void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default); + + /// + /// Moves the specified directory to the recycle bin. + /// + /// The path of the directory to be moved. + /// The flags to be used for the operation. + void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flags = default); + + /// + /// Moves the specified directories to the recycle bin. + /// + /// The paths of the directories to be moved. + /// The flags to be used for the operation. + void MoveDirectoriesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default); +} diff --git a/StabilityMatrix.Native.Abstractions/NativeFileOperationFlags.cs b/StabilityMatrix.Native.Abstractions/NativeFileOperationFlags.cs new file mode 100644 index 000000000..97784c20e --- /dev/null +++ b/StabilityMatrix.Native.Abstractions/NativeFileOperationFlags.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StabilityMatrix.Native.Abstractions; + +[Flags] +public enum NativeFileOperationFlags : uint +{ + /// + /// Do not display a progress dialog. + /// + Silent = 1 << 0, + + /// + /// Display a warning if files are being permanently deleted. + /// + WarnOnPermanentDelete = 1 << 1, + + /// + /// Do not ask the user to confirm the operation. + /// + NoConfirmation = 1 << 2, +} + +public static class NativeFileOperationFlagsExtensions +{ + [SuppressMessage("ReSharper", "CommentTypo")] + public static void ToWindowsFileOperationFlags( + this NativeFileOperationFlags flags, + ref uint windowsFileOperationFlags + ) + { + if (flags.HasFlag(NativeFileOperationFlags.Silent)) + { + windowsFileOperationFlags |= 0x0004; // FOF_SILENT + } + + if (flags.HasFlag(NativeFileOperationFlags.WarnOnPermanentDelete)) + { + windowsFileOperationFlags |= 0x4000; // FOF_WANTNUKEWARNING + } + + if (flags.HasFlag(NativeFileOperationFlags.NoConfirmation)) + { + windowsFileOperationFlags |= 0x0010; // FOF_NOCONFIRMATION + } + } +} diff --git a/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj b/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj new file mode 100644 index 000000000..0bffe1a13 --- /dev/null +++ b/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + win-x64 + enable + enable + + + + + + + diff --git a/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs b/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs new file mode 100644 index 000000000..ef1f1a191 --- /dev/null +++ b/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs @@ -0,0 +1,222 @@ +using System.ComponentModel; +using JetBrains.Annotations; +using StabilityMatrix.Native.Windows.Interop; + +namespace StabilityMatrix.Native.Windows.FileOperations; + +internal partial class FileOperationWrapper : IDisposable +{ + private bool _disposed; + private readonly IFileOperation _fileOperation; + private readonly IFileOperationProgressSink? _callbackSink; + private readonly uint _sinkCookie; + + [PublicAPI] + public FileOperationWrapper() + : this(null) { } + + public FileOperationWrapper(IFileOperationProgressSink? callbackSink) + : this(callbackSink, IntPtr.Zero) { } + + public FileOperationWrapper(IFileOperationProgressSink? callbackSink, IntPtr ownerHandle) + { + _callbackSink = callbackSink; + _fileOperation = + (IFileOperation?)Activator.CreateInstance(FileOperationType) + ?? throw new NullReferenceException("Failed to create FileOperation instance."); + + if (_callbackSink != null) + _sinkCookie = _fileOperation.Advise(_callbackSink); + if (ownerHandle != IntPtr.Zero) + _fileOperation.SetOwnerWindow((uint)ownerHandle); + } + + public void SetOperationFlags(FileOperationFlags operationFlags) + { + _fileOperation.SetOperationFlags(operationFlags); + } + + [PublicAPI] + public void CopyItem(string source, string destination, string newName) + { + ThrowIfDisposed(); + using var sourceItem = CreateShellItem(source); + using var destinationItem = CreateShellItem(destination); + _fileOperation.CopyItem(sourceItem.Item, destinationItem.Item, newName, null); + } + + [PublicAPI] + public void MoveItem(string source, string destination, string newName) + { + ThrowIfDisposed(); + using var sourceItem = CreateShellItem(source); + using var destinationItem = CreateShellItem(destination); + _fileOperation.MoveItem(sourceItem.Item, destinationItem.Item, newName, null); + } + + [PublicAPI] + public void RenameItem(string source, string newName) + { + ThrowIfDisposed(); + using var sourceItem = CreateShellItem(source); + _fileOperation.RenameItem(sourceItem.Item, newName, null); + } + + public void DeleteItem(string source) + { + ThrowIfDisposed(); + using var sourceItem = CreateShellItem(source); + _fileOperation.DeleteItem(sourceItem.Item, null); + } + + /*public void DeleteItems(params string[] sources) + { + ThrowIfDisposed(); + using var sourceItems = CreateShellItemArray(sources); + _fileOperation.DeleteItems(sourceItems.Item); + }*/ + + public void DeleteItems(string[] sources) + { + ThrowIfDisposed(); + + var pidlArray = new IntPtr[sources.Length]; + + try + { + // Convert paths to PIDLs + for (var i = 0; i < sources.Length; i++) + { + pidlArray[i] = ILCreateFromPathW(sources[i]); + if (pidlArray[i] == IntPtr.Zero) + throw new Exception($"Failed to create PIDL for path: {sources[i]}"); + } + + // Create ShellItemArray from PIDLs + // if (SHCreateShellItemArrayFromIDLists((uint)sources.Length, pidlArray, out var shellItemArray) != 0) + // throw new Exception("Failed to create IShellItemArray from PIDLs."); + + var shellItemArray = SHCreateShellItemArrayFromIDLists((uint)sources.Length, pidlArray); + + // Use the IFileOperation interface to delete items + _fileOperation.DeleteItems(shellItemArray); + } + finally + { + // Free PIDLs + foreach (var pidl in pidlArray) + { + if (pidl != IntPtr.Zero) + Marshal.FreeCoTaskMem(pidl); + } + } + } + + [PublicAPI] + public void NewItem(string folderName, string name, FileAttributes attrs) + { + ThrowIfDisposed(); + using var folderItem = CreateShellItem(folderName); + _fileOperation.NewItem(folderItem.Item, attrs, name, string.Empty, _callbackSink); + } + + public void PerformOperations() + { + ThrowIfDisposed(); + _fileOperation.PerformOperations(); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + if (_callbackSink != null) + _fileOperation.Unadvise(_sinkCookie); + Marshal.FinalReleaseComObject(_fileOperation); + } + } + + private static ComReleaser CreateShellItem(string path) + { + return new ComReleaser( + (IShellItem)SHCreateItemFromParsingName(path, IntPtr.Zero, ref _shellItemGuid) + ); + } + + /*private static ComReleaser CreateShellItemArray(params string[] paths) + { + var pidls = new IntPtr[paths.Length]; + + try + { + for (var i = 0; i < paths.Length; i++) + { + if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + pidls[i] = pidl; + } + + return new ComReleaser( + (IShellItemArray)SHCreateShellItemArrayFromIDLists((uint)pidls.Length, pidls) + ); + } + finally + { + foreach (var pidl in pidls) + { + Marshal.FreeCoTaskMem(pidl); + } + } + }*/ + + [LibraryImport("shell32.dll", SetLastError = true)] + private static partial int SHParseDisplayName( + [MarshalAs(UnmanagedType.LPWStr)] string pszName, + IntPtr pbc, // IBindCtx + out IntPtr ppidl, + uint sfgaoIn, + out uint psfgaoOut + ); + + /*[LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial int SHCreateShellItemArrayFromIDLists(uint cidl, IntPtr[] rgpidl, out IShellItemArray ppsiItemArray);*/ + + [LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial IntPtr ILCreateFromPathW(string pszPath); + + [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] + [return: MarshalAs(UnmanagedType.Interface)] + private static extern object SHCreateItemFromParsingName( + [MarshalAs(UnmanagedType.LPWStr)] string pszPath, + IntPtr pbc, // IBindCtx + ref Guid riid + ); + + [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] + [return: MarshalAs(UnmanagedType.Interface)] + private static extern IShellItemArray SHCreateShellItemArrayFromIDLists( + uint cidl, + [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStruct)] IntPtr[] rgpidl + ); + + /*[DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] + [return: MarshalAs(UnmanagedType.Interface)] + private static extern object SHCreateShellItemArrayFromIDLists( + uint cidl, + [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStruct)] IntPtr[] rgpidl + );*/ + + private static readonly Guid ClsidFileOperation = new("3ad05575-8857-4850-9277-11b85bdb8e09"); + private static readonly Type FileOperationType = Type.GetTypeFromCLSID(ClsidFileOperation); + private static Guid _shellItemGuid = typeof(IShellItem).GUID; +} diff --git a/StabilityMatrix.Native.Windows/GlobalUsings.cs b/StabilityMatrix.Native.Windows/GlobalUsings.cs new file mode 100644 index 000000000..59f9320ae --- /dev/null +++ b/StabilityMatrix.Native.Windows/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Global using directives + +global using System.Runtime.InteropServices; +global using System.Runtime.InteropServices.Marshalling; diff --git a/StabilityMatrix.Native.Windows/Interop/ComReleaser.cs b/StabilityMatrix.Native.Windows/Interop/ComReleaser.cs new file mode 100644 index 000000000..891b7bbb0 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/ComReleaser.cs @@ -0,0 +1,25 @@ +namespace StabilityMatrix.Native.Windows.Interop +{ + internal sealed class ComReleaser : IDisposable + where T : class + { + public T? Item { get; private set; } + + public ComReleaser(T obj) + { + ArgumentNullException.ThrowIfNull(obj); + if (!Marshal.IsComObject(obj)) + throw new ArgumentOutOfRangeException(nameof(obj)); + Item = obj; + } + + public void Dispose() + { + if (Item != null) + { + Marshal.FinalReleaseComObject(Item); + Item = null; + } + } + } +} diff --git a/StabilityMatrix.Native.Windows/Interop/FileOperationFlags.cs b/StabilityMatrix.Native.Windows/Interop/FileOperationFlags.cs new file mode 100644 index 000000000..60f358ee8 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/FileOperationFlags.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StabilityMatrix.Native.Windows.Interop; + +[Flags] +[SuppressMessage("ReSharper", "InconsistentNaming")] +[SuppressMessage("ReSharper", "IdentifierTypo")] +internal enum FileOperationFlags : uint +{ + FOF_MULTIDESTFILES = 0x0001, + FOF_CONFIRMMOUSE = 0x0002, + FOF_WANTMAPPINGHANDLE = 0x0020, // Fill in SHFILEOPSTRUCT.hNameMappings + FOF_FILESONLY = 0x0080, // on *.*, do only files + FOF_NOCONFIRMMKDIR = 0x0200, // don't confirm making any needed dirs + FOF_NOCOPYSECURITYATTRIBS = 0x0800, // dont copy NT file Security Attributes + FOF_NORECURSION = 0x1000, // don't recurse into directories. + FOF_NO_CONNECTED_ELEMENTS = 0x2000, // don't operate on connected file elements. + FOF_NORECURSEREPARSE = 0x8000, // treat reparse points as objects, not containers + + /// + /// Do not show a dialog during the process + /// + FOF_SILENT = 0x0004, + + FOF_RENAMEONCOLLISION = 0x0008, + + /// + /// Do not ask the user to confirm selection + /// + FOF_NOCONFIRMATION = 0x0010, + + /// + /// Delete the file to the recycle bin. (Required flag to send a file to the bin + /// + FOF_ALLOWUNDO = 0x0040, + + /// + /// Do not show the names of the files or folders that are being recycled. + /// + FOF_SIMPLEPROGRESS = 0x0100, + + /// + /// Surpress errors, if any occur during the process. + /// + FOF_NOERRORUI = 0x0400, + + /// + /// Warn if files are too big to fit in the recycle bin and will need + /// to be deleted completely. + /// + FOF_WANTNUKEWARNING = 0x4000, + + FOFX_ADDUNDORECORD = 0x20000000, + + FOFX_NOSKIPJUNCTIONS = 0x00010000, + + FOFX_PREFERHARDLINK = 0x00020000, + + FOFX_SHOWELEVATIONPROMPT = 0x00040000, + + FOFX_EARLYFAILURE = 0x00100000, + + FOFX_PRESERVEFILEEXTENSIONS = 0x00200000, + + FOFX_KEEPNEWERFILE = 0x00400000, + + FOFX_NOCOPYHOOKS = 0x00800000, + + FOFX_NOMINIMIZEBOX = 0x01000000, + + FOFX_MOVEACLSACROSSVOLUMES = 0x02000000, + + FOFX_DONTDISPLAYSOURCEPATH = 0x04000000, + + FOFX_DONTDISPLAYDESTPATH = 0x08000000, + + FOFX_RECYCLEONDELETE = 0x00080000, + + FOFX_REQUIREELEVATION = 0x10000000, + + FOFX_COPYASDOWNLOAD = 0x40000000, + + FOFX_DONTDISPLAYLOCATIONS = 0x80000000, +} diff --git a/StabilityMatrix.Native.Windows/Interop/FileOperationProgressSinkTcs.cs b/StabilityMatrix.Native.Windows/Interop/FileOperationProgressSinkTcs.cs new file mode 100644 index 000000000..a19108334 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/FileOperationProgressSinkTcs.cs @@ -0,0 +1,112 @@ +namespace StabilityMatrix.Native.Windows.Interop; + +[GeneratedComClass] +[Guid("04b0f1a7-9490-44bc-96e1-4296a31252e2")] +public partial class FileOperationProgressSinkTcs : TaskCompletionSource, IFileOperationProgressSink +{ + private readonly IProgress<(uint WorkTotal, uint WorkSoFar)>? progress; + + public FileOperationProgressSinkTcs() { } + + public FileOperationProgressSinkTcs(IProgress<(uint, uint)> progress) + { + this.progress = progress; + } + + /// + public virtual void StartOperations() { } + + /// + public virtual void FinishOperations(uint hrResult) + { + SetResult(hrResult); + } + + /// + public virtual void PreRenameItem(uint dwFlags, IShellItem psiItem, string pszNewName) { } + + /// + public virtual void PostRenameItem( + uint dwFlags, + IShellItem psiItem, + string pszNewName, + uint hrRename, + IShellItem psiNewlyCreated + ) { } + + /// + public virtual void PreMoveItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + string pszNewName + ) { } + + /// + public virtual void PostMoveItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + string pszNewName, + uint hrMove, + IShellItem psiNewlyCreated + ) { } + + /// + public virtual void PreCopyItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + string pszNewName + ) { } + + /// + public virtual void PostCopyItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + string pszNewName, + uint hrCopy, + IShellItem psiNewlyCreated + ) { } + + /// + public virtual void PreDeleteItem(uint dwFlags, IShellItem psiItem) { } + + /// + public virtual void PostDeleteItem( + uint dwFlags, + IShellItem psiItem, + uint hrDelete, + IShellItem psiNewlyCreated + ) { } + + /// + public virtual void PreNewItem(uint dwFlags, IShellItem psiDestinationFolder, string pszNewName) { } + + /// + public virtual void PostNewItem( + uint dwFlags, + IShellItem psiDestinationFolder, + string pszNewName, + string pszTemplateName, + uint dwFileAttributes, + uint hrNew, + IShellItem psiNewItem + ) { } + + /// + public virtual void UpdateProgress(uint iWorkTotal, uint iWorkSoFar) + { + progress?.Report((iWorkTotal, iWorkSoFar)); + } + + /// + public virtual void ResetTimer() { } + + /// + public virtual void PauseTimer() { } + + /// + public virtual void ResumeTimer() { } +} diff --git a/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs b/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs new file mode 100644 index 000000000..863f4e170 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs @@ -0,0 +1,53 @@ +namespace StabilityMatrix.Native.Windows.Interop; + +[GeneratedComInterface] +[Guid("947aab5f-0a5c-4c13-b4d6-4bf7836fc9f8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal partial interface IFileOperation +{ + uint Advise(IFileOperationProgressSink pfops); + void Unadvise(uint dwCookie); + void SetOperationFlags(FileOperationFlags dwOperationFlags); + void SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string pszMessage); + void SetProgressDialog([MarshalAs(UnmanagedType.Interface)] object popd); + void SetProperties([MarshalAs(UnmanagedType.Interface)] object pproparray); + void SetOwnerWindow(uint hwndParent); + void ApplyPropertiesToItem(IShellItem psiItem); + void ApplyPropertiesToItems([MarshalAs(UnmanagedType.Interface)] object punkItems); + void RenameItem( + IShellItem psiItem, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + IFileOperationProgressSink pfopsItem + ); + void RenameItems( + [MarshalAs(UnmanagedType.Interface)] object pUnkItems, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName + ); + void MoveItem( + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + IFileOperationProgressSink pfopsItem + ); + void MoveItems([MarshalAs(UnmanagedType.Interface)] object punkItems, IShellItem psiDestinationFolder); + void CopyItem( + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszCopyName, + IFileOperationProgressSink pfopsItem + ); + void CopyItems([MarshalAs(UnmanagedType.Interface)] object punkItems, IShellItem psiDestinationFolder); + void DeleteItem(IShellItem psiItem, IFileOperationProgressSink pfopsItem); + void DeleteItems([MarshalAs(UnmanagedType.Interface)] object punkItems); + uint NewItem( + IShellItem psiDestinationFolder, + FileAttributes dwFileAttributes, + [MarshalAs(UnmanagedType.LPWStr)] string pszName, + [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, + IFileOperationProgressSink pfopsItem + ); + void PerformOperations(); + + [return: MarshalAs(UnmanagedType.Bool)] + bool GetAnyOperationsAborted(); +} diff --git a/StabilityMatrix.Native.Windows/Interop/IFileOperationProgressSink.cs b/StabilityMatrix.Native.Windows/Interop/IFileOperationProgressSink.cs new file mode 100644 index 000000000..0d000a231 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/IFileOperationProgressSink.cs @@ -0,0 +1,73 @@ +namespace StabilityMatrix.Native.Windows.Interop; + +[GeneratedComInterface] +[Guid("04b0f1a7-9490-44bc-96e1-4296a31252e2")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public partial interface IFileOperationProgressSink +{ + void StartOperations(); + void FinishOperations(uint hrResult); + + void PreRenameItem(uint dwFlags, IShellItem psiItem, [MarshalAs(UnmanagedType.LPWStr)] string pszNewName); + void PostRenameItem( + uint dwFlags, + IShellItem psiItem, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + uint hrRename, + IShellItem psiNewlyCreated + ); + + void PreMoveItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName + ); + void PostMoveItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + uint hrMove, + IShellItem psiNewlyCreated + ); + + void PreCopyItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName + ); + void PostCopyItem( + uint dwFlags, + IShellItem psiItem, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + uint hrCopy, + IShellItem psiNewlyCreated + ); + + void PreDeleteItem(uint dwFlags, IShellItem psiItem); + void PostDeleteItem(uint dwFlags, IShellItem psiItem, uint hrDelete, IShellItem psiNewlyCreated); + + void PreNewItem( + uint dwFlags, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName + ); + void PostNewItem( + uint dwFlags, + IShellItem psiDestinationFolder, + [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, + [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, + uint dwFileAttributes, + uint hrNew, + IShellItem psiNewItem + ); + + void UpdateProgress(uint iWorkTotal, uint iWorkSoFar); + + void ResetTimer(); + void PauseTimer(); + void ResumeTimer(); +} diff --git a/StabilityMatrix.Native.Windows/Interop/IShellItem.cs b/StabilityMatrix.Native.Windows/Interop/IShellItem.cs new file mode 100644 index 000000000..6220e6761 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/IShellItem.cs @@ -0,0 +1,23 @@ +namespace StabilityMatrix.Native.Windows.Interop; + +[GeneratedComInterface] +[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public partial interface IShellItem +{ + [return: MarshalAs(UnmanagedType.Interface)] + object BindToHandler( + IntPtr pbc, // IBindCTX + ref Guid bhid, + ref Guid riid + ); + + IShellItem GetParent(); + + [return: MarshalAs(UnmanagedType.LPWStr)] + string GetDisplayName(SIGDN sigdnName); + + uint GetAttributes(uint sfgaoMask); + + int Compare(IShellItem psi, uint hint); +} diff --git a/StabilityMatrix.Native.Windows/Interop/IShellItemArray.cs b/StabilityMatrix.Native.Windows/Interop/IShellItemArray.cs new file mode 100644 index 000000000..56ed2a28d --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/IShellItemArray.cs @@ -0,0 +1,37 @@ +using System.Runtime.CompilerServices; + +namespace StabilityMatrix.Native.Windows.Interop; + +[GeneratedComInterface] +[Guid("b63ea76d-1f85-456f-a19c-48159efa858b")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public partial interface IShellItemArray +{ + // uint BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out object ppvOut); + + /*[return: MarshalAs(UnmanagedType.Interface)] + IShellItem GetItemAt(uint dwIndex); + + [return: MarshalAs(UnmanagedType.U4)] + uint GetCount();*/ + + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + void BindToHandler( + [MarshalAs(UnmanagedType.Interface)] IntPtr pbc, + ref Guid rbhid, + ref Guid riid, + out IntPtr ppvOut + ); + + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + void GetPropertyStore(int flags, ref Guid riid, out IntPtr ppv); + + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + int GetCount(); + + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + IShellItem GetItemAt(int dwIndex); + + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + void EnumItems([MarshalAs(UnmanagedType.Interface)] out IntPtr ppenumShellItems); +} diff --git a/StabilityMatrix.Native.Windows/Interop/SIGDN.cs b/StabilityMatrix.Native.Windows/Interop/SIGDN.cs new file mode 100644 index 000000000..9f456e386 --- /dev/null +++ b/StabilityMatrix.Native.Windows/Interop/SIGDN.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StabilityMatrix.Native.Windows.Interop +{ + [SuppressMessage("ReSharper", "InconsistentNaming")] + [SuppressMessage("ReSharper", "IdentifierTypo")] + public enum SIGDN : uint + { + SIGDN_NORMALDISPLAY = 0x00000000, + SIGDN_PARENTRELATIVEPARSING = 0x80018001, + SIGDN_PARENTRELATIVEFORADDRESSBAR = 0x8001c001, + SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000, + SIGDN_PARENTRELATIVEEDITING = 0x80031001, + SIGDN_DESKTOPABSOLUTEEDITING = 0x8004c000, + SIGDN_FILESYSPATH = 0x80058000, + SIGDN_URL = 0x80068000 + } +} diff --git a/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs b/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs new file mode 100644 index 000000000..038f1ff57 --- /dev/null +++ b/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs @@ -0,0 +1,73 @@ +using JetBrains.Annotations; +using StabilityMatrix.Native.Abstractions; +using StabilityMatrix.Native.Windows.FileOperations; +using StabilityMatrix.Native.Windows.Interop; + +namespace StabilityMatrix.Native.Windows; + +[PublicAPI] +public class NativeRecycleBinProvider : INativeRecycleBinProvider +{ + /// + public void MoveFileToRecycleBin(string path, NativeFileOperationFlags flags = default) + { + using var fo = new FileOperationWrapper(); + + var fileOperationFlags = default(uint); + flags.ToWindowsFileOperationFlags(ref fileOperationFlags); + + fo.SetOperationFlags( + (FileOperationFlags)fileOperationFlags | FileOperationFlags.FOFX_RECYCLEONDELETE + ); + fo.DeleteItem(path); + fo.PerformOperations(); + } + + /// + public void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default) + { + using var fo = new FileOperationWrapper(); + + var fileOperationFlags = default(uint); + flags.ToWindowsFileOperationFlags(ref fileOperationFlags); + + fo.SetOperationFlags( + (FileOperationFlags)fileOperationFlags | FileOperationFlags.FOFX_RECYCLEONDELETE + ); + fo.DeleteItems(paths.ToArray()); + fo.PerformOperations(); + } + + /// + public void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flags = default) + { + using var fo = new FileOperationWrapper(); + + var fileOperationFlags = default(uint); + flags.ToWindowsFileOperationFlags(ref fileOperationFlags); + + fo.SetOperationFlags( + (FileOperationFlags)fileOperationFlags | FileOperationFlags.FOFX_RECYCLEONDELETE + ); + fo.DeleteItem(path); + fo.PerformOperations(); + } + + /// + public void MoveDirectoriesToRecycleBin( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + using var fo = new FileOperationWrapper(); + + var fileOperationFlags = default(uint); + flags.ToWindowsFileOperationFlags(ref fileOperationFlags); + + fo.SetOperationFlags( + (FileOperationFlags)fileOperationFlags | FileOperationFlags.FOFX_RECYCLEONDELETE + ); + fo.DeleteItems(paths.ToArray()); + fo.PerformOperations(); + } +} diff --git a/StabilityMatrix.Native.Windows/StabilityMatrix.Native.Windows.csproj b/StabilityMatrix.Native.Windows/StabilityMatrix.Native.Windows.csproj new file mode 100644 index 000000000..057f7b917 --- /dev/null +++ b/StabilityMatrix.Native.Windows/StabilityMatrix.Native.Windows.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + win-x64 + enable + enable + true + true + true + + + + + + + + + + + + diff --git a/StabilityMatrix.Native/NativeFileOperations.cs b/StabilityMatrix.Native/NativeFileOperations.cs new file mode 100644 index 000000000..fde0d6c5f --- /dev/null +++ b/StabilityMatrix.Native/NativeFileOperations.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using StabilityMatrix.Native.Abstractions; +#if Windows +using StabilityMatrix.Native.Windows; +#endif + +namespace StabilityMatrix.Native; + +[PublicAPI] +public static class NativeFileOperations +{ + public static INativeRecycleBinProvider? RecycleBin { get; } + + [MemberNotNullWhen(true, nameof(IsRecycleBinAvailable))] + public static bool IsRecycleBinAvailable => RecycleBin is not null; + + static NativeFileOperations() + { +#if Windows + RecycleBin = new NativeRecycleBinProvider(); +#endif + } +} diff --git a/StabilityMatrix.Native/StabilityMatrix.Native.csproj b/StabilityMatrix.Native/StabilityMatrix.Native.csproj new file mode 100644 index 000000000..dc6e01bf0 --- /dev/null +++ b/StabilityMatrix.Native/StabilityMatrix.Native.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + win-x64 + enable + enable + + + + true + true + true + + + + Windows + + + OSX + + + Linux + + + + + + + + + + + diff --git a/StabilityMatrix.Tests/Helper/WindowsFileOperationsTests.cs b/StabilityMatrix.Tests/Helper/WindowsFileOperationsTests.cs deleted file mode 100644 index 8aecf358b..000000000 --- a/StabilityMatrix.Tests/Helper/WindowsFileOperationsTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.ReparsePoints; - -namespace StabilityMatrix.Tests.Helper; - -[TestClass] -[SupportedOSPlatform("windows")] -public class WindowsFileOperationsTests -{ - private string tempFolder = string.Empty; - - [TestInitialize] - public void Initialize() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Inconclusive("Test cannot be run on anything but Windows currently."); - return; - } - - tempFolder = Path.GetTempFileName(); - File.Delete(tempFolder); - Directory.CreateDirectory(tempFolder); - } - - [TestCleanup] - public void Cleanup() - { - if (string.IsNullOrEmpty(tempFolder)) - return; - TempFiles.DeleteDirectory(tempFolder); - } - - [TestMethod] - public void FileOpDeleteItem_RecycleFile() - { - var targetFile = Path.Combine(tempFolder, $"RecycleFile_{Guid.NewGuid().ToString()}"); - File.Create(targetFile).Close(); - - Assert.IsTrue(File.Exists(targetFile)); - - using var fo = new WindowsFileOperations.FileOperation(); - - fo.SetOperationFlags(WindowsFileOperations.FileOperationFlags.FOFX_RECYCLEONDELETE); - fo.DeleteItem(targetFile); - fo.PerformOperations(); - - Assert.IsFalse(File.Exists(targetFile)); - } - - [TestMethod] - public void FileOpDeleteItems_RecycleFiles() - { - var targetFiles = Enumerable - .Range(0, 8) - .Select(i => Path.Combine(tempFolder, $"RecycleFiles_{i}_{Guid.NewGuid().ToString()}")) - .ToArray(); - - foreach (var targetFile in targetFiles) - { - File.Create(targetFile).Close(); - Assert.IsTrue(File.Exists(targetFile)); - } - - using var fo = new WindowsFileOperations.FileOperation(); - - fo.SetOperationFlags(WindowsFileOperations.FileOperationFlags.FOFX_RECYCLEONDELETE); - fo.DeleteItems(targetFiles); - fo.PerformOperations(); - - foreach (var targetFile in targetFiles) - { - Assert.IsFalse(File.Exists(targetFile)); - } - } -} diff --git a/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs new file mode 100644 index 000000000..bb7a532d1 --- /dev/null +++ b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs @@ -0,0 +1,124 @@ +using System.Runtime.InteropServices; +using StabilityMatrix.Native; +using StabilityMatrix.Native.Abstractions; + +namespace StabilityMatrix.Tests.Native; + +[TestClass] +public class NativeRecycleBinProviderTests +{ + private string tempFolder = string.Empty; + + [TestInitialize] + public void Initialize() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.IsFalse(NativeFileOperations.IsRecycleBinAvailable); + Assert.IsNull(NativeFileOperations.RecycleBin); + return; + } + + Assert.IsTrue(NativeFileOperations.IsRecycleBinAvailable); + Assert.IsNotNull(NativeFileOperations.RecycleBin); + + tempFolder = Path.GetTempFileName(); + File.Delete(tempFolder); + Directory.CreateDirectory(tempFolder); + } + + [TestCleanup] + public void Cleanup() + { + if (string.IsNullOrEmpty(tempFolder)) + return; + TempFiles.DeleteDirectory(tempFolder); + } + + [TestMethod] + public void RecycleFile() + { + var targetFile = Path.Combine(tempFolder, $"{nameof(RecycleFile)}_{Guid.NewGuid().ToString()}"); + File.Create(targetFile).Close(); + + Assert.IsTrue(File.Exists(targetFile)); + + NativeFileOperations.RecycleBin!.MoveFileToRecycleBin( + targetFile, + NativeFileOperationFlags.Silent | NativeFileOperationFlags.NoConfirmation + ); + + Assert.IsFalse(File.Exists(targetFile)); + } + + [TestMethod] + public void RecycleFiles() + { + var targetFiles = Enumerable + .Range(0, 8) + .Select(i => Path.Combine(tempFolder, $"{nameof(RecycleFiles)}_{i}_{Guid.NewGuid().ToString()}")) + .ToArray(); + + foreach (var targetFile in targetFiles) + { + File.Create(targetFile).Close(); + Assert.IsTrue(File.Exists(targetFile)); + } + + NativeFileOperations.RecycleBin!.MoveFilesToRecycleBin( + targetFiles, + NativeFileOperationFlags.Silent | NativeFileOperationFlags.NoConfirmation + ); + + foreach (var targetFile in targetFiles) + { + Assert.IsFalse(File.Exists(targetFile)); + } + } + + [TestMethod] + public void RecycleDirectory() + { + var targetDirectory = Path.Combine( + tempFolder, + $"{nameof(RecycleDirectory)}_{Guid.NewGuid().ToString()}" + ); + Directory.CreateDirectory(targetDirectory); + + Assert.IsTrue(Directory.Exists(targetDirectory)); + + NativeFileOperations.RecycleBin!.MoveDirectoryToRecycleBin( + targetDirectory, + NativeFileOperationFlags.Silent | NativeFileOperationFlags.NoConfirmation + ); + + Assert.IsFalse(Directory.Exists(targetDirectory)); + } + + [TestMethod] + public void RecycleDirectories() + { + var targetDirectories = Enumerable + .Range(0, 2) + .Select( + i => Path.Combine(tempFolder, $"{nameof(RecycleDirectories)}_{i}_{Guid.NewGuid().ToString()}") + ) + .ToArray(); + + foreach (var targetDirectory in targetDirectories) + { + Directory.CreateDirectory(targetDirectory); + Assert.IsTrue(Directory.Exists(targetDirectory)); + } + + NativeFileOperations.RecycleBin!.MoveDirectoriesToRecycleBin( + targetDirectories, + NativeFileOperationFlags.Silent | NativeFileOperationFlags.NoConfirmation + ); + + foreach (var targetDirectory in targetDirectories) + { + Assert.IsFalse(Directory.Exists(targetDirectory)); + } + } +} diff --git a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj index 257b8f932..b8309d3e1 100644 --- a/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj +++ b/StabilityMatrix.Tests/StabilityMatrix.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/StabilityMatrix.sln b/StabilityMatrix.sln index ff5b52a41..a1ab41aea 100644 --- a/StabilityMatrix.sln +++ b/StabilityMatrix.sln @@ -17,6 +17,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.UITests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Gif", "Avalonia.Gif\Avalonia.Gif.csproj", "{72A73F1E-024B-4A25-AD34-626198D9527F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native", "StabilityMatrix.Native\StabilityMatrix.Native.csproj", "{254FC709-B602-4EF8-8714-FF5E47E14E50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native.Windows", "StabilityMatrix.Native.Windows\StabilityMatrix.Native.Windows.csproj", "{27B4D892-B507-4A01-B25F-67C9499E61B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native.Abstractions", "StabilityMatrix.Native.Abstractions\StabilityMatrix.Native.Abstractions.csproj", "{C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +55,18 @@ Global {72A73F1E-024B-4A25-AD34-626198D9527F}.Debug|Any CPU.Build.0 = Debug|Any CPU {72A73F1E-024B-4A25-AD34-626198D9527F}.Release|Any CPU.ActiveCfg = Release|Any CPU {72A73F1E-024B-4A25-AD34-626198D9527F}.Release|Any CPU.Build.0 = Release|Any CPU + {254FC709-B602-4EF8-8714-FF5E47E14E50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {254FC709-B602-4EF8-8714-FF5E47E14E50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {254FC709-B602-4EF8-8714-FF5E47E14E50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {254FC709-B602-4EF8-8714-FF5E47E14E50}.Release|Any CPU.Build.0 = Release|Any CPU + {27B4D892-B507-4A01-B25F-67C9499E61B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27B4D892-B507-4A01-B25F-67C9499E61B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27B4D892-B507-4A01-B25F-67C9499E61B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27B4D892-B507-4A01-B25F-67C9499E61B8}.Release|Any CPU.Build.0 = Release|Any CPU + {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From fa31381abe8e52efc1b7c00343ff7df8fb30d508 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:05:32 -0400 Subject: [PATCH 069/239] Remove old windows file ops class --- .../Helper/WindowsFileOperations.cs | 514 ------------------ 1 file changed, 514 deletions(-) delete mode 100644 StabilityMatrix.Core/Helper/WindowsFileOperations.cs diff --git a/StabilityMatrix.Core/Helper/WindowsFileOperations.cs b/StabilityMatrix.Core/Helper/WindowsFileOperations.cs deleted file mode 100644 index 6039f8e4d..000000000 --- a/StabilityMatrix.Core/Helper/WindowsFileOperations.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.ComTypes; -using System.Runtime.InteropServices.Marshalling; -using System.Runtime.Versioning; -using JetBrains.Annotations; - -namespace StabilityMatrix.Core.Helper; - -[SupportedOSPlatform("windows")] -[SuppressMessage("ReSharper", "InconsistentNaming")] -public static partial class WindowsFileOperations -{ - public enum HRESULT : uint - { - S_OK = 0x00000000, - S_FALSE = 0x00000001, - E_ABORT = 0x80004004, - E_FAIL = 0x80004005, - E_NOINTERFACE = 0x80004002, - E_NOTIMPLEMENTED = 0x80004001, - E_POINTER = 0x80004003, - E_UNEXPECTED = 0x8000FFFF, - E_ACCESSDENIED = 0x80070005, - E_HANDLE = 0x80070006, - E_INVALIDARG = 0x80070057, - E_OUTOFMEMORY = 0x8007000E, - } - - /// - /// Possible flags for the SHFileOperation method. - /// - [Flags] - public enum FileOperationFlags : uint - { - FOF_MULTIDESTFILES = 0x0001, - FOF_CONFIRMMOUSE = 0x0002, - FOF_WANTMAPPINGHANDLE = 0x0020, // Fill in SHFILEOPSTRUCT.hNameMappings - FOF_FILESONLY = 0x0080, // on *.*, do only files - FOF_NOCONFIRMMKDIR = 0x0200, // don't confirm making any needed dirs - FOF_NOCOPYSECURITYATTRIBS = 0x0800, // dont copy NT file Security Attributes - FOF_NORECURSION = 0x1000, // don't recurse into directories. - FOF_NO_CONNECTED_ELEMENTS = 0x2000, // don't operate on connected file elements. - FOF_NORECURSEREPARSE = 0x8000, // treat reparse points as objects, not containers - - /// - /// Do not show a dialog during the process - /// - FOF_SILENT = 0x0004, - - FOF_RENAMEONCOLLISION = 0x0008, - - /// - /// Do not ask the user to confirm selection - /// - FOF_NOCONFIRMATION = 0x0010, - - /// - /// Delete the file to the recycle bin. (Required flag to send a file to the bin - /// - FOF_ALLOWUNDO = 0x0040, - - /// - /// Do not show the names of the files or folders that are being recycled. - /// - FOF_SIMPLEPROGRESS = 0x0100, - - /// - /// Surpress errors, if any occur during the process. - /// - FOF_NOERRORUI = 0x0400, - - /// - /// Warn if files are too big to fit in the recycle bin and will need - /// to be deleted completely. - /// - FOF_WANTNUKEWARNING = 0x4000, - - FOFX_ADDUNDORECORD = 0x20000000, - - FOFX_NOSKIPJUNCTIONS = 0x00010000, - - FOFX_PREFERHARDLINK = 0x00020000, - - FOFX_SHOWELEVATIONPROMPT = 0x00040000, - - FOFX_EARLYFAILURE = 0x00100000, - - FOFX_PRESERVEFILEEXTENSIONS = 0x00200000, - - FOFX_KEEPNEWERFILE = 0x00400000, - - FOFX_NOCOPYHOOKS = 0x00800000, - - FOFX_NOMINIMIZEBOX = 0x01000000, - - FOFX_MOVEACLSACROSSVOLUMES = 0x02000000, - - FOFX_DONTDISPLAYSOURCEPATH = 0x04000000, - - FOFX_DONTDISPLAYDESTPATH = 0x08000000, - - FOFX_RECYCLEONDELETE = 0x00080000, - - FOFX_REQUIREELEVATION = 0x10000000, - - FOFX_COPYASDOWNLOAD = 0x40000000, - - FOFX_DONTDISPLAYLOCATIONS = 0x80000000, - } - - /// - /// File Operation Function Type for SHFileOperation - /// - public enum FileOperationType : uint - { - /// - /// Move the objects - /// - FO_MOVE = 0x0001, - - /// - /// Copy the objects - /// - FO_COPY = 0x0002, - - /// - /// Delete (or recycle) the objects - /// - FO_DELETE = 0x0003, - - /// - /// Rename the object(s) - /// - FO_RENAME = 0x0004, - } - - public enum SIGDN : uint - { - SIGDN_NORMALDISPLAY = 0x00000000, - SIGDN_PARENTRELATIVEPARSING = 0x80018001, - SIGDN_PARENTRELATIVEFORADDRESSBAR = 0x8001c001, - SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000, - SIGDN_PARENTRELATIVEEDITING = 0x80031001, - SIGDN_DESKTOPABSOLUTEEDITING = 0x8004c000, - SIGDN_FILESYSPATH = 0x80058000, - SIGDN_URL = 0x80068000 - } - - internal sealed class ComReleaser : IDisposable - where T : class - { - public ComReleaser(T obj) - { - ArgumentNullException.ThrowIfNull(obj); - if (!Marshal.IsComObject(obj)) - throw new ArgumentOutOfRangeException(nameof(obj)); - Item = obj; - } - - public T? Item { get; private set; } - - public void Dispose() - { - if (Item != null) - { - Marshal.FinalReleaseComObject(Item); - Item = null; - } - } - } - - [GeneratedComInterface] - [Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public partial interface IShellItem - { - [return: MarshalAs(UnmanagedType.Interface)] - object BindToHandler( - IntPtr pbc, // IBindCTX - ref Guid bhid, - ref Guid riid - ); - - IShellItem GetParent(); - - [return: MarshalAs(UnmanagedType.LPWStr)] - string GetDisplayName(SIGDN sigdnName); - - uint GetAttributes(uint sfgaoMask); - - int Compare(IShellItem psi, uint hint); - } - - [GeneratedComInterface] - [Guid("b63ea76d-1f85-456f-a19c-48159efa858b")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public partial interface IShellItemArray - { - // uint BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out object ppvOut); - - IShellItem GetItemAt(uint dwIndex); - - uint GetCount(); - } - - [GeneratedComInterface] - [Guid("04b0f1a7-9490-44bc-96e1-4296a31252e2")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public partial interface IFileOperationProgressSink - { - void StartOperations(); - void FinishOperations(uint hrResult); - - void PreRenameItem( - uint dwFlags, - IShellItem psiItem, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName - ); - void PostRenameItem( - uint dwFlags, - IShellItem psiItem, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - uint hrRename, - IShellItem psiNewlyCreated - ); - - void PreMoveItem( - uint dwFlags, - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName - ); - void PostMoveItem( - uint dwFlags, - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - uint hrMove, - IShellItem psiNewlyCreated - ); - - void PreCopyItem( - uint dwFlags, - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName - ); - void PostCopyItem( - uint dwFlags, - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - uint hrCopy, - IShellItem psiNewlyCreated - ); - - void PreDeleteItem(uint dwFlags, IShellItem psiItem); - void PostDeleteItem(uint dwFlags, IShellItem psiItem, uint hrDelete, IShellItem psiNewlyCreated); - - void PreNewItem( - uint dwFlags, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName - ); - void PostNewItem( - uint dwFlags, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, - uint dwFileAttributes, - uint hrNew, - IShellItem psiNewItem - ); - - void UpdateProgress(uint iWorkTotal, uint iWorkSoFar); - - void ResetTimer(); - void PauseTimer(); - void ResumeTimer(); - } - - internal partial class FileOperation : IDisposable - { - private bool _disposed; - private readonly IFileOperation _fileOperation; - private readonly IFileOperationProgressSink _callbackSink; - private readonly uint _sinkCookie; - - [PublicAPI] - public FileOperation() - : this(null) { } - - public FileOperation(IFileOperationProgressSink callbackSink) - : this(callbackSink, IntPtr.Zero) { } - - public FileOperation(IFileOperationProgressSink callbackSink, IntPtr ownerHandle) - { - _callbackSink = callbackSink; - _fileOperation = (IFileOperation)Activator.CreateInstance(FileOperationType); - - _fileOperation.SetOperationFlags(FileOperationFlags.FOF_NOCONFIRMMKDIR); - if (_callbackSink != null) - _sinkCookie = _fileOperation.Advise(_callbackSink); - if (ownerHandle != IntPtr.Zero) - _fileOperation.SetOwnerWindow((uint)ownerHandle); - } - - public void SetOperationFlags(FileOperationFlags operationFlags) - { - _fileOperation.SetOperationFlags(operationFlags); - } - - [PublicAPI] - public void CopyItem(string source, string destination, string newName) - { - ThrowIfDisposed(); - using var sourceItem = CreateShellItem(source); - using var destinationItem = CreateShellItem(destination); - _fileOperation.CopyItem(sourceItem.Item, destinationItem.Item, newName, null); - } - - [PublicAPI] - public void MoveItem(string source, string destination, string newName) - { - ThrowIfDisposed(); - using var sourceItem = CreateShellItem(source); - using var destinationItem = CreateShellItem(destination); - _fileOperation.MoveItem(sourceItem.Item, destinationItem.Item, newName, null); - } - - [PublicAPI] - public void RenameItem(string source, string newName) - { - ThrowIfDisposed(); - using var sourceItem = CreateShellItem(source); - _fileOperation.RenameItem(sourceItem.Item, newName, null); - } - - public void DeleteItem(string source) - { - ThrowIfDisposed(); - using var sourceItem = CreateShellItem(source); - _fileOperation.DeleteItem(sourceItem.Item, null); - } - - public void DeleteItems(params string[] sources) - { - ThrowIfDisposed(); - using var sourceItems = CreateShellItemArray(sources); - _fileOperation.DeleteItems(sourceItems.Item); - } - - [PublicAPI] - public void NewItem(string folderName, string name, FileAttributes attrs) - { - ThrowIfDisposed(); - using var folderItem = CreateShellItem(folderName); - _fileOperation.NewItem(folderItem.Item, attrs, name, string.Empty, _callbackSink); - } - - public void PerformOperations() - { - ThrowIfDisposed(); - _fileOperation.PerformOperations(); - } - - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(GetType().Name); - } - - public void Dispose() - { - if (!_disposed) - { - _disposed = true; - if (_callbackSink != null) - _fileOperation.Unadvise(_sinkCookie); - Marshal.FinalReleaseComObject(_fileOperation); - } - } - - private static ComReleaser CreateShellItem(string path) - { - return new ComReleaser( - (IShellItem)SHCreateItemFromParsingName(path, IntPtr.Zero, ref _shellItemGuid) - ); - } - - private static ComReleaser CreateShellItemArray(params string[] paths) - { - var pidls = new IntPtr[paths.Length]; - - try - { - for (var i = 0; i < paths.Length; i++) - { - if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0) - { - ThrowLastWin32Error("Failed to parse display name."); - } - - pidls[i] = pidl; - } - - return new ComReleaser( - (IShellItemArray)SHCreateShellItemArrayFromIDLists((uint)pidls.Length, pidls) - ); - } - finally - { - foreach (var pidl in pidls) - { - Marshal.FreeCoTaskMem(pidl); - } - } - } - - [LibraryImport("shell32.dll", SetLastError = true)] - private static partial int SHParseDisplayName( - [MarshalAs(UnmanagedType.LPWStr)] string pszName, - IntPtr pbc, // IBindCtx - out IntPtr ppidl, - uint sfgaoIn, - out uint psfgaoOut - ); - - [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] - [return: MarshalAs(UnmanagedType.Interface)] - private static extern object SHCreateItemFromParsingName( - [MarshalAs(UnmanagedType.LPWStr)] string pszPath, - IntPtr pbc, // IBindCtx - ref Guid riid - ); - - [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] - [return: MarshalAs(UnmanagedType.Interface)] - private static extern object SHCreateShellItemArrayFromIDLists( - uint cidl, - [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStruct)] IntPtr[] rgpidl - ); - - private static readonly Guid ClsidFileOperation = new("3ad05575-8857-4850-9277-11b85bdb8e09"); - private static readonly Type FileOperationType = Type.GetTypeFromCLSID(ClsidFileOperation); - private static Guid _shellItemGuid = typeof(IShellItem).GUID; - } - - [GeneratedComInterface] - [Guid("947aab5f-0a5c-4c13-b4d6-4bf7836fc9f8")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal partial interface IFileOperation - { - uint Advise(IFileOperationProgressSink pfops); - void Unadvise(uint dwCookie); - void SetOperationFlags(FileOperationFlags dwOperationFlags); - void SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string pszMessage); - void SetProgressDialog([MarshalAs(UnmanagedType.Interface)] object popd); - void SetProperties([MarshalAs(UnmanagedType.Interface)] object pproparray); - void SetOwnerWindow(uint hwndParent); - void ApplyPropertiesToItem(IShellItem psiItem); - void ApplyPropertiesToItems([MarshalAs(UnmanagedType.Interface)] object punkItems); - void RenameItem( - IShellItem psiItem, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - IFileOperationProgressSink pfopsItem - ); - void RenameItems( - [MarshalAs(UnmanagedType.Interface)] object pUnkItems, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName - ); - void MoveItem( - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - IFileOperationProgressSink pfopsItem - ); - void MoveItems( - [MarshalAs(UnmanagedType.Interface)] object punkItems, - IShellItem psiDestinationFolder - ); - void CopyItem( - IShellItem psiItem, - IShellItem psiDestinationFolder, - [MarshalAs(UnmanagedType.LPWStr)] string pszCopyName, - IFileOperationProgressSink pfopsItem - ); - void CopyItems( - [MarshalAs(UnmanagedType.Interface)] object punkItems, - IShellItem psiDestinationFolder - ); - void DeleteItem(IShellItem psiItem, IFileOperationProgressSink pfopsItem); - void DeleteItems([MarshalAs(UnmanagedType.Interface)] object punkItems); - uint NewItem( - IShellItem psiDestinationFolder, - FileAttributes dwFileAttributes, - [MarshalAs(UnmanagedType.LPWStr)] string pszName, - [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, - IFileOperationProgressSink pfopsItem - ); - void PerformOperations(); - - [return: MarshalAs(UnmanagedType.Bool)] - bool GetAnyOperationsAborted(); - } - - [DoesNotReturn] - private static void ThrowLastWin32Error(string message) - { - throw new IOException(message, Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error())); - } -} From 981414c350c20632f1c7900c7cf1ff0cafa317c5 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:09:02 -0400 Subject: [PATCH 070/239] Fix test on other platforms --- StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs index bb7a532d1..0e5c29374 100644 --- a/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs +++ b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs @@ -16,6 +16,7 @@ public void Initialize() { Assert.IsFalse(NativeFileOperations.IsRecycleBinAvailable); Assert.IsNull(NativeFileOperations.RecycleBin); + Assert.Inconclusive("Recycle bin is only available on Windows."); return; } From f80e9506cd6244eca5c880ccb61867bba3d76d55 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:33:45 -0400 Subject: [PATCH 071/239] Fix supported os platform warnings --- StabilityMatrix.Native.Windows/AssemblyInfo.cs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 StabilityMatrix.Native.Windows/AssemblyInfo.cs diff --git a/StabilityMatrix.Native.Windows/AssemblyInfo.cs b/StabilityMatrix.Native.Windows/AssemblyInfo.cs new file mode 100644 index 000000000..d04ca728b --- /dev/null +++ b/StabilityMatrix.Native.Windows/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] From 43cdae375620798feeb5de301e903fb0575553d7 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:36:07 -0400 Subject: [PATCH 072/239] Formatting and fixes --- .../FileOperations/FileOperationWrapper.cs | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs b/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs index ef1f1a191..91edd04b6 100644 --- a/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs +++ b/StabilityMatrix.Native.Windows/FileOperations/FileOperationWrapper.cs @@ -1,9 +1,11 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using StabilityMatrix.Native.Windows.Interop; namespace StabilityMatrix.Native.Windows.FileOperations; +[SuppressMessage("ReSharper", "InconsistentNaming")] internal partial class FileOperationWrapper : IDisposable { private bool _disposed; @@ -93,9 +95,6 @@ public void DeleteItems(string[] sources) } // Create ShellItemArray from PIDLs - // if (SHCreateShellItemArrayFromIDLists((uint)sources.Length, pidlArray, out var shellItemArray) != 0) - // throw new Exception("Failed to create IShellItemArray from PIDLs."); - var shellItemArray = SHCreateShellItemArrayFromIDLists((uint)sources.Length, pidlArray); // Use the IFileOperation interface to delete items @@ -107,7 +106,9 @@ public void DeleteItems(string[] sources) foreach (var pidl in pidlArray) { if (pidl != IntPtr.Zero) + { Marshal.FreeCoTaskMem(pidl); + } } } } @@ -129,7 +130,9 @@ public void PerformOperations() private void ThrowIfDisposed() { if (_disposed) + { throw new ObjectDisposedException(GetType().Name); + } } public void Dispose() @@ -137,8 +140,12 @@ public void Dispose() if (!_disposed) { _disposed = true; + if (_callbackSink != null) + { _fileOperation.Unadvise(_sinkCookie); + } + Marshal.FinalReleaseComObject(_fileOperation); } } @@ -150,7 +157,7 @@ private static ComReleaser CreateShellItem(string path) ); } - /*private static ComReleaser CreateShellItemArray(params string[] paths) + private static ComReleaser CreateShellItemArray(params string[] paths) { var pidls = new IntPtr[paths.Length]; @@ -167,7 +174,7 @@ private static ComReleaser CreateShellItem(string path) } return new ComReleaser( - (IShellItemArray)SHCreateShellItemArrayFromIDLists((uint)pidls.Length, pidls) + SHCreateShellItemArrayFromIDLists((uint)pidls.Length, pidls) ); } finally @@ -177,7 +184,7 @@ private static ComReleaser CreateShellItem(string path) Marshal.FreeCoTaskMem(pidl); } } - }*/ + } [LibraryImport("shell32.dll", SetLastError = true)] private static partial int SHParseDisplayName( @@ -188,9 +195,6 @@ private static partial int SHParseDisplayName( out uint psfgaoOut ); - /*[LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] - private static partial int SHCreateShellItemArrayFromIDLists(uint cidl, IntPtr[] rgpidl, out IShellItemArray ppsiItemArray);*/ - [LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] private static partial IntPtr ILCreateFromPathW(string pszPath); @@ -209,14 +213,9 @@ private static extern IShellItemArray SHCreateShellItemArrayFromIDLists( [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStruct)] IntPtr[] rgpidl ); - /*[DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode, PreserveSig = false)] - [return: MarshalAs(UnmanagedType.Interface)] - private static extern object SHCreateShellItemArrayFromIDLists( - uint cidl, - [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPStruct)] IntPtr[] rgpidl - );*/ - private static readonly Guid ClsidFileOperation = new("3ad05575-8857-4850-9277-11b85bdb8e09"); - private static readonly Type FileOperationType = Type.GetTypeFromCLSID(ClsidFileOperation); + private static readonly Type FileOperationType = + Type.GetTypeFromCLSID(ClsidFileOperation) + ?? throw new NullReferenceException("Failed to get FileOperation type from CLSID"); private static Guid _shellItemGuid = typeof(IShellItem).GUID; } From fe91f5f1a375994e33000f880b342d99c9f21d26 Mon Sep 17 00:00:00 2001 From: Ionite Date: Wed, 15 May 2024 16:36:56 -0400 Subject: [PATCH 073/239] Fix some nullable vars --- .../Interop/IFileOperation.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs b/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs index 863f4e170..8f97e5cb4 100644 --- a/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs +++ b/StabilityMatrix.Native.Windows/Interop/IFileOperation.cs @@ -17,7 +17,7 @@ internal partial interface IFileOperation void RenameItem( IShellItem psiItem, [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - IFileOperationProgressSink pfopsItem + IFileOperationProgressSink? pfopsItem ); void RenameItems( [MarshalAs(UnmanagedType.Interface)] object pUnkItems, @@ -27,24 +27,24 @@ void MoveItem( IShellItem psiItem, IShellItem psiDestinationFolder, [MarshalAs(UnmanagedType.LPWStr)] string pszNewName, - IFileOperationProgressSink pfopsItem + IFileOperationProgressSink? pfopsItem ); void MoveItems([MarshalAs(UnmanagedType.Interface)] object punkItems, IShellItem psiDestinationFolder); void CopyItem( IShellItem psiItem, IShellItem psiDestinationFolder, [MarshalAs(UnmanagedType.LPWStr)] string pszCopyName, - IFileOperationProgressSink pfopsItem + IFileOperationProgressSink? pfopsItem ); void CopyItems([MarshalAs(UnmanagedType.Interface)] object punkItems, IShellItem psiDestinationFolder); - void DeleteItem(IShellItem psiItem, IFileOperationProgressSink pfopsItem); + void DeleteItem(IShellItem psiItem, IFileOperationProgressSink? pfopsItem); void DeleteItems([MarshalAs(UnmanagedType.Interface)] object punkItems); uint NewItem( IShellItem psiDestinationFolder, FileAttributes dwFileAttributes, [MarshalAs(UnmanagedType.LPWStr)] string pszName, [MarshalAs(UnmanagedType.LPWStr)] string pszTemplateName, - IFileOperationProgressSink pfopsItem + IFileOperationProgressSink? pfopsItem ); void PerformOperations(); From 1b85c27814e6454882e2c2067845bc54b117b933 Mon Sep 17 00:00:00 2001 From: Ionite Date: Thu, 16 May 2024 15:26:39 -0400 Subject: [PATCH 074/239] Add custom text close button for TaskDialog base --- .../ViewModels/Base/TaskDialogViewModelBase.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs index a09db95dd..0ce997563 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/TaskDialogViewModelBase.cs @@ -30,7 +30,16 @@ protected static TaskDialogCommand GetCommandButton(string text, ICommand comman protected static TaskDialogButton GetCloseButton() { - return new TaskDialogButton { Text = Resources.Action_Close, DialogResult = TaskDialogStandardResult.Close }; + return new TaskDialogButton + { + Text = Resources.Action_Close, + DialogResult = TaskDialogStandardResult.Close + }; + } + + protected static TaskDialogButton GetCloseButton(string text) + { + return new TaskDialogButton { Text = text, DialogResult = TaskDialogStandardResult.Close }; } /// From 4629255911eea5bbc453de1371a3d64999ab4bb5 Mon Sep 17 00:00:00 2001 From: JT Date: Sat, 18 May 2024 00:29:28 -0700 Subject: [PATCH 075/239] added dialog for civit models on new checkpoints page & added download speed to downloads flyout tab thing --- .../DesignData/MockModelIndexService.cs | 2 +- .../Services/IModelImportService.cs | 28 +++ .../Services/ModelImportService.cs | 161 ++++++++++++++++++ .../ViewModels/Base/ProgressViewModel.cs | 5 + .../ViewModels/NewCheckpointsPageViewModel.cs | 156 ++++++++++++++++- .../Progress/DownloadProgressItemViewModel.cs | 19 ++- .../Views/NewCheckpointsPage.axaml | 43 ++--- .../Views/ProgressManagerPage.axaml | 2 + .../Database/LiteDbContext.cs | 18 +- StabilityMatrix.Core/Helper/ModelFinder.cs | 15 +- .../Models/CheckpointSortMode.cs | 11 +- .../Models/Database/LocalModelFile.cs | 21 ++- .../Models/Progress/ProgressReport.cs | 7 + .../Services/DownloadService.cs | 17 +- .../Services/IModelIndexService.cs | 1 + .../Services/ModelIndexService.cs | 39 ++++- 16 files changed, 483 insertions(+), 62 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Services/IModelImportService.cs create mode 100644 StabilityMatrix.Avalonia/Services/ModelImportService.cs diff --git a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs b/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs index fabb4d791..e02c1eb39 100644 --- a/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs +++ b/StabilityMatrix.Avalonia/DesignData/MockModelIndexService.cs @@ -54,7 +54,7 @@ public Task RemoveModelsAsync(IEnumerable models) return Task.FromResult(false); } - public Task CheckModelsForUpdates() + public Task CheckModelsForUpdateAsync() { return Task.CompletedTask; } diff --git a/StabilityMatrix.Avalonia/Services/IModelImportService.cs b/StabilityMatrix.Avalonia/Services/IModelImportService.cs new file mode 100644 index 000000000..4ac503411 --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/IModelImportService.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; + +namespace StabilityMatrix.Avalonia.Services; + +public interface IModelImportService +{ + /// + /// Saves the preview image to the same directory as the model file + /// + /// + /// + /// The file path of the saved preview image + Task SavePreviewImage(CivitModelVersion modelVersion, FilePath modelFilePath); + + Task DoImport( + CivitModel model, + DirectoryPath downloadFolder, + CivitModelVersion? selectedVersion = null, + CivitFile? selectedFile = null, + IProgress? progress = null, + Func? onImportComplete = null, + Func? onImportFailed = null + ); +} diff --git a/StabilityMatrix.Avalonia/Services/ModelImportService.cs b/StabilityMatrix.Avalonia/Services/ModelImportService.cs new file mode 100644 index 000000000..4d93d96cd --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/ModelImportService.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls.Notifications; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Progress; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.Services; + +[Singleton(typeof(IModelImportService))] +public class ModelImportService( + IDownloadService downloadService, + INotificationService notificationService, + ITrackedDownloadService trackedDownloadService +) : IModelImportService +{ + public static async Task SaveCmInfo( + CivitModel model, + CivitModelVersion modelVersion, + CivitFile modelFile, + DirectoryPath downloadDirectory + ) + { + var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name); + var modelInfo = new ConnectedModelInfo(model, modelVersion, modelFile, DateTime.UtcNow); + + await modelInfo.SaveJsonToDirectory(downloadDirectory, modelFileName); + + var jsonName = $"{modelFileName}.cm-info.json"; + return downloadDirectory.JoinFile(jsonName); + } + + /// + /// Saves the preview image to the same directory as the model file + /// + /// + /// + /// The file path of the saved preview image + public async Task SavePreviewImage(CivitModelVersion modelVersion, FilePath modelFilePath) + { + // Skip if model has no images + if (modelVersion.Images == null || modelVersion.Images.Count == 0) + { + return null; + } + + var image = modelVersion.Images.FirstOrDefault(x => x.Type == "image"); + if (image is null) + return null; + + var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); + if (imageExtension is "jpg" or "jpeg" or "png") + { + var imageDownloadPath = modelFilePath.Directory!.JoinFile( + $"{modelFilePath.NameWithoutExtension}.preview.{imageExtension}" + ); + + var imageTask = downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); + await notificationService.TryAsync(imageTask, "Could not download preview image"); + + return imageDownloadPath; + } + + return null; + } + + public async Task DoImport( + CivitModel model, + DirectoryPath downloadFolder, + CivitModelVersion? selectedVersion = null, + CivitFile? selectedFile = null, + IProgress? progress = null, + Func? onImportComplete = null, + Func? onImportFailed = null + ) + { + // Get latest version + var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault(); + if (modelVersion is null) + { + notificationService.Show( + new Notification( + "Model has no versions available", + "This model has no versions available for download", + NotificationType.Warning + ) + ); + return; + } + + // Get latest version file + var modelFile = + selectedFile ?? modelVersion.Files?.FirstOrDefault(x => x.Type == CivitFileType.Model); + if (modelFile is null) + { + notificationService.Show( + new Notification( + "Model has no files available", + "This model has no files available for download", + NotificationType.Warning + ) + ); + return; + } + + // Folders might be missing if user didn't install any packages yet + downloadFolder.Create(); + + var downloadPath = downloadFolder.JoinFile(modelFile.Name); + + // Download model info and preview first + var cmInfoPath = await SaveCmInfo(model, modelVersion, modelFile, downloadFolder); + var previewImagePath = await SavePreviewImage(modelVersion, downloadPath); + + // Create tracked download + var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath); + + // Add hash info + download.ExpectedHashSha256 = modelFile.Hashes.SHA256; + + // Add files to cleanup list + download.ExtraCleanupFileNames.Add(cmInfoPath); + if (previewImagePath is not null) + { + download.ExtraCleanupFileNames.Add(previewImagePath); + } + + // Attach for progress updates + download.ProgressUpdate += (s, e) => + { + progress?.Report(e); + }; + + download.ProgressStateChanged += (s, e) => + { + if (e == ProgressState.Success) + { + onImportComplete?.Invoke().SafeFireAndForget(); + } + else if (e == ProgressState.Cancelled) + { + // todo? + } + else if (e == ProgressState.Failed) + { + onImportFailed?.Invoke().SafeFireAndForget(); + } + }; + + // Add hash context action + download.ContextAction = CivitPostDownloadContextAction.FromCivitFile(modelFile); + + download.Start(); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/ProgressViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Base/ProgressViewModel.cs index 40375dfaf..80948c634 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/ProgressViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/ProgressViewModel.cs @@ -22,6 +22,11 @@ public partial class ProgressViewModel : ViewModelBase [ObservableProperty, NotifyPropertyChangedFor(nameof(IsProgressVisible))] private bool isIndeterminate; + [ObservableProperty, NotifyPropertyChangedFor(nameof(FormattedDownloadSpeed))] + private double downloadSpeedInMBps; + + public string FormattedDownloadSpeed => $"{DownloadSpeedInMBps:0.00} MB/s"; + public virtual bool IsProgressVisible => Value > 0 || IsIndeterminate; public virtual bool IsTextVisible => !string.IsNullOrWhiteSpace(Text); diff --git a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs index 3d294976e..4bc3c5cbb 100644 --- a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; @@ -23,12 +24,15 @@ using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Exceptions; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; @@ -48,7 +52,9 @@ public partial class NewCheckpointsPageViewModel( ModelFinder modelFinder, IDownloadService downloadService, INotificationService notificationService, - IMetadataImportService metadataImportService + IMetadataImportService metadataImportService, + IModelImportService modelImportService, + ServiceManager dialogFactory ) : PageViewModelBase { public override string Title => Resources.Label_CheckpointManager; @@ -123,10 +129,21 @@ protected override void OnInitialLoaded() (Func)( file => string.IsNullOrWhiteSpace(SearchQuery) - || file.FileNameWithoutExtension.Contains( + || file.DisplayModelFileName.Contains( SearchQuery, StringComparison.OrdinalIgnoreCase ) + || file.DisplayModelName.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) + || file.DisplayModelVersion.Contains( + SearchQuery, + StringComparison.OrdinalIgnoreCase + ) + || ( + file.ConnectedModelInfo?.TrainedWordsString.Contains( + SearchQuery, + StringComparison.OrdinalIgnoreCase + ) ?? false + ) ) ) .AsObservable(); @@ -203,6 +220,14 @@ or nameof(SortConnectedModelsFirst) comparer = comparer.ThenByAscending(vm => vm.CheckpointFile.DisplayModelName); break; + case CheckpointSortMode.UpdateAvailable: + comparer = + SelectedSortDirection == ListSortDirection.Ascending + ? comparer.ThenByAscending(vm => vm.CheckpointFile.HasUpdate) + : comparer.ThenByDescending(vm => vm.CheckpointFile.HasUpdate); + comparer = comparer.ThenByAscending(vm => vm.CheckpointFile.DisplayModelName); + comparer = comparer.ThenByDescending(vm => vm.CheckpointFile.DisplayModelVersion); + break; default: throw new ArgumentOutOfRangeException(); } @@ -234,7 +259,7 @@ or nameof(SortConnectedModelsFirst) true ); - Refresh(); + Refresh().SafeFireAndForget(); EventManager.Instance.ModelIndexChanged += (_, _) => { @@ -287,9 +312,10 @@ public void ClearSearchQuery() } [RelayCommand] - private void Refresh() + private async Task Refresh() { - modelIndexService.RefreshIndex(); + await modelIndexService.RefreshIndex(); + Task.Run(async () => await modelIndexService.CheckModelsForUpdateAsync()).SafeFireAndForget(); } [RelayCommand] @@ -367,6 +393,116 @@ private async Task ScanMetadata(bool updateExistingMetadata) notificationService.Show("Scan Complete", message, NotificationType.Success); } + [RelayCommand] + private Task OnItemClick(CheckpointFileViewModel item) + { + // Select item if we're in "select mode" + if (NumItemsSelected > 0) + { + item.IsSelected = !item.IsSelected; + } + else if (item.CheckpointFile.HasConnectedModel) + { + return ShowVersionDialog(item); + } + else + { + item.IsSelected = !item.IsSelected; + } + + return Task.CompletedTask; + } + + [RelayCommand] + private async Task ShowVersionDialog(CheckpointFileViewModel item) + { + var model = item.CheckpointFile.LatestModelInfo; + if (model is null) + { + notificationService.Show( + "Model not found", + "Model not found in index, please try again later.", + NotificationType.Error + ); + return; + } + + var versions = model.ModelVersions; + if (versions is null || versions.Count == 0) + { + notificationService.Show( + new Notification( + "Model has no versions available", + "This model has no versions available for download", + NotificationType.Warning + ) + ); + return; + } + + item.IsLoading = true; + + var dialog = new BetterContentDialog + { + Title = model.Name, + IsPrimaryButtonEnabled = false, + IsSecondaryButtonEnabled = false, + IsFooterVisible = false, + MaxDialogWidth = 750, + MaxDialogHeight = 950, + }; + + var prunedDescription = Utilities.RemoveHtml(model.Description); + + var viewModel = dialogFactory.Get(); + viewModel.Dialog = dialog; + viewModel.Title = model.Name; + viewModel.Description = prunedDescription; + viewModel.CivitModel = model; + viewModel.Versions = versions + .Select( + version => + new ModelVersionViewModel( + settingsManager.Settings.InstalledModelHashes ?? new HashSet(), + version + ) + ) + .ToImmutableArray(); + viewModel.SelectedVersionViewModel = viewModel.Versions[0]; + + dialog.Content = new SelectModelVersionDialog { DataContext = viewModel }; + + var result = await dialog.ShowAsync(); + + if (result != ContentDialogResult.Primary) + { + DelayedClearViewModelProgress(item, TimeSpan.FromMilliseconds(100)); + return; + } + + var selectedVersion = viewModel?.SelectedVersionViewModel?.ModelVersion; + var selectedFile = viewModel?.SelectedFile?.CivitFile; + + DirectoryPath downloadPath; + if (viewModel?.IsCustomSelected is true) + { + downloadPath = viewModel.CustomInstallLocation; + } + else + { + var subFolder = + viewModel?.SelectedInstallLocation + ?? Path.Combine(@"Models", model.Type.ConvertTo().GetStringValue()); + downloadPath = Path.Combine(settingsManager.LibraryDir, subFolder); + } + + await Task.Delay(100); + await modelImportService.DoImport(model, downloadPath, selectedVersion, selectedFile); + + item.Progress = new ProgressReport(1f, "Import started. Check the downloads tab for progress."); + DelayedClearViewModelProgress(item, TimeSpan.FromMilliseconds(1000)); + } + public async Task ImportFilesAsync(IEnumerable files, DirectoryPath destinationFolder) { if (destinationFolder.FullPath == settingsManager.ModelsDirectory) @@ -619,4 +755,14 @@ private void DelayedClearProgress(TimeSpan delay) Progress.Value = 0; }); } + + private void DelayedClearViewModelProgress(CheckpointFileViewModel viewModel, TimeSpan delay) + { + Task.Delay(delay) + .ContinueWith(_ => + { + viewModel.IsLoading = false; + viewModel.Progress = new ProgressReport(0f, ""); + }); + } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs index 4281a5b50..40f937d22 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Progress/DownloadProgressItemViewModel.cs @@ -9,8 +9,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Progress; public class DownloadProgressItemViewModel : PausableProgressItemViewModelBase { private readonly TrackedDownload download; - - public DownloadProgressItemViewModel(TrackedDownload download) + + public DownloadProgressItemViewModel(TrackedDownload download) { this.download = download; @@ -18,20 +18,21 @@ public DownloadProgressItemViewModel(TrackedDownload download) Name = download.FileName; State = download.ProgressState; OnProgressStateChanged(State); - + // If initial progress provided, load it - if (download is {TotalBytes: > 0, DownloadedBytes: > 0}) + if (download is { TotalBytes: > 0, DownloadedBytes: > 0 }) { - var current = download.DownloadedBytes / (double) download.TotalBytes; - Progress.Value = (float) Math.Ceiling(Math.Clamp(current, 0, 1) * 100); + var current = download.DownloadedBytes / (double)download.TotalBytes; + Progress.Value = (float)Math.Ceiling(Math.Clamp(current, 0, 1) * 100); } - + download.ProgressUpdate += (s, e) => { Progress.Value = e.Percentage; Progress.IsIndeterminate = e.IsIndeterminate; + Progress.DownloadSpeedInMBps = e.SpeedInMBps; }; - + download.ProgressStateChanged += (s, e) => { State = e; @@ -76,7 +77,7 @@ public override Task Pause() download.Pause(); return Task.CompletedTask; } - + /// public override Task Resume() { diff --git a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml index 0bcead2bd..875c0267c 100644 --- a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml @@ -18,6 +18,7 @@ xmlns:models="clr-namespace:StabilityMatrix.Core.Models;assembly=StabilityMatrix.Core" xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:helpers="clr-namespace:StabilityMatrix.Avalonia.Helpers" + xmlns:input="clr-namespace:FluentAvalonia.UI.Input;assembly=FluentAvalonia" mc:Ignorable="d" d:DesignWidth="850" d:DesignHeight="650" x:Class="StabilityMatrix.Avalonia.Views.NewCheckpointsPage" d:DataContext="{x:Static mocks:DesignData.NewCheckpointsPageViewModel}" @@ -29,6 +30,8 @@ False True + @@ -360,7 +363,8 @@ Padding="0" ClipToBounds="True" CornerRadius="12" - Command="{Binding ToggleSelectionCommand}"> + Command="{StaticResource ItemClickCommand}" + CommandParameter="{Binding }"> - - - - - - - - - - - - - - - - + + + + @@ -516,7 +521,7 @@ - + + diff --git a/StabilityMatrix.Core/Database/LiteDbContext.cs b/StabilityMatrix.Core/Database/LiteDbContext.cs index 429c092be..9a87e4779 100644 --- a/StabilityMatrix.Core/Database/LiteDbContext.cs +++ b/StabilityMatrix.Core/Database/LiteDbContext.cs @@ -70,7 +70,10 @@ private LiteDatabaseAsync CreateDatabase() } catch (IOException e) { - logger.LogWarning("Database in use or not accessible ({Message}), using temporary database", e.Message); + logger.LogWarning( + "Database in use or not accessible ({Message}), using temporary database", + e.Message + ); } } @@ -80,6 +83,7 @@ private LiteDatabaseAsync CreateDatabase() // Register reference fields LiteDBExtensions.Register(m => m.ModelVersions, "CivitModelVersions"); LiteDBExtensions.Register(e => e.Items, "CivitModels"); + LiteDBExtensions.Register(e => e.LatestModelInfo, "CivitModels"); return db; } @@ -89,7 +93,10 @@ private LiteDatabaseAsync CreateDatabase() var version = await CivitModelVersions .Query() .Where( - mv => mv.Files!.Select(f => f.Hashes).Select(hashes => hashes.BLAKE3).Any(hash => hash == hashBlake3) + mv => + mv.Files!.Select(f => f.Hashes) + .Select(hashes => hashes.BLAKE3) + .Any(hash => hash == hashBlake3) ) .FirstOrDefaultAsync() .ConfigureAwait(false); @@ -110,7 +117,9 @@ private LiteDatabaseAsync CreateDatabase() public async Task UpsertCivitModelAsync(CivitModel civitModel) { // Insert model versions first then model - var versionsUpdated = await CivitModelVersions.UpsertAsync(civitModel.ModelVersions).ConfigureAwait(false); + var versionsUpdated = await CivitModelVersions + .UpsertAsync(civitModel.ModelVersions) + .ConfigureAwait(false); var updated = await CivitModels.UpsertAsync(civitModel).ConfigureAwait(false); // Notify listeners on any change var anyUpdated = versionsUpdated > 0 || updated; @@ -162,7 +171,8 @@ public async Task UpsertCivitModelQueryCacheEntryAsync(CivitModelQueryCach return null; } - public Task UpsertGithubCacheEntry(GithubCacheEntry cacheEntry) => GithubCache.UpsertAsync(cacheEntry); + public Task UpsertGithubCacheEntry(GithubCacheEntry cacheEntry) => + GithubCache.UpsertAsync(cacheEntry); public void Dispose() { diff --git a/StabilityMatrix.Core/Helper/ModelFinder.cs b/StabilityMatrix.Core/Helper/ModelFinder.cs index bdce054d0..c035edc61 100644 --- a/StabilityMatrix.Core/Helper/ModelFinder.cs +++ b/StabilityMatrix.Core/Helper/ModelFinder.cs @@ -109,17 +109,22 @@ public async Task> FindRemoteModelsById(IEnumerable { var results = new List(); - // split ids into batches of 20 - var batches = ids.Select((id, index) => (id, index)) - .GroupBy(tuple => tuple.index / 20) - .Select(group => group.Select(tuple => tuple.id)); + // split ids into batches of 100 + var batches = ids.Chunk(100); foreach (var batch in batches) { try { var response = await civitApi - .GetModels(new CivitModelsRequest { CommaSeparatedModelIds = string.Join(",", batch) }) + .GetModels( + new CivitModelsRequest + { + CommaSeparatedModelIds = string.Join(",", batch), + Nsfw = "true", + Query = string.Empty + } + ) .ConfigureAwait(false); if (response.Items == null || response.Items.Count == 0) diff --git a/StabilityMatrix.Core/Models/CheckpointSortMode.cs b/StabilityMatrix.Core/Models/CheckpointSortMode.cs index 61d788ad9..fe1a72a26 100644 --- a/StabilityMatrix.Core/Models/CheckpointSortMode.cs +++ b/StabilityMatrix.Core/Models/CheckpointSortMode.cs @@ -4,15 +4,18 @@ namespace StabilityMatrix.Core.Models; public enum CheckpointSortMode { + [StringValue("Base Model")] + BaseModel, + [StringValue("File Name")] FileName, [StringValue("Title")] Title, - [StringValue("Base Model")] - BaseModel, - [StringValue("Type")] - SharedFolderType + SharedFolderType, + + [StringValue("Update Available")] + UpdateAvailable, } diff --git a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs index a4be58049..4f4d85370 100644 --- a/StabilityMatrix.Core/Models/Database/LocalModelFile.cs +++ b/StabilityMatrix.Core/Models/Database/LocalModelFile.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using LiteDB; using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models.Api; namespace StabilityMatrix.Core.Models.Database; @@ -11,7 +12,7 @@ public record LocalModelFile { private sealed class RelativePathConnectedModelInfoEqualityComparer : IEqualityComparer { - public bool Equals(LocalModelFile x, LocalModelFile y) + public bool Equals(LocalModelFile? x, LocalModelFile? y) { if (ReferenceEquals(x, y)) return true; @@ -21,12 +22,14 @@ public bool Equals(LocalModelFile x, LocalModelFile y) return false; if (x.GetType() != y.GetType()) return false; - return x.RelativePath == y.RelativePath && Equals(x.ConnectedModelInfo, y.ConnectedModelInfo); + return x.RelativePath == y.RelativePath + && Equals(x.ConnectedModelInfo, y.ConnectedModelInfo) + && x.HasUpdate == y.HasUpdate; } public int GetHashCode(LocalModelFile obj) { - return HashCode.Combine(obj.RelativePath, obj.ConnectedModelInfo); + return HashCode.Combine(obj.RelativePath, obj.ConnectedModelInfo, obj.HasUpdate); } } @@ -39,12 +42,14 @@ public virtual bool Equals(LocalModelFile? other) return false; if (ReferenceEquals(this, other)) return true; - return RelativePath == other.RelativePath && Equals(ConnectedModelInfo, other.ConnectedModelInfo); + return RelativePath == other.RelativePath + && Equals(ConnectedModelInfo, other.ConnectedModelInfo) + && HasUpdate == other.HasUpdate; } public override int GetHashCode() { - return HashCode.Combine(RelativePath, ConnectedModelInfo); + return HashCode.Combine(RelativePath, ConnectedModelInfo, HasUpdate); } /// @@ -88,6 +93,12 @@ public override int GetHashCode() /// public DateTimeOffset LastUpdateCheck { get; set; } + /// + /// The latest CivitModel info + /// + [BsonRef("CivitModels")] + public CivitModel? LatestModelInfo { get; set; } + /// /// File name of the relative path. /// diff --git a/StabilityMatrix.Core/Models/Progress/ProgressReport.cs b/StabilityMatrix.Core/Models/Progress/ProgressReport.cs index 04322f30d..e3f532a28 100644 --- a/StabilityMatrix.Core/Models/Progress/ProgressReport.cs +++ b/StabilityMatrix.Core/Models/Progress/ProgressReport.cs @@ -25,6 +25,7 @@ public record struct ProgressReport public float Percentage => (float)Math.Ceiling(Math.Clamp(Progress ?? 0, 0, 1) * 100); public ProgressType Type { get; init; } = ProgressType.Generic; public bool PrintToConsole { get; init; } = true; + public double SpeedInMBps { get; init; } = 0f; public ProgressReport( double progress, @@ -32,6 +33,7 @@ public ProgressReport( string? message = null, bool isIndeterminate = false, bool printToConsole = true, + double speedInMBps = 0, ProgressType type = ProgressType.Generic ) { @@ -41,6 +43,7 @@ public ProgressReport( IsIndeterminate = isIndeterminate; Type = type; PrintToConsole = printToConsole; + SpeedInMBps = speedInMBps; } public ProgressReport( @@ -50,6 +53,7 @@ public ProgressReport( string? message = null, bool isIndeterminate = false, bool printToConsole = true, + double speedInMBps = 0, ProgressType type = ProgressType.Generic ) { @@ -61,6 +65,7 @@ public ProgressReport( IsIndeterminate = isIndeterminate; Type = type; PrintToConsole = printToConsole; + SpeedInMBps = speedInMBps; } public ProgressReport( @@ -70,6 +75,7 @@ public ProgressReport( string? message = null, bool isIndeterminate = false, bool printToConsole = true, + double speedInMBps = 0, ProgressType type = ProgressType.Generic ) { @@ -85,6 +91,7 @@ public ProgressReport( IsIndeterminate = isIndeterminate; Type = type; PrintToConsole = printToConsole; + SpeedInMBps = speedInMBps; } public ProgressReport( diff --git a/StabilityMatrix.Core/Services/DownloadService.cs b/StabilityMatrix.Core/Services/DownloadService.cs index 5235d3414..1429afc32 100644 --- a/StabilityMatrix.Core/Services/DownloadService.cs +++ b/StabilityMatrix.Core/Services/DownloadService.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Diagnostics; +using System.Net; using System.Net.Http.Headers; using Microsoft.Extensions.Logging; using Polly.Contrib.WaitAndRetry; @@ -90,6 +91,7 @@ public async Task DownloadToFileAsync( await using var stream = await response .Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); + var stopwatch = Stopwatch.StartNew(); var totalBytesRead = 0L; var buffer = new byte[BufferSize]; while (true) @@ -101,6 +103,9 @@ public async Task DownloadToFileAsync( totalBytesRead += bytesRead; + var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; + var speedInMBps = (totalBytesRead / elapsedSeconds) / (1024 * 1024); + if (isIndeterminate) { progress?.Report(new ProgressReport(-1, isIndeterminate: true)); @@ -112,7 +117,8 @@ public async Task DownloadToFileAsync( current: Convert.ToUInt64(totalBytesRead), total: Convert.ToUInt64(contentLength), message: "Downloading...", - printToConsole: false + printToConsole: false, + speedInMBps: speedInMBps ) ); } @@ -221,6 +227,7 @@ var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), .Content.ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); var totalBytesRead = 0L; + var stopwatch = Stopwatch.StartNew(); var buffer = new byte[BufferSize]; while (true) { @@ -233,6 +240,9 @@ var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), totalBytesRead += bytesRead; + var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; + var speedInMBps = (totalBytesRead / elapsedSeconds) / (1024 * 1024); + if (isIndeterminate) { progress?.Report(new ProgressReport(-1, isIndeterminate: true)); @@ -245,7 +255,8 @@ var delay in Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(50), current: Convert.ToUInt64(totalBytesRead + existingFileSize), // Total as the original total total: Convert.ToUInt64(originalContentLength), - message: "Downloading..." + message: "Downloading...", + speedInMBps: speedInMBps ) ); } diff --git a/StabilityMatrix.Core/Services/IModelIndexService.cs b/StabilityMatrix.Core/Services/IModelIndexService.cs index 7cfe2f20e..bb4c76486 100644 --- a/StabilityMatrix.Core/Services/IModelIndexService.cs +++ b/StabilityMatrix.Core/Services/IModelIndexService.cs @@ -43,4 +43,5 @@ public interface IModelIndexService Task RemoveModelAsync(LocalModelFile model); Task RemoveModelsAsync(IEnumerable models); + Task CheckModelsForUpdateAsync(); } diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index 934eacb55..70e08b0e2 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -7,7 +7,6 @@ using AutoCtor; using KGySoft.CoreLibraries; using Microsoft.Extensions.Logging; -using OneOf.Types; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Database; using StabilityMatrix.Core.Extensions; @@ -28,6 +27,8 @@ public partial class ModelIndexService : IModelIndexService private readonly ILiteDbContext liteDbContext; private readonly ModelFinder modelFinder; + private DateTimeOffset lastUpdateCheck = DateTimeOffset.MinValue; + /// /// Whether the database has been initially loaded. /// @@ -88,7 +89,7 @@ private async Task LoadFromDbAsync() logger.LogInformation("Loading models from database..."); var allModels = ( - await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) + await liteDbContext.LocalModelFiles.IncludeAll().FindAllAsync().ConfigureAwait(false) ).ToImmutableArray(); ModelIndex = allModels.GroupBy(m => m.SharedFolderType).ToDictionary(g => g.Key, g => g.ToList()); @@ -445,9 +446,17 @@ private async Task RefreshIndexParallelCore() var newIndexComplete = newIndexFlat.ToArray(); + var modelsDict = ModelIndex.Values.SelectMany(x => x).ToDictionary(f => f.RelativePath, file => file); + var newIndex = new Dictionary>(); foreach (var model in newIndexComplete) { + if (modelsDict.TryGetValue(model.RelativePath, out var dbModel)) + { + model.HasUpdate = dbModel.HasUpdate; + model.LastUpdateCheck = dbModel.LastUpdateCheck; + model.LatestModelInfo = dbModel.LatestModelInfo; + } var list = newIndex.GetOrAdd(model.SharedFolderType); list.Add(model); } @@ -461,7 +470,6 @@ private async Task RefreshIndexParallelCore() stopwatch.Restart(); using var db = await liteDbContext.Database.BeginTransactionAsync().ConfigureAwait(false); - var localModelFiles = db.GetCollection("LocalModelFiles")!; await localModelFiles.DeleteAllAsync().ConfigureAwait(false); @@ -539,17 +547,25 @@ public async Task RemoveModelsAsync(IEnumerable models) // idk do somethin with this public async Task CheckModelsForUpdateAsync() { - var installedHashes = settingsManager.Settings.InstalledModelHashes; + if (DateTimeOffset.UtcNow < lastUpdateCheck.AddMinutes(5)) + { + return; + } + + var installedHashes = settingsManager.Settings.InstalledModelHashes ?? []; var dbModels = ( await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) ?? Enumerable.Empty() ).ToList(); + var ids = dbModels .Where(x => x.ConnectedModelInfo != null) .Where( x => x.LastUpdateCheck == default || x.LastUpdateCheck < DateTimeOffset.UtcNow.AddHours(-8) ) - .Select(x => x.ConnectedModelInfo!.ModelId); + .Select(x => x.ConnectedModelInfo!.ModelId) + .Distinct(); + var remoteModels = (await modelFinder.FindRemoteModelsById(ids).ConfigureAwait(false)).ToList(); foreach (var dbModel in dbModels) @@ -563,15 +579,24 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) var latestHashes = latestVersion ?.Files ?.Where(f => f.Type == CivitFileType.Model) - .Select(f => f.Hashes.BLAKE3); + .Select(f => f.Hashes.BLAKE3) + .ToList(); if (latestHashes == null) continue; - dbModel.HasUpdate = !latestHashes.Any(hash => installedHashes?.Contains(hash) ?? false); + ModelIndex[dbModel.SharedFolderType].Remove(dbModel); + + dbModel.HasUpdate = !latestHashes.Any(hash => installedHashes.Contains(hash)); dbModel.LastUpdateCheck = DateTimeOffset.UtcNow; + dbModel.LatestModelInfo = remoteModel; await liteDbContext.LocalModelFiles.UpsertAsync(dbModel).ConfigureAwait(false); + ModelIndex[dbModel.SharedFolderType].Add(dbModel); } + + lastUpdateCheck = DateTimeOffset.UtcNow; + + EventManager.Instance.OnModelIndexChanged(); } } From ed5ad528c358f39f8364008bc94807afad1dfcf0 Mon Sep 17 00:00:00 2001 From: JT Date: Sat, 18 May 2024 17:45:18 -0700 Subject: [PATCH 076/239] fixes & chagenlog --- CHANGELOG.md | 6 +++ .../ViewModels/NewCheckpointsPageViewModel.cs | 10 ++--- .../Models/Api/CivitFileType.cs | 4 ++ .../PackageModification/ImportModelsStep.cs | 8 +++- .../Services/ModelIndexService.cs | 42 +++++++++++++------ 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d938355b..a460c2f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.11.0-dev.3 +### Added +- Added download speed indicator to model downloads in the Downloads tab +### Changed +- Revamped Checkpoints tab now shows available model updates and has better drag & drop functionality + ## v2.11.0-dev.2 ### Added - Added Brazilian Portuguese language option, thanks to jbostroski for the translation! diff --git a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs index 4bc3c5cbb..f340f3f51 100644 --- a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs @@ -344,6 +344,7 @@ private async Task DeleteAsync() SecondaryButtonText = Resources.Action_Cancel, DefaultButton = ContentDialogButton.Primary, IsSecondaryButtonEnabled = true, + CloseOnClickOutside = true }; var dialogResult = await confirmationDialog.ShowAsync(); @@ -533,6 +534,10 @@ public async Task ImportFilesAsync(IEnumerable files, DirectoryPath dest EventManager.Instance.OnPackageInstallProgressAdded(runner); await runner.ExecuteSteps([importModelsStep]); + + SelectedCategory = Categories + .SelectMany(c => c.Flatten()) + .FirstOrDefault(x => x.Path == destinationFolder.FullPath); } public async Task MoveBetweenFolders(LocalModelFile sourceFile, DirectoryPath destinationFolder) @@ -580,8 +585,6 @@ public async Task MoveBetweenFolders(LocalModelFile sourceFile, DirectoryPath de await FileTransfers.MoveFileAsync(sourcePreviewPath, destinationPreviewPath); } - await modelIndexService.RemoveModelAsync(sourceFile); - notificationService.Show( "Model moved successfully", $"Moved '{sourcePath.Name}' to '{Path.GetFileName(destinationFolder)}'" @@ -690,15 +693,12 @@ private void RefreshCategories() ?? Categories.FirstOrDefault(x => x.Path == previouslySelectedCategory?.Path) ?? Categories.First(); - var sw = Stopwatch.StartNew(); foreach (var checkpointCategory in Categories.SelectMany(c => c.Flatten())) { checkpointCategory.Count = Directory .EnumerateFileSystemEntries(checkpointCategory.Path, "*", SearchOption.AllDirectories) .Count(x => CheckpointFile.SupportedCheckpointExtensions.Contains(Path.GetExtension(x))); } - sw.Stop(); - Console.WriteLine($"counting took {sw.Elapsed.Milliseconds}ms"); } private ObservableCollection GetSubfolders(string strPath) diff --git a/StabilityMatrix.Core/Models/Api/CivitFileType.cs b/StabilityMatrix.Core/Models/Api/CivitFileType.cs index b41089241..ef142a8c7 100644 --- a/StabilityMatrix.Core/Models/Api/CivitFileType.cs +++ b/StabilityMatrix.Core/Models/Api/CivitFileType.cs @@ -10,6 +10,10 @@ public enum CivitFileType Unknown, Model, VAE, + Config, + + [EnumMember(Value = "Pruned Model")] + PrunedModel, [EnumMember(Value = "Training Data")] TrainingData diff --git a/StabilityMatrix.Core/Models/PackageModification/ImportModelsStep.cs b/StabilityMatrix.Core/Models/PackageModification/ImportModelsStep.cs index 8d6d0e8aa..2ef04ebc6 100644 --- a/StabilityMatrix.Core/Models/PackageModification/ImportModelsStep.cs +++ b/StabilityMatrix.Core/Models/PackageModification/ImportModelsStep.cs @@ -99,7 +99,9 @@ public async Task ExecuteAsync(IProgress? progress = null) // Save connected model info json var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Info.Name); var modelInfo = new ConnectedModelInfo(model, version, file, DateTimeOffset.UtcNow); - await modelInfo.SaveJsonToDirectory(destinationFolder, modelFileName); + await modelInfo + .SaveJsonToDirectory(destinationFolder, modelFileName) + .ConfigureAwait(false); // If available, save thumbnail var image = version.Images?.FirstOrDefault(x => x.Type == "image"); @@ -111,7 +113,9 @@ public async Task ExecuteAsync(IProgress? progress = null) var imageDownloadPath = Path.GetFullPath( Path.Combine(destinationFolder, $"{modelFileName}.preview.{imageExt}") ); - await downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); + await downloadService + .DownloadToFileAsync(image.Url, imageDownloadPath) + .ConfigureAwait(false); } } diff --git a/StabilityMatrix.Core/Services/ModelIndexService.cs b/StabilityMatrix.Core/Services/ModelIndexService.cs index 70e08b0e2..72e4ce394 100644 --- a/StabilityMatrix.Core/Services/ModelIndexService.cs +++ b/StabilityMatrix.Core/Services/ModelIndexService.cs @@ -5,6 +5,7 @@ using System.Text.Json; using AsyncAwaitBestPractices; using AutoCtor; +using JetBrains.Annotations; using KGySoft.CoreLibraries; using Microsoft.Extensions.Logging; using StabilityMatrix.Core.Attributes; @@ -95,6 +96,7 @@ await liteDbContext.LocalModelFiles.IncludeAll().FindAllAsync().ConfigureAwait(f ModelIndex = allModels.GroupBy(m => m.SharedFolderType).ToDictionary(g => g.Key, g => g.ToList()); IsDbLoaded = true; + EventManager.Instance.OnModelIndexChanged(); timer.Stop(); logger.LogInformation( @@ -446,7 +448,10 @@ private async Task RefreshIndexParallelCore() var newIndexComplete = newIndexFlat.ToArray(); - var modelsDict = ModelIndex.Values.SelectMany(x => x).ToDictionary(f => f.RelativePath, file => file); + var modelsDict = ModelIndex + .Values.SelectMany(x => x) + .DistinctBy(f => f.RelativePath) + .ToDictionary(f => f.RelativePath, file => file); var newIndex = new Dictionary>(); foreach (var model in newIndexComplete) @@ -457,6 +462,16 @@ private async Task RefreshIndexParallelCore() model.LastUpdateCheck = dbModel.LastUpdateCheck; model.LatestModelInfo = dbModel.LatestModelInfo; } + + if (model.LatestModelInfo == null && model.HasConnectedModel) + { + var civitModel = await liteDbContext + .CivitModels.Include(m => m.ModelVersions) + .FindByIdAsync(model.ConnectedModelInfo.ModelId) + .ConfigureAwait(false); + + model.LatestModelInfo = civitModel; + } var list = newIndex.GetOrAdd(model.SharedFolderType); list.Add(model); } @@ -544,7 +559,6 @@ public async Task RemoveModelsAsync(IEnumerable models) return result; } - // idk do somethin with this public async Task CheckModelsForUpdateAsync() { if (DateTimeOffset.UtcNow < lastUpdateCheck.AddMinutes(5)) @@ -552,6 +566,8 @@ public async Task CheckModelsForUpdateAsync() return; } + lastUpdateCheck = DateTimeOffset.UtcNow; + var installedHashes = settingsManager.Settings.InstalledModelHashes ?? []; var dbModels = ( await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) @@ -560,14 +576,15 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) var ids = dbModels .Where(x => x.ConnectedModelInfo != null) - .Where( - x => x.LastUpdateCheck == default || x.LastUpdateCheck < DateTimeOffset.UtcNow.AddHours(-8) - ) .Select(x => x.ConnectedModelInfo!.ModelId) .Distinct(); var remoteModels = (await modelFinder.FindRemoteModelsById(ids).ConfigureAwait(false)).ToList(); + // update the civitmodels cache with this new result + await liteDbContext.UpsertCivitModelAsync(remoteModels).ConfigureAwait(false); + + var localModelsToUpdate = new List(); foreach (var dbModel in dbModels) { if (dbModel.ConnectedModelInfo == null) @@ -585,18 +602,19 @@ await liteDbContext.LocalModelFiles.FindAllAsync().ConfigureAwait(false) if (latestHashes == null) continue; - ModelIndex[dbModel.SharedFolderType].Remove(dbModel); - dbModel.HasUpdate = !latestHashes.Any(hash => installedHashes.Contains(hash)); dbModel.LastUpdateCheck = DateTimeOffset.UtcNow; dbModel.LatestModelInfo = remoteModel; - await liteDbContext.LocalModelFiles.UpsertAsync(dbModel).ConfigureAwait(false); - ModelIndex[dbModel.SharedFolderType].Add(dbModel); + localModelsToUpdate.Add(dbModel); } + await liteDbContext.LocalModelFiles.UpsertAsync(localModelsToUpdate).ConfigureAwait(false); + await LoadFromDbAsync().ConfigureAwait(false); + } - lastUpdateCheck = DateTimeOffset.UtcNow; - - EventManager.Instance.OnModelIndexChanged(); + public async Task UpsertModelAsync(LocalModelFile model) + { + await liteDbContext.LocalModelFiles.UpsertAsync(model).ConfigureAwait(false); + await LoadFromDbAsync().ConfigureAwait(false); } } From 2bdfbacdd5d8bd5f338034b65e5b4abc4b4b8435 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 19 May 2024 13:37:09 -0700 Subject: [PATCH 077/239] dont allow just any ol file to get imported --- .../ViewModels/NewCheckpointsPageViewModel.cs | 17 +++++++- .../Views/NewCheckpointsPage.axaml.cs | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs index f340f3f51..52da42165 100644 --- a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs @@ -516,11 +516,26 @@ public async Task ImportFilesAsync(IEnumerable files, DirectoryPath dest return; } + var fileList = files.ToList(); + if ( + fileList.Any( + file => !CheckpointFile.SupportedCheckpointExtensions.Contains(Path.GetExtension(file)) + ) + ) + { + notificationService.Show( + "Invalid File", + "Please select only checkpoint files to import.", + NotificationType.Error + ); + return; + } + var importModelsStep = new ImportModelsStep( modelFinder, downloadService, modelIndexService, - files, + fileList, destinationFolder, IsImportAsConnectedEnabled ); diff --git a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs index a4c9938f1..a417de83b 100644 --- a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Avalonia.Controls; using Avalonia.Input; @@ -28,6 +29,20 @@ public NewCheckpointsPage() AddHandler(DragDrop.DropEvent, OnDrop); AddHandler(DragDrop.DragEnterEvent, OnDragEnter); AddHandler(DragDrop.DragLeaveEvent, OnDragExit); + AddHandler(DragDrop.DragOverEvent, OnDragOver); + } + + private void OnDragOver(object? sender, DragEventArgs e) + { + if (e.Data.Get(DataFormats.Files) is not IEnumerable files) + return; + + var paths = files.Select(f => f.Path.LocalPath); + if (paths.All(p => CheckpointFile.SupportedCheckpointExtensions.Contains(Path.GetExtension(p)))) + return; + + e.DragEffects = DragDropEffects.None; + e.Handled = true; } private void OnDragExit(object? sender, DragEventArgs e) @@ -80,6 +95,31 @@ private void OnDragEnter(object? sender, DragEventArgs e) if (DataContext as NewCheckpointsPageViewModel is not { SelectedCategory: not null } checkpointsVm) return; + // Only allow Copy or Link as Drop Operations. + e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link; + + // Only allow if the dragged data contains text or filenames. + if (!e.Data.Contains(DataFormats.Text) && !e.Data.Contains(DataFormats.Files)) + { + e.DragEffects = DragDropEffects.None; + } + + if (e.Data.Get(DataFormats.Files) is IEnumerable files) + { + var paths = files.Select(f => f.Path.LocalPath); + if ( + paths.Any( + p => + !CheckpointFile.SupportedCheckpointExtensions.Contains(System.IO.Path.GetExtension(p)) + ) + ) + { + e.DragEffects = DragDropEffects.None; + e.Handled = true; + return; + } + } + switch (control) { case TreeViewItem treeViewItem: From e836bafcd4082d47ac605a29740fce546979feb6 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 19 May 2024 13:39:29 -0700 Subject: [PATCH 078/239] removed some unnecessary stuff --- .../Views/NewCheckpointsPage.axaml.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs index a417de83b..6b2c1c8d9 100644 --- a/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/NewCheckpointsPage.axaml.cs @@ -95,15 +95,6 @@ private void OnDragEnter(object? sender, DragEventArgs e) if (DataContext as NewCheckpointsPageViewModel is not { SelectedCategory: not null } checkpointsVm) return; - // Only allow Copy or Link as Drop Operations. - e.DragEffects &= DragDropEffects.Copy | DragDropEffects.Link; - - // Only allow if the dragged data contains text or filenames. - if (!e.Data.Contains(DataFormats.Text) && !e.Data.Contains(DataFormats.Files)) - { - e.DragEffects = DragDropEffects.None; - } - if (e.Data.Get(DataFormats.Files) is IEnumerable files) { var paths = files.Select(f => f.Path.LocalPath); From e61998803744a83fe53818991a8d1590f3ace30e Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 19 May 2024 14:25:14 -0700 Subject: [PATCH 079/239] Add visionary shoutout to chagenlog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a460c2f4d..464f93153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). ## v2.11.0-dev.3 +### Supporters +#### Visionaries +- Special thanks to our first Visionary on Patreon, **Scopp Mcdee**, for their generous support! ### Added - Added download speed indicator to model downloads in the Downloads tab ### Changed From 8de192f5abc2a8a3e60feadee21028b0ed888d5b Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 19 May 2024 15:07:57 -0700 Subject: [PATCH 080/239] rearrange --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464f93153..41ac47fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). ## v2.11.0-dev.3 -### Supporters -#### Visionaries -- Special thanks to our first Visionary on Patreon, **Scopp Mcdee**, for their generous support! ### Added - Added download speed indicator to model downloads in the Downloads tab ### Changed - Revamped Checkpoints tab now shows available model updates and has better drag & drop functionality +### Supporters +#### Visionaries +- Special thanks to our first Visionary on Patreon, **Scopp Mcdee**, for their generous support! ## v2.11.0-dev.2 ### Added From eaac27ee67b3a4c3adcf8440af24cec9b65f8106 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 19 May 2024 20:31:55 -0400 Subject: [PATCH 081/239] Add ConfirmDeleteDialog --- .../DesignData/DesignData.cs | 10 + .../Languages/Resources.Designer.cs | 36 ++++ .../Languages/Resources.resx | 12 ++ .../Dialogs/ConfirmDeleteDialogViewModel.cs | 198 ++++++++++++++++++ .../Views/Dialogs/ConfirmDeleteDialog.axaml | 94 +++++++++ .../Dialogs/ConfirmDeleteDialog.axaml.cs | 13 ++ StabilityMatrix.Core/Test.cs | 100 +++++++++ 7 files changed, 463 insertions(+) create mode 100644 StabilityMatrix.Avalonia/ViewModels/Dialogs/ConfirmDeleteDialogViewModel.cs create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/ConfirmDeleteDialog.axaml create mode 100644 StabilityMatrix.Avalonia/Views/Dialogs/ConfirmDeleteDialog.axaml.cs create mode 100644 StabilityMatrix.Core/Test.cs diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 71298f668..4afec4e0c 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -1070,6 +1070,16 @@ public static CompletionList SampleCompletionList public static ControlNetCardViewModel ControlNetCardViewModel => DialogFactory.Get(); + public static ConfirmDeleteDialogViewModel ConfirmDeleteDialogViewModel => + DialogFactory.Get(vm => + { + vm.IsRecycleBinAvailable = true; + vm.PathsToDelete = Enumerable + .Range(1, 64) + .Select(i => $"C:/Users/ExampleUser/Data/ExampleFile{i}.txt") + .ToArray(); + }); + public static OpenArtWorkflowViewModel OpenArtWorkflowViewModel => new(Services.GetRequiredService(), Services.GetRequiredService()) { diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 712e11a34..e046237eb 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -293,6 +293,15 @@ public static string Action_Login { } } + /// + /// Looks up a localized string similar to Move to Trash. + /// + public static string Action_MoveToTrash { + get { + return ResourceManager.GetString("Action_MoveToTrash", resourceCulture); + } + } + /// /// Looks up a localized string similar to New. /// @@ -1184,6 +1193,15 @@ public static string Label_Deemphasis { } } + /// + /// Looks up a localized string similar to Delete Permanently. + /// + public static string Label_DeletePermanently { + get { + return ResourceManager.GetString("Label_DeletePermanently", resourceCulture); + } + } + /// /// Looks up a localized string similar to Denoising Strength. /// @@ -2858,6 +2876,15 @@ public static string Text_AppWillRelaunchAfterUpdate { } } + /// + /// Looks up a localized string similar to You are about to delete the following items:. + /// + public static string Text_DeleteFollowingItems { + get { + return ResourceManager.GetString("Text_DeleteFollowingItems", resourceCulture); + } + } + /// /// Looks up a localized string similar to Choose your preferred interface to get started. /// @@ -2912,6 +2939,15 @@ public static string Text_WelcomeToStabilityMatrix { } } + /// + /// Looks up a localized string similar to You are about to delete the following {0} items:. + /// + public static string TextTemplate_DeleteFollowingCountItems { + get { + return ResourceManager.GetString("TextTemplate_DeleteFollowingCountItems", resourceCulture); + } + } + /// /// Looks up a localized string similar to Error updating {0}. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index bd3cea5db..885494009 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1080,4 +1080,16 @@ Number Format + + You are about to delete the following items: + + + You are about to delete the following {0} items: + + + Delete Permanently + + + Move to Trash + diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/ConfirmDeleteDialogViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ConfirmDeleteDialogViewModel.cs new file mode 100644 index 000000000..28ae34fd7 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/ConfirmDeleteDialogViewModel.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls.Primitives; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Native; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +[View(typeof(ConfirmDeleteDialog))] +[Transient] +[ManagedService] +public partial class ConfirmDeleteDialogViewModel(ILogger logger) + : ContentDialogViewModelBase +{ + [ObservableProperty] + private string title = "Confirm Delete"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConfirmDeleteButtonText))] + [NotifyPropertyChangedFor(nameof(IsPermanentDelete))] + [NotifyPropertyChangedFor(nameof(DeleteFollowingFilesText))] + private bool isRecycleBinAvailable = NativeFileOperations.IsRecycleBinAvailable; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConfirmDeleteButtonText))] + [NotifyPropertyChangedFor(nameof(IsPermanentDelete))] + [NotifyPropertyChangedFor(nameof(DeleteFollowingFilesText))] + private bool isRecycleBinOptOutChecked; + + public bool IsPermanentDelete => !IsRecycleBinAvailable || IsRecycleBinOptOutChecked; + + public string ConfirmDeleteButtonText => + IsPermanentDelete ? Resources.Action_Delete : Resources.Action_MoveToTrash; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DeleteFollowingFilesText))] + private IReadOnlyList pathsToDelete = []; + + public string DeleteFollowingFilesText => + PathsToDelete.Count is var count and > 1 + ? string.Format(Resources.TextTemplate_DeleteFollowingCountItems, count) + : Resources.Text_DeleteFollowingItems; + + public bool ShowActionCannotBeUndoneNotice { get; set; } = true; + + /// + public override BetterContentDialog GetDialog() + { + var dialog = base.GetDialog(); + + dialog.MinDialogWidth = 550; + dialog.MaxDialogHeight = 600; + dialog.IsFooterVisible = false; + dialog.CloseOnClickOutside = true; + dialog.ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled; + + return dialog; + } + + [RelayCommand(CanExecute = nameof(CanExecuteConfirmDelete))] + private Task OnConfirmDeleteClick() + { + return Task.CompletedTask; + } + + private bool CanExecuteConfirmDelete() + { + return !HasErrors && IsValid(); + } + + private bool IsValid() + { + return true; + } + + /*public async Task OpenDeleteTaskDialogAsync() + { + var dialog = new TaskDialog + { + Content = new PackageImportDialog { DataContext = viewModel }, + ShowProgressBar = false, + Buttons = new List + { + new(Resources.Action_Import, TaskDialogStandardResult.Yes) { IsDefault = true }, + new(Resources.Action_Cancel, TaskDialogStandardResult.Cancel) + } + }; + + dialog.Closing += async (sender, e) => + { + // We only want to use the deferral on the 'Yes' Button + if ((TaskDialogStandardResult)e.Result == TaskDialogStandardResult.Yes) + { + var deferral = e.GetDeferral(); + + sender.ShowProgressBar = true; + sender.SetProgressBarState(0, TaskDialogProgressState.Indeterminate); + + await using (new MinimumDelay(200, 300)) + { + var result = await notificationService.TryAsync(viewModel.AddPackageWithCurrentInputs()); + if (result.IsSuccessful) + { + EventManager.Instance.OnInstalledPackagesChanged(); + } + } + + deferral.Complete(); + } + }; + + dialog.XamlRoot = App.VisualRoot; + + await dialog.ShowAsync(true); + }*/ + + public async Task ExecuteCurrentDeleteOperationAsync(bool ignoreErrors = false, bool failFast = false) + { + var paths = PathsToDelete; + + var exceptions = new List(); + + if (!IsPermanentDelete) + { + // Recycle bin + if (NativeFileOperations.IsRecycleBinAvailable) + { + throw new NotSupportedException("Recycle bin is not available on this platform"); + } + + await Task.Run(() => + { + try + { + NativeFileOperations.RecycleBin!.MoveFilesToRecycleBin(paths); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to move path to recycle bin"); + + if (!ignoreErrors) + { + exceptions.Add(e); + + if (failFast) + { + throw new AggregateException(exceptions); + } + } + } + }); + } + else + { + await Task.Run(() => + { + foreach (var path in paths) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + else + { + File.Delete(path); + } + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to delete path"); + + if (!ignoreErrors) + { + exceptions.Add(e); + + if (failFast) + { + throw new AggregateException(exceptions); + } + } + } + } + }); + } + } +} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ConfirmDeleteDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ConfirmDeleteDialog.axaml new file mode 100644 index 000000000..4040720a7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/ConfirmDeleteDialog.axaml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + public bool IsImageViewerPixelGridEnabled { get; set; } = true; + + /// + /// Whether Inference Image Browser delete action uses recycle bin if available + /// + public bool IsInferenceImageBrowserUseRecycleBinForDelete { get; set; } = true; + public bool RemoveFolderLinksOnShutdown { get; set; } public bool IsDiscordRichPresenceEnabled { get; set; } From 28057c3f546a4f060f580d4146f5c1b88b60c55e Mon Sep 17 00:00:00 2001 From: Ionite Date: Mon, 20 May 2024 19:12:07 -0400 Subject: [PATCH 094/239] Add settings option for inference image file delete mode --- .../Settings/InferenceSettingsViewModel.cs | 10 ++++++++++ .../Views/Settings/InferenceSettingsPage.axaml | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index 290bed3c5..17afed25d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -62,6 +62,9 @@ public partial class InferenceSettingsViewModel : PageViewModelBase [ObservableProperty] private string? outputImageFileNameFormatSample; + [ObservableProperty] + private bool isInferenceImageBrowserUseRecycleBinForDelete = true; + public IEnumerable OutputImageFileNameFormatVars => FileNameFormatProvider .GetSample() @@ -109,6 +112,13 @@ ISettingsManager settingsManager true ); + settingsManager.RelayPropertyFor( + this, + vm => vm.IsInferenceImageBrowserUseRecycleBinForDelete, + settings => settings.IsInferenceImageBrowserUseRecycleBinForDelete, + true + ); + this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) .Throttle(TimeSpan.FromMilliseconds(50)) .Subscribe(formatProperty => diff --git a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml index 288e57aa6..486515325 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml @@ -14,6 +14,7 @@ xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:vm="clr-namespace:StabilityMatrix.Avalonia.ViewModels" xmlns:vmSettings="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Settings" + xmlns:native="clr-namespace:StabilityMatrix.Native;assembly=StabilityMatrix.Native" d:DataContext="{x:Static mocks:DesignData.InferenceSettingsViewModel}" d:DesignHeight="650" d:DesignWidth="900" @@ -102,6 +103,20 @@ + + + + + + + + + + + From 52bdafa066534a7cb426ecac09f213c28070774f Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 20 May 2024 21:54:58 -0700 Subject: [PATCH 095/239] add new visionary to shoutout --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca5a7364..5a196357c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed issue where the "installed" state on HuggingFace model browser was not always correct ### Supporters #### Visionaries -- Special thanks to our first Visionary on Patreon, **Scopp Mcdee**, for their generous support! +- Special shoutout to our first two Visionaries on Patreon! + - **Scopp Mcdee** + - **Waterclouds** +- Thank you for your generous support! ## v2.11.0-dev.2 ### Added From e44d74e4b71a16ed8d590812b7778510bc975a83 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 20 May 2024 21:55:41 -0700 Subject: [PATCH 096/239] oops helps if i save the file --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a196357c..358300f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed issue where the "installed" state on HuggingFace model browser was not always correct ### Supporters #### Visionaries -- Special shoutout to our first two Visionaries on Patreon! - - **Scopp Mcdee** - - **Waterclouds** -- Thank you for your generous support! +- Special shoutout to our first two Visionaries on Patreon, **Scopp Mcdee** and **Waterclouds**! Thank you for your generous support! ## v2.11.0-dev.2 ### Added From c11f407acbf4334bb414c9977eab8b67eda914dc Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 May 2024 01:24:00 -0400 Subject: [PATCH 097/239] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358300f69..3a9d454b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Added download speed indicator to model downloads in the Downloads tab - Added XL ControlNets section to HuggingFace model browser - Added toggle in Settings for model browser auto-search on load +- Added Recycle Bin support for deleting files, currently on Windows only ### Changed - Revamped Checkpoints page now shows available model updates and has better drag & drop functionality - Updated HuggingFace page so the command bar stays fixed at the top +- Revamped file deletion confirmation dialog with affected file paths display and recycle bin / permanent delete options ### Fixed - Fixed issue where the "installed" state on HuggingFace model browser was not always correct ### Supporters From e56e802103b940653853718e143e19facc67d47d Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 May 2024 01:27:22 -0400 Subject: [PATCH 098/239] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9d454b3..359a1869d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Added XL ControlNets section to HuggingFace model browser - Added toggle in Settings for model browser auto-search on load - Added Recycle Bin support for deleting files, currently on Windows only +- Added optional Recycle Bin mode when deleting images in the Inference image browser, can be disabled in settings ### Changed - Revamped Checkpoints page now shows available model updates and has better drag & drop functionality - Updated HuggingFace page so the command bar stays fixed at the top -- Revamped file deletion confirmation dialog with affected file paths display and recycle bin / permanent delete options +- Revamped file deletion confirmation dialog with affected file paths display and recycle bin / permanent delete options (Checkpoint and Output Browsers) ### Fixed - Fixed issue where the "installed" state on HuggingFace model browser was not always correct ### Supporters From eda216d515c9fbc1436a4817aa717b9d663d60a5 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 May 2024 01:44:11 -0400 Subject: [PATCH 099/239] Update CHANGELOG.md --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359a1869d..db7556b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,11 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Added download speed indicator to model downloads in the Downloads tab - Added XL ControlNets section to HuggingFace model browser - Added toggle in Settings for model browser auto-search on load -- Added Recycle Bin support for deleting files, currently on Windows only -- Added optional Recycle Bin mode when deleting images in the Inference image browser, can be disabled in settings +- Added optional Recycle Bin mode when deleting images in the Inference image browser, can be disabled in settings (Currently on Windows only) ### Changed - Revamped Checkpoints page now shows available model updates and has better drag & drop functionality - Updated HuggingFace page so the command bar stays fixed at the top -- Revamped file deletion confirmation dialog with affected file paths display and recycle bin / permanent delete options (Checkpoint and Output Browsers) +- Revamped file deletion confirmation dialog with affected file paths display and recycle bin / permanent delete options (Checkpoint and Output Browsers) (Currently on Windows only) ### Fixed - Fixed issue where the "installed" state on HuggingFace model browser was not always correct ### Supporters From a0b5c26d2dd8438fb74304852a720a9cad957aa8 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 21 May 2024 01:58:55 -0400 Subject: [PATCH 100/239] Add missing runtime identifiers --- .../StabilityMatrix.Native.Abstractions.csproj | 2 +- StabilityMatrix.Native/StabilityMatrix.Native.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj b/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj index 0bffe1a13..cf8f00e6a 100644 --- a/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj +++ b/StabilityMatrix.Native.Abstractions/StabilityMatrix.Native.Abstractions.csproj @@ -2,7 +2,7 @@ net8.0 - win-x64 + win-x64;linux-x64;osx-x64;osx-arm64 enable enable diff --git a/StabilityMatrix.Native/StabilityMatrix.Native.csproj b/StabilityMatrix.Native/StabilityMatrix.Native.csproj index dc6e01bf0..568d94c52 100644 --- a/StabilityMatrix.Native/StabilityMatrix.Native.csproj +++ b/StabilityMatrix.Native/StabilityMatrix.Native.csproj @@ -2,7 +2,7 @@ net8.0 - win-x64 + win-x64;linux-x64;osx-x64;osx-arm64 enable enable From 001ae45bfe4b16177c297c442aff3f7b18461e82 Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 25 May 2024 00:43:49 -0400 Subject: [PATCH 101/239] Add async overloads for NativeRecycleBinProvider --- .../INativeRecycleBinProvider.cs | 35 +++++++++++++++++++ .../NativeRecycleBinProvider.cs | 30 ++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs b/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs index 1cb90f241..c22ec6502 100644 --- a/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs +++ b/StabilityMatrix.Native.Abstractions/INativeRecycleBinProvider.cs @@ -9,6 +9,14 @@ public interface INativeRecycleBinProvider /// The flags to be used for the operation. void MoveFileToRecycleBin(string path, NativeFileOperationFlags flags = default); + /// + /// Asynchronously moves a file to the recycle bin. + /// + /// The path of the file to be moved. + /// The flags to be used for the operation. + /// A task representing the asynchronous operation. + Task MoveFileToRecycleBinAsync(string path, NativeFileOperationFlags flags = default); + /// /// Moves the specified files to the recycle bin. /// @@ -16,6 +24,14 @@ public interface INativeRecycleBinProvider /// The flags to be used for the operation. void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default); + /// + /// Asynchronously moves the specified files to the recycle bin. + /// + /// The paths of the files to be moved. + /// The flags to be used for the operation. + /// A task representing the asynchronous operation. + Task MoveFilesToRecycleBinAsync(IEnumerable paths, NativeFileOperationFlags flags = default); + /// /// Moves the specified directory to the recycle bin. /// @@ -23,10 +39,29 @@ public interface INativeRecycleBinProvider /// The flags to be used for the operation. void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flags = default); + /// + /// Moves a directory to the recycle bin asynchronously. + /// + /// The path of the directory to be moved. + /// The flags to be used for the operation. + /// A task representing the asynchronous operation. + Task MoveDirectoryToRecycleBinAsync(string path, NativeFileOperationFlags flags = default); + /// /// Moves the specified directories to the recycle bin. /// /// The paths of the directories to be moved. /// The flags to be used for the operation. void MoveDirectoriesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default); + + /// + /// Moves the specified directories to the recycle bin asynchronously. + /// + /// The paths of the directories to be moved. + /// The flags to be used for the operation. + /// A task representing the asynchronous operation. + Task MoveDirectoriesToRecycleBinAsync( + IEnumerable paths, + NativeFileOperationFlags flags = default + ); } diff --git a/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs b/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs index 038f1ff57..075b788de 100644 --- a/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs +++ b/StabilityMatrix.Native.Windows/NativeRecycleBinProvider.cs @@ -23,6 +23,12 @@ public void MoveFileToRecycleBin(string path, NativeFileOperationFlags flags = d fo.PerformOperations(); } + /// + public Task MoveFileToRecycleBinAsync(string path, NativeFileOperationFlags flags = default) + { + return Task.Run(() => MoveFileToRecycleBin(path, flags)); + } + /// public void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default) { @@ -38,6 +44,15 @@ public void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperation fo.PerformOperations(); } + /// + public Task MoveFilesToRecycleBinAsync( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + return Task.Run(() => MoveFilesToRecycleBin(paths, flags)); + } + /// public void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flags = default) { @@ -53,6 +68,12 @@ public void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flag fo.PerformOperations(); } + /// + public Task MoveDirectoryToRecycleBinAsync(string path, NativeFileOperationFlags flags = default) + { + return Task.Run(() => MoveDirectoryToRecycleBin(path, flags)); + } + /// public void MoveDirectoriesToRecycleBin( IEnumerable paths, @@ -70,4 +91,13 @@ public void MoveDirectoriesToRecycleBin( fo.DeleteItems(paths.ToArray()); fo.PerformOperations(); } + + /// + public Task MoveDirectoriesToRecycleBinAsync( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + return Task.Run(() => MoveDirectoriesToRecycleBin(paths, flags)); + } } From aa1bdb208e8fb51831e3d5e1b98fadc66b5bb15b Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 25 May 2024 00:50:49 -0400 Subject: [PATCH 102/239] Add macos Native assembly and NativeRecycleBinProvider --- StabilityMatrix.Native.macOS/AssemblyInfo.cs | 4 + .../NativeRecycleBinProvider.cs | 104 ++++++++++++++++++ .../StabilityMatrix.Native.macOS.csproj | 14 +++ StabilityMatrix.sln | 6 + 4 files changed, 128 insertions(+) create mode 100644 StabilityMatrix.Native.macOS/AssemblyInfo.cs create mode 100644 StabilityMatrix.Native.macOS/NativeRecycleBinProvider.cs create mode 100644 StabilityMatrix.Native.macOS/StabilityMatrix.Native.macOS.csproj diff --git a/StabilityMatrix.Native.macOS/AssemblyInfo.cs b/StabilityMatrix.Native.macOS/AssemblyInfo.cs new file mode 100644 index 000000000..926a6bb3c --- /dev/null +++ b/StabilityMatrix.Native.macOS/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("macos")] +[assembly: SupportedOSPlatform("osx")] diff --git a/StabilityMatrix.Native.macOS/NativeRecycleBinProvider.cs b/StabilityMatrix.Native.macOS/NativeRecycleBinProvider.cs new file mode 100644 index 000000000..9be82fb73 --- /dev/null +++ b/StabilityMatrix.Native.macOS/NativeRecycleBinProvider.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using StabilityMatrix.Native.Abstractions; + +namespace StabilityMatrix.Native.macOS; + +[PublicAPI] +public class NativeRecycleBinProvider : INativeRecycleBinProvider +{ + /// + public void MoveFileToRecycleBin(string path, NativeFileOperationFlags flags = default) + { + MoveFileToRecycleBinAsync(path, flags).GetAwaiter().GetResult(); + } + + /// + public async Task MoveFileToRecycleBinAsync(string path, NativeFileOperationFlags flags = default) + { + await RunAppleScriptAsync($"tell application \"Finder\" to delete POSIX file \"{path}\""); + } + + /// + public void MoveFilesToRecycleBin(IEnumerable paths, NativeFileOperationFlags flags = default) + { + MoveFilesToRecycleBinAsync(paths, flags).GetAwaiter().GetResult(); + } + + /// + public async Task MoveFilesToRecycleBinAsync( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + var pathsArrayString = string.Join(", ", paths.Select(p => $"POSIX file \"{p}\"")); + + await RunAppleScriptAsync($"tell application \"Finder\" to delete {{{pathsArrayString}}}"); + } + + /// + public void MoveDirectoryToRecycleBin(string path, NativeFileOperationFlags flags = default) + { + MoveDirectoryToRecycleBinAsync(path, flags).GetAwaiter().GetResult(); + } + + /// + public async Task MoveDirectoryToRecycleBinAsync(string path, NativeFileOperationFlags flags = default) + { + await RunAppleScriptAsync($"tell application \"Finder\" to delete folder POSIX file \"{path}\""); + } + + /// + public void MoveDirectoriesToRecycleBin( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + MoveDirectoriesToRecycleBinAsync(paths, flags).GetAwaiter().GetResult(); + } + + /// + public async Task MoveDirectoriesToRecycleBinAsync( + IEnumerable paths, + NativeFileOperationFlags flags = default + ) + { + var pathsArrayString = string.Join(", ", paths.Select(p => $"folder POSIX file \"{p}\"")); + + await RunAppleScriptAsync($"tell application \"Finder\" to delete {{{pathsArrayString}}}"); + } + + /// + /// Runs an AppleScript script. + /// + private static async Task RunAppleScriptAsync( + string script, + CancellationToken cancellationToken = default + ) + { + using var process = new Process(); + + process.StartInfo = new ProcessStartInfo + { + FileName = "/usr/bin/osascript", + Arguments = $"-e '{script}'", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + var stdOut = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var stdErr = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException( + $"The AppleScript script failed with exit code {process.ExitCode}: (StdOut = {stdOut}, StdErr = {stdErr})" + ); + } + } +} diff --git a/StabilityMatrix.Native.macOS/StabilityMatrix.Native.macOS.csproj b/StabilityMatrix.Native.macOS/StabilityMatrix.Native.macOS.csproj new file mode 100644 index 000000000..9de9d96b5 --- /dev/null +++ b/StabilityMatrix.Native.macOS/StabilityMatrix.Native.macOS.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + osx-x64;osx-arm64 + enable + enable + + + + + + + diff --git a/StabilityMatrix.sln b/StabilityMatrix.sln index a1ab41aea..08e7a5601 100644 --- a/StabilityMatrix.sln +++ b/StabilityMatrix.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native.Wind EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native.Abstractions", "StabilityMatrix.Native.Abstractions\StabilityMatrix.Native.Abstractions.csproj", "{C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StabilityMatrix.Native.macOS", "StabilityMatrix.Native.macOS\StabilityMatrix.Native.macOS.csproj", "{473AE646-17E4-4247-9271-858D257DFFE1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {C521F1CF-7A5E-4016-AAD0-427FEDC53FB8}.Release|Any CPU.Build.0 = Release|Any CPU + {473AE646-17E4-4247-9271-858D257DFFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {473AE646-17E4-4247-9271-858D257DFFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {473AE646-17E4-4247-9271-858D257DFFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {473AE646-17E4-4247-9271-858D257DFFE1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a4a606bfd74855eb88bb990eb95c8280d592a2bc Mon Sep 17 00:00:00 2001 From: Ionite Date: Sat, 25 May 2024 00:52:45 -0400 Subject: [PATCH 103/239] Allow recycle bin tests on macos --- .../Native/NativeRecycleBinProviderTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs index 0e5c29374..14e007ef3 100644 --- a/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs +++ b/StabilityMatrix.Tests/Native/NativeRecycleBinProviderTests.cs @@ -12,11 +12,16 @@ public class NativeRecycleBinProviderTests [TestInitialize] public void Initialize() { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if ( + !( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ) + ) { Assert.IsFalse(NativeFileOperations.IsRecycleBinAvailable); Assert.IsNull(NativeFileOperations.RecycleBin); - Assert.Inconclusive("Recycle bin is only available on Windows."); + Assert.Inconclusive("Recycle bin is only available on Windows and macOS."); return; } From 9fffc21bd6b3203475ed956fa0dd0e63acbb542c Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 28 May 2024 18:17:37 -0400 Subject: [PATCH 104/239] Create SkiaExtensions.cs --- .../Extensions/SkiaExtensions.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Extensions/SkiaExtensions.cs diff --git a/StabilityMatrix.Avalonia/Extensions/SkiaExtensions.cs b/StabilityMatrix.Avalonia/Extensions/SkiaExtensions.cs new file mode 100644 index 000000000..ae0330311 --- /dev/null +++ b/StabilityMatrix.Avalonia/Extensions/SkiaExtensions.cs @@ -0,0 +1,97 @@ +using System; +using Avalonia; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using SkiaSharp; + +namespace StabilityMatrix.Avalonia.Extensions; + +public static class SkiaExtensions +{ + private record class SKBitmapDrawOperation : ICustomDrawOperation + { + public Rect Bounds { get; set; } + + public SKBitmap? Bitmap { get; init; } + + public void Dispose() + { + //nop + } + + public bool Equals(ICustomDrawOperation? other) => false; + + public bool HitTest(Point p) => Bounds.Contains(p); + + public void Render(ImmediateDrawingContext context) + { + if ( + Bitmap is { } bitmap + && context.PlatformImpl.GetFeature() is { } leaseFeature + ) + { + var lease = leaseFeature.Lease(); + using (lease) + { + lease.SkCanvas.DrawBitmap( + bitmap, + SKRect.Create( + (float)Bounds.X, + (float)Bounds.Y, + (float)Bounds.Width, + (float)Bounds.Height + ) + ); + } + } + } + } + + private class AvaloniaImage : IImage, IDisposable + { + private readonly SKBitmap? _source; + SKBitmapDrawOperation? _drawImageOperation; + + public AvaloniaImage(SKBitmap? source) + { + _source = source; + if (source?.Info.Size is { } size) + { + Size = new Size(size.Width, size.Height); + } + } + + public Size Size { get; } + + public void Dispose() => _source?.Dispose(); + + public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) + { + if (_drawImageOperation is null) + { + _drawImageOperation = new SKBitmapDrawOperation { Bitmap = _source, }; + } + + _drawImageOperation.Bounds = sourceRect; + context.Custom(_drawImageOperation); + } + } + + public static SKBitmap? ToSKBitmap(this System.IO.Stream? stream) + { + if (stream == null) + return null; + return SKBitmap.Decode(stream); + } + + public static IImage? ToAvaloniaImage(this SKBitmap? bitmap) + { + if (bitmap is not null) + { + return new AvaloniaImage(bitmap); + } + return default; + } +} From 8ed3fb26b4b928fa5c2edf690e3ee300b975b929 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 28 May 2024 18:17:50 -0400 Subject: [PATCH 105/239] Create BitmapExtensions.cs --- .../Extensions/BitmapExtensions.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Extensions/BitmapExtensions.cs diff --git a/StabilityMatrix.Avalonia/Extensions/BitmapExtensions.cs b/StabilityMatrix.Avalonia/Extensions/BitmapExtensions.cs new file mode 100644 index 000000000..0aebcbc74 --- /dev/null +++ b/StabilityMatrix.Avalonia/Extensions/BitmapExtensions.cs @@ -0,0 +1,31 @@ +using Avalonia; +using Avalonia.Media.Imaging; +using SkiaSharp; + +namespace StabilityMatrix.Avalonia.Extensions; + +public static class BitmapExtensions +{ + /// + /// Converts an Avalonia to a SkiaSharp . + /// + /// The Avalonia bitmap to convert. + /// The SkiaSharp bitmap. + public static SKBitmap ToSKBitmap(this Bitmap bitmap) + { + var skBitmap = new SKBitmap( + bitmap.PixelSize.Width, + bitmap.PixelSize.Height, + SKColorType.Bgra8888, + SKAlphaType.Premul + ); + + var stride = skBitmap.RowBytes; + var bufferSize = stride * skBitmap.Height; + var sourceRect = new PixelRect(0, 0, bitmap.PixelSize.Width, bitmap.PixelSize.Height); + + bitmap.CopyPixels(sourceRect, skBitmap.GetPixels(), bufferSize, stride); + + return skBitmap; + } +} From c0d4920b1dd87609dd865c2ed4faad2dfbebb7bf Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 28 May 2024 18:18:14 -0400 Subject: [PATCH 106/239] Add localized strings --- StabilityMatrix.Avalonia/Languages/Resources.Designer.cs | 9 +++++++++ StabilityMatrix.Avalonia/Languages/Resources.resx | 3 +++ 2 files changed, 12 insertions(+) diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 4eb4e3776..f42a60e57 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -230,6 +230,15 @@ public static string Action_Edit { } } + /// + /// Looks up a localized string similar to Edit Clipping Mask. + /// + public static string Action_EditClippingMask { + get { + return ResourceManager.GetString("Action_EditClippingMask", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exit Application. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 5dfe69e4b..2e841d603 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1101,4 +1101,7 @@ Automatically initiate a search when the model browser page is loaded + + Edit Clipping Mask + From 1176baa1f0426e4e2b7796ef68ffed7fe3a0f592 Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 28 May 2024 16:18:30 -0700 Subject: [PATCH 107/239] - Added "Show Nested Models" toggle for new Checkpoints page, allowing users to show or hide models in subfolders of the selected folder - Added ZLUDA option for SD.Next - Added PixArt & SDXL Hyper options to the Civitai model browser - Maximized state is now stored on exit and restored on launch - Fixed package installs not showing any progress messages --- StabilityMatrix.Avalonia/App.axaml.cs | 1 + .../ViewModels/NewCheckpointsPageViewModel.cs | 39 ++++++++++++++----- .../Views/Dialogs/OneClickInstallDialog.axaml | 22 +++++++++++ .../Views/MainWindow.axaml.cs | 11 +++++- .../Views/NewCheckpointsPage.axaml | 28 +++++++++---- .../PackageInstallBrowserView.axaml | 22 +++++++++++ .../Models/Api/CivitBaseModelType.cs | 9 +++++ StabilityMatrix.Core/Models/Api/CivitModel.cs | 11 +++--- .../PackageModification/InstallPackageStep.cs | 31 ++++----------- .../Models/Packages/A3WebUI.cs | 9 ----- .../Models/Packages/VladAutomatic.cs | 21 +++++++++- .../Models/Settings/Settings.cs | 1 + .../Models/Settings/WindowSettings.cs | 2 +- StabilityMatrix.Core/Models/TorchVersion.cs | 1 + 14 files changed, 150 insertions(+), 58 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 29fc53dec..87dd0aebb 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -269,6 +269,7 @@ private void ShowMainWindow() mainWindow.Position = new PixelPoint(windowSettings.X, windowSettings.Y); mainWindow.Width = windowSettings.Width; mainWindow.Height = windowSettings.Height; + mainWindow.WindowState = windowSettings.IsMaximized ? WindowState.Maximized : WindowState.Normal; } else { diff --git a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs index b18a9b981..11d2d846e 100644 --- a/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/NewCheckpointsPageViewModel.cs @@ -73,6 +73,9 @@ ServiceManager dialogFactory [ObservableProperty] private bool showFolders = true; + [ObservableProperty] + private bool showModelsInSubfolders = true; + [ObservableProperty] private CheckpointCategory? selectedCategory; @@ -148,7 +151,11 @@ protected override void OnInitialLoaded() ) .AsObservable(); - var filterPredicate = this.WhenPropertyChanged(vm => vm.SelectedCategory) + var filterPredicate = Observable + .FromEventPattern(this, nameof(PropertyChanged)) + .Where( + x => x.EventArgs.PropertyName is nameof(SelectedCategory) or nameof(ShowModelsInSubfolders) + ) .Throttle(TimeSpan.FromMilliseconds(50)) .Select( _ => @@ -157,14 +164,21 @@ protected override void OnInitialLoaded() ? (Func)(_ => true) : (Func)( file => - Path.GetDirectoryName(file.RelativePath) - ?.Contains( - SelectedCategory - ?.Path - .Replace(settingsManager.ModelsDirectory, string.Empty) - .TrimStart(Path.DirectorySeparatorChar) - ) - is true + ShowModelsInSubfolders + ? Path.GetDirectoryName(file.RelativePath) + ?.Contains( + SelectedCategory + ?.Path + .Replace(settingsManager.ModelsDirectory, string.Empty) + .TrimStart(Path.DirectorySeparatorChar) + ) + is true + : SelectedCategory + ?.Path + .Replace(settingsManager.ModelsDirectory, string.Empty) + .TrimStart(Path.DirectorySeparatorChar) + .Equals(Path.GetDirectoryName(file.RelativePath)) + is true ) ) .AsObservable(); @@ -300,6 +314,13 @@ or nameof(SortConnectedModelsFirst) true ); + settingsManager.RelayPropertyFor( + this, + vm => vm.ShowModelsInSubfolders, + settings => settings.ShowModelsInSubfolders, + true + ); + // make sure a sort happens OnPropertyChanged(nameof(SortConnectedModelsFirst)); } diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/OneClickInstallDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/OneClickInstallDialog.axaml index 8df43ca83..ae31079d4 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/OneClickInstallDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/OneClickInstallDialog.axaml @@ -161,6 +161,28 @@ + + + + + + + + + + @@ -38,54 +40,72 @@ --> - + + + + + + + + + + + + + + + + + - - + + Background="Transparent" /> diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs index d9a155eae..ffaba6cc0 100644 --- a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml.cs @@ -45,23 +45,14 @@ static PaintCanvas() AffectsRender(BoundsProperty); } - public void SaveCanvasToRasterWebp(Stream stream) + public SKImage GetCanvasSnapshot() { using var surface = SKSurface.Create(new SKImageInfo((int)Bounds.Width, (int)Bounds.Height)); using var canvas = surface.Canvas; RenderCanvasCore(canvas); - using var image = surface.Snapshot(); - using var data = image.Encode(SKEncodedImageFormat.Webp, 100); - data.SaveTo(stream); - } - - public void LoadCanvasFromRasterWebp(Stream stream) - { - ViewModel?.LayerImages.Add(SKBitmap.Decode(stream)); - - Dispatcher.UIThread.Post(() => MainCanvas?.InvalidateVisual(), DispatcherPriority.Render); + return surface.Snapshot(); } public void RefreshCanvas() @@ -104,6 +95,13 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) UpdateCanvasCursor(); } }; + + if (ViewModel is not null) + { + ViewModel.CurrentZoom = zoomBorder.ZoomX; + + UpdateCanvasCursor(); + } } OnDataContextChanged(EventArgs.Empty); @@ -117,8 +115,7 @@ protected override void OnDataContextChanged(EventArgs e) if (DataContext is PaintCanvasViewModel viewModel) { // Set the remote actions - viewModel.SaveCanvasToImage = SaveCanvasToRasterWebp; - viewModel.LoadCanvasFromImage = LoadCanvasFromRasterWebp; + viewModel.GetCanvasSnapshot = GetCanvasSnapshot; viewModel.RefreshCanvas = RefreshCanvas; viewModelSubscription?.Dispose(); @@ -183,13 +180,10 @@ private void HandlePointerEvent(PointerEventArgs e) return; } - // if (e.Pointer.Type != PointerType.Pen || lastPointer.Properties.Pressure > 0) - e.Handled = true; // Must have this or stylus inputs lost after a while // https://github.com/AvaloniaUI/Avalonia/issues/12289#issuecomment-1695620412 - e.PreventGestureRecognition(); if (DataContext is not PaintCanvasViewModel viewModel) @@ -209,21 +203,14 @@ private void HandlePointerEvent(PointerEventArgs e) isPenDown = true; - var cursorPosition = e.GetPosition(MainCanvas); - - // Start a new path - var path = new SKPath(); - path.MoveTo(cursorPosition.ToSKPoint()); - - TemporaryPaths[e.Pointer.Id] = new PenPath(path) - { - FillColor = viewModel.PaintBrushSKColor.WithAlpha((byte)(viewModel.PaintBrushAlpha * 255)) - }; + HandlePointerMoved(e); } else if (e.RoutedEvent == PointerReleasedEvent) { if (isPenDown) { + HandlePointerMoved(e); + isPenDown = false; } @@ -242,39 +229,54 @@ private void HandlePointerEvent(PointerEventArgs e) return; } - // Use intermediate points to include past events we missed - var points = e.GetIntermediatePoints(MainCanvas); + HandlePointerMoved(e); + } - viewModel.CurrentPenPressure = points.FirstOrDefault().Properties.Pressure; + Dispatcher.UIThread.Post(() => MainCanvas?.InvalidateVisual(), DispatcherPriority.Render); + } + + private void HandlePointerMoved(PointerEventArgs e) + { + if (DataContext is not PaintCanvasViewModel viewModel) + { + return; + } - // Get existing temp path - if (TemporaryPaths.TryGetValue(e.Pointer.Id, out var penPath)) + // Use intermediate points to include past events we missed + var points = e.GetIntermediatePoints(MainCanvas); + + viewModel.CurrentPenPressure = points.FirstOrDefault().Properties.Pressure; + + // Get or create a temp path + if (!TemporaryPaths.TryGetValue(e.Pointer.Id, out var penPath)) + { + penPath = new PenPath(new SKPath()) { - var cursorPosition = e.GetPosition(MainCanvas); + FillColor = viewModel.PaintBrushSKColor.WithAlpha((byte)(viewModel.PaintBrushAlpha * 255)) + }; + TemporaryPaths[e.Pointer.Id] = penPath; + } - // Add line for path - penPath.Path.LineTo(cursorPosition.ToSKPoint()); + // Add line for path + // var cursorPosition = e.GetPosition(MainCanvas); + // penPath.Path.LineTo(cursorPosition.ToSKPoint()); - // Add points - foreach (var point in points) - { - var skCanvasPoint = point.Position.ToSKPoint(); + // Add points + foreach (var point in points) + { + var skCanvasPoint = point.Position.ToSKPoint(); - // penPath.Path.LineTo(skCanvasPoint); + penPath.Path.LineTo(skCanvasPoint); - var penPoint = new PenPoint(skCanvasPoint) - { - Pressure = point.Pointer.Type != PointerType.Mouse ? 1 : point.Properties.Pressure, - Radius = viewModel.PaintBrushSize, - IsPen = point.Pointer.Type == PointerType.Pen - }; + var penPoint = new PenPoint(skCanvasPoint) + { + Pressure = point.Pointer.Type == PointerType.Mouse ? null : point.Properties.Pressure, + Radius = viewModel.PaintBrushSize, + IsPen = point.Pointer.Type == PointerType.Pen + }; - penPath.Points.Add(penPoint); - } - } + penPath.Points.Add(penPoint); } - - Dispatcher.UIThread.Post(() => MainCanvas?.InvalidateVisual(), DispatcherPriority.Render); } /// @@ -347,31 +349,40 @@ private void UpdateCanvasCursor() var currentZoom = ViewModel?.CurrentZoom ?? 1; // Get brush size - var currentBrushSize = Math.Max(ViewModel?.PaintBrushSize ?? 1, 1); - var brushPixels = (int)Math.Ceiling(currentBrushSize * 2 * currentZoom); - var brushCanvasPixels = brushPixels * 2; + var currentBrushSize = Math.Max((ViewModel?.PaintBrushSize ?? 1) - 1, 1); + var brushRadius = (int)Math.Ceiling(currentBrushSize * 2 * currentZoom); // Only update cursor if brush size has changed - if (brushCanvasPixels == lastCanvasCursorRadius) + if (brushRadius == lastCanvasCursorRadius) { canvas.Cursor = lastCanvasCursor; return; } - lastCanvasCursorRadius = brushCanvasPixels; + lastCanvasCursorRadius = brushRadius; + + var brushDiameter = brushRadius * 2; + + const int padding = 4; + + var canvasCenter = brushRadius + padding; + var canvasSize = brushDiameter + padding * 2; + + using var cursorBitmap = new SKBitmap(canvasSize, canvasSize); - using var cursorBitmap = new SKBitmap(brushCanvasPixels, brushCanvasPixels); using var cursorCanvas = new SKCanvas(cursorBitmap); cursorCanvas.Clear(SKColors.Transparent); cursorCanvas.DrawCircle( - brushPixels, - brushPixels, - brushPixels, + brushRadius + padding, + brushRadius + padding, + brushRadius, new SKPaint { Color = SKColors.Black, Style = SKPaintStyle.Stroke, - StrokeWidth = 1, + StrokeWidth = 1.5f, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round, IsDither = true, IsAntialias = true } @@ -383,7 +394,7 @@ private void UpdateCanvasCursor() var bitmap = WriteableBitmap.Decode(stream); - canvas.Cursor = new Cursor(bitmap, new PixelPoint(brushCanvasPixels / 2, brushCanvasPixels / 2)); + canvas.Cursor = new Cursor(bitmap, new PixelPoint(canvasCenter, canvasCenter)); lastCanvasCursor?.Dispose(); lastCanvasCursor = canvas.Cursor; @@ -431,6 +442,13 @@ private static void RenderPenPath(SKCanvas canvas, PenPath penPath, SKPaint pain // Apply Color paint.Color = penPath.FillColor; + + if (penPath.IsErase) + { + paint.BlendMode = SKBlendMode.SrcIn; + paint.Color = SKColors.Transparent; + } + // Defaults paint.IsDither = true; paint.IsAntialias = true; @@ -445,40 +463,42 @@ private static void RenderPenPath(SKCanvas canvas, PenPath penPath, SKPaint pain var penPoint = penPath.Points[i]; // Skip non-pen points - if (!penPoint.IsPen) + /*if (!penPoint.IsPen) { continue; - } + }*/ hasPenPoints = true; var radius = penPoint.Radius; var pressure = penPoint.Pressure ?? 1; - var thickness = pressure * radius * 1.5; - // var radius = pressure * penPoint.Radius * 1.5; + var thickness = pressure * radius * 2.5; // Draw path - if (i < penPath.Points.Count - 1) + /*if (i < penPath.Points.Count - 1) { - paint.Style = SKPaintStyle.Fill; + paint.Style = SKPaintStyle.Stroke; paint.StrokeWidth = (float)thickness; + paint.StrokeCap = SKStrokeCap.Round; + paint.StrokeJoin = SKStrokeJoin.Round; canvas.DrawLine(penPoint.Point, penPath.Points[i + 1].Point, paint); - } + }*/ // Draw circles for pens paint.Style = SKPaintStyle.Fill; - canvas.DrawCircle(penPoint.Point, (float)thickness, paint); + canvas.DrawCircle(penPoint.Point, (float)thickness / 2, paint); } // Draw paths directly if we didn't have any pen points if (!hasPenPoints) { var point = penPath.Points[0]; - var thickness = point.Radius * 1.5; + var thickness = point.Radius * 2; paint.Style = SKPaintStyle.Stroke; paint.StrokeWidth = (float)thickness; paint.StrokeCap = SKStrokeCap.Round; + paint.StrokeJoin = SKStrokeJoin.Round; canvas.DrawPath(penPath.Path, paint); } diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index f50ae2e78..89480b428 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -21,6 +21,7 @@ using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; +using StabilityMatrix.Avalonia.ViewModels.Controls; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Inference; using StabilityMatrix.Avalonia.ViewModels.Inference.Video; @@ -1088,6 +1089,8 @@ public static CompletionList SampleCompletionList ); }); + public static PaintCanvasViewModel PaintCanvasViewModel => DialogFactory.Get(); + public static ImageSource SampleImageSource => new( new Uri( diff --git a/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs index 9dd6c198b..25471350f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Controls/PaintCanvasViewModel.cs @@ -2,30 +2,32 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; -using System.IO; using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Avalonia.Media; using Avalonia.Skia; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using SkiaSharp; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls.Models; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Attributes; +#pragma warning disable CS0657 // Not a valid attribute location for this declaration namespace StabilityMatrix.Avalonia.ViewModels.Controls; -public partial class PaintCanvasViewModel : ObservableObject +[Transient] +[ManagedService] +public partial class PaintCanvasViewModel : LoadableViewModelBase { public ConcurrentDictionary TemporaryPaths { get; } = new(); [ObservableProperty] - [NotifyPropertyChangedFor(nameof(CanUndo))] [NotifyCanExecuteChangedFor(nameof(UndoCommand))] private ImmutableList paths = []; - public bool CanUndo => Paths.Count > 0; - [ObservableProperty] private Color? paintBrushColor; @@ -53,17 +55,63 @@ public partial class PaintCanvasViewModel : ObservableObject private bool isEraserSelected; [ObservableProperty] + [property: JsonIgnore] private SKBitmap? backgroundImage; + [JsonIgnore] public List LayerImages { get; } = []; - public Action? LoadCanvasFromImage { get; set; } + /// + /// Set by to allow the view model to take a snapshot of the canvas. + /// + [JsonIgnore] + public Func? GetCanvasSnapshot { get; set; } + + /// + /// Set by to allow the view model to + /// refresh the canvas view after updating points or bitmap layers. + /// + [JsonIgnore] + public Action? RefreshCanvas { get; set; } - public Action? SaveCanvasToImage { get; set; } + public void LoadCanvasFromBitmap(SKBitmap bitmap) + { + LayerImages.Clear(); + LayerImages.Add(bitmap); - public Action? RefreshCanvas { get; set; } + RefreshCanvas?.Invoke(); + } - public async Task SaveCanvasToJson(Stream stream) + private bool CanExecuteUndo() + { + return Paths.Count > 0; + } + + [RelayCommand(CanExecute = nameof(CanExecuteUndo))] + public void Undo() + { + // Remove last path + var currentPaths = Paths; + + if (currentPaths.IsEmpty) + { + return; + } + + Paths = currentPaths.RemoveAt(currentPaths.Count - 1); + + RefreshCanvas?.Invoke(); + } + + /// + public override void LoadStateFromJsonObject(JsonObject state) + { + base.LoadStateFromJsonObject(state); + + RefreshCanvas?.Invoke(); + } + + protected PaintCanvasModel SaveCanvas() { var model = new PaintCanvasModel { @@ -74,15 +122,13 @@ public async Task SaveCanvasToJson(Stream stream) PaintBrushAlpha = PaintBrushAlpha }; - await JsonSerializer.SerializeAsync(stream, model); + return model; } - public async Task LoadCanvasFromJson(Stream stream) + protected void LoadCanvas(PaintCanvasModel model) { - var model = await JsonSerializer.DeserializeAsync(stream); - TemporaryPaths.Clear(); - foreach (var (key, value) in model!.TemporaryPaths) + foreach (var (key, value) in model.TemporaryPaths) { TemporaryPaths.TryAdd(key, value); } @@ -95,32 +141,16 @@ public async Task LoadCanvasFromJson(Stream stream) RefreshCanvas?.Invoke(); } - [RelayCommand(CanExecute = nameof(CanUndo))] - public void Undo() - { - // Remove last path - var currentPaths = Paths; - - if (currentPaths.IsEmpty) - { - return; - } - - Paths = currentPaths.RemoveAt(currentPaths.Count - 1); - - RefreshCanvas?.Invoke(); - } - - public class PaintCanvasModel + protected class PaintCanvasModel { - public Dictionary TemporaryPaths { get; set; } = new(); + public Dictionary TemporaryPaths { get; init; } = new(); - public ImmutableList Paths { get; set; } = ImmutableList.Empty; + public ImmutableList Paths { get; init; } = ImmutableList.Empty; - public Color? PaintBrushColor { get; set; } + public Color? PaintBrushColor { get; init; } - public double PaintBrushSize { get; set; } + public double PaintBrushSize { get; init; } - public double PaintBrushAlpha { get; set; } + public double PaintBrushAlpha { get; init; } } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/MaskEditorViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/MaskEditorViewModel.cs index aa9f07008..3f6785894 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/MaskEditorViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/MaskEditorViewModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Nodes; +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.Primitives; @@ -8,7 +11,6 @@ using SkiaSharp; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Controls; using StabilityMatrix.Avalonia.Views.Dialogs; @@ -22,6 +24,9 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; [View(typeof(MaskEditorDialog))] public partial class MaskEditorViewModel : ContentDialogViewModelBase { + /// + /// When true, the mask will be applied to the image. + /// [ObservableProperty] private bool isMaskEnabled = true; @@ -39,11 +44,19 @@ public SKBitmap? BackgroundImage set => PaintCanvasViewModel.BackgroundImage = value; } + public static FilePickerFileType MaskImageFilePickerType { get; } = + new("Mask image or json") + { + Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.json" }, + AppleUniformTypeIdentifiers = new[] { "public.image", "public.json" }, + MimeTypes = new[] { "image/*", "application/json" } + }; + [RelayCommand] - private async Task LoadMaskFromFile() + private async Task DebugSelectFileLoadMask() { var files = await App.StorageProvider.OpenFilePickerAsync( - new FilePickerOpenOptions { Title = "Select a mask image", } + new FilePickerOpenOptions { Title = "Select a mask", FileTypeFilter = [MaskImageFilePickerType] } ); if (files.Count == 0) @@ -51,23 +64,31 @@ private async Task LoadMaskFromFile() return; } - await using var stream = await files[0].OpenReadAsync(); + var file = files[0]; + await using var stream = await file.OpenReadAsync(); - if (PaintCanvasViewModel.LoadCanvasFromImage is { } loadCanvasFromImage) + if (file.Name.EndsWith(".json")) + { + var json = await JsonSerializer.DeserializeAsync(stream); + PaintCanvasViewModel.LoadStateFromJsonObject(json!); + } + else { - await Task.Run(() => loadCanvasFromImage(stream)); + var bitmap = SKBitmap.Decode(stream); + PaintCanvasViewModel.LoadCanvasFromBitmap(bitmap); } } [RelayCommand] - private async Task SaveMaskToFile() + private async Task DebugSelectFileSaveMask() { var file = await App.StorageProvider.SaveFilePickerAsync( new FilePickerSaveOptions { Title = "Save mask image", - DefaultExtension = ".webp", - SuggestedFileName = "mask.webp", + DefaultExtension = ".json", + FileTypeChoices = [MaskImageFilePickerType], + SuggestedFileName = "mask.json", } ); @@ -78,15 +99,30 @@ private async Task SaveMaskToFile() await using var stream = await file.OpenWriteAsync(); - if (PaintCanvasViewModel.SaveCanvasToImage is { } saveCanvasToImage) + if (file.Name.EndsWith(".json")) + { + var json = PaintCanvasViewModel.SaveStateToJsonObject(); + await JsonSerializer.SerializeAsync(stream, json); + } + else { - await Task.Run(() => saveCanvasToImage(stream)); + var image = PaintCanvasViewModel.GetCanvasSnapshot?.Invoke(); + await image! + .Encode( + Path.GetExtension(file.Name.ToLowerInvariant()) switch + { + ".png" => SKEncodedImageFormat.Png, + ".jpg" or ".jpeg" => SKEncodedImageFormat.Jpeg, + ".webp" => SKEncodedImageFormat.Webp, + _ => throw new NotSupportedException("Unsupported image format") + }, + 100 + ) + .AsStream() + .CopyToAsync(stream); } } - [RelayCommand] - private async Task ReplaceMaskWithImage() { } - /// public override BetterContentDialog GetDialog() { diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/MaskEditorDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/MaskEditorDialog.axaml index a78b16e93..45e04a034 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/MaskEditorDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/MaskEditorDialog.axaml @@ -22,14 +22,14 @@ - - + + + + + + + + + + + + + + + + + + vmFactory +) : LoadableViewModelBase, IDropTarget, IComfyStep, IInputImageProvider { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + /// + /// When true, enables a button to open a mask editor for the image. + /// + [ObservableProperty] + private bool isMaskEditorEnabled; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSelectionAvailable))] [NotifyPropertyChangedFor(nameof(IsImageFileNotFound))] @@ -66,6 +73,8 @@ public partial class SelectImageCardViewModel(INotificationService notificationS /// public string? NotFoundImagePath => ImageSource?.LocalFile?.FullPath; + public MaskEditorViewModel? MaskEditorViewModel { get; private set; } + /// public void ApplyStep(ModuleApplyStepEventArgs e) { @@ -105,6 +114,17 @@ partial void OnImageSourceChanged(ImageSource? value) } } + /// + /// When IsMaskEditorEnabled is set to true, initializes the MaskEditorViewModel. + /// + partial void OnIsMaskEditorEnabledChanged(bool value) + { + if (value) + { + MaskEditorViewModel ??= vmFactory.Get(); + } + } + private static FilePickerFileType SupportedImages { get; } = new("Supported Images") { @@ -129,6 +149,27 @@ private async Task SelectImageFromFilePickerAsync() } } + [RelayCommand] + private async Task OpenEditMaskDialogAsync() + { + if (ImageSource is null) + { + return; + } + + MaskEditorViewModel ??= vmFactory.Get(); + + if (await ImageSource.GetBitmapAsync() is not { } currentBitmap) + { + Logger.Warn("GetBitmapAsync returned null for image {Path}", ImageSource.LocalFile?.FullPath); + return; + } + + MaskEditorViewModel.BackgroundImage = currentBitmap.ToSKBitmap(); + + await MaskEditorViewModel.GetDialog().ShowAsync(); + } + /// /// Supports LocalImageFile Context or OS Files /// From 936017658bb78cad2477b9515bba598bde412c21 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 28 May 2024 21:46:07 -0400 Subject: [PATCH 110/239] Improved paint canvas serializing --- .../Controls/Models/PenPath.cs | 26 ++++++- .../Controls/Models/PenPoint.cs | 13 +++- .../Controls/Painting/PaintCanvas.axaml | 10 ++- .../Controls/Painting/PaintCanvas.axaml.cs | 40 +++++++---- .../Models/PaintCanvasTool.cs | 8 +++ .../Controls/PaintCanvasViewModel.cs | 53 +++++++++++---- .../ViewModels/Dialogs/MaskEditorViewModel.cs | 67 +++++++++++++++---- .../Inference/SelectImageCardViewModel.cs | 3 +- .../Converters/Json/SKColorJsonConverter.cs | 26 +++++++ 9 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Models/PaintCanvasTool.cs create mode 100644 StabilityMatrix.Core/Converters/Json/SKColorJsonConverter.cs diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs index 394ecf27c..ba830c7f9 100644 --- a/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs +++ b/StabilityMatrix.Avalonia/Controls/Models/PenPath.cs @@ -1,13 +1,37 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; using SkiaSharp; +using StabilityMatrix.Core.Converters.Json; namespace StabilityMatrix.Avalonia.Controls.Models; -public readonly record struct PenPath(SKPath Path) +public readonly record struct PenPath() { + [JsonConverter(typeof(SKColorJsonConverter))] public SKColor FillColor { get; init; } public bool IsErase { get; init; } public List Points { get; init; } = []; + + public SKPath ToSKPath() + { + var skPath = new SKPath(); + + if (Points.Count <= 0) + { + return skPath; + } + + // First move to the first point + skPath.MoveTo(Points[0].X, Points[0].Y); + + // Add the rest of the points + for (var i = 1; i < Points.Count; i++) + { + skPath.LineTo(Points[i].X, Points[i].Y); + } + + return skPath; + } } diff --git a/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs index c20983dba..b3f004492 100644 --- a/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs +++ b/StabilityMatrix.Avalonia/Controls/Models/PenPoint.cs @@ -1,9 +1,16 @@ -using SkiaSharp; +using System; +using SkiaSharp; namespace StabilityMatrix.Avalonia.Controls.Models; -public readonly record struct PenPoint(SKPoint Point) +public readonly record struct PenPoint(ulong X, ulong Y) { + public PenPoint(double x, double y) + : this(Convert.ToUInt64(x), Convert.ToUInt64(y)) { } + + public PenPoint(SKPoint skPoint) + : this(Convert.ToUInt64(skPoint.X), Convert.ToUInt64(skPoint.Y)) { } + /// /// Radius of the point. /// @@ -18,4 +25,6 @@ public readonly record struct PenPoint(SKPoint Point) /// True if the point was created by a pen, false if it was created by a mouse. /// public bool IsPen { get; init; } + + public SKPoint ToSKPoint() => new(X, Y); } diff --git a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml index 198ac0ef1..cfd62d17a 100644 --- a/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml +++ b/StabilityMatrix.Avalonia/Controls/Painting/PaintCanvas.axaml @@ -8,10 +8,16 @@ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:vmControls="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Controls" xmlns:faIcons="https://github.com/projektanker/icons.avalonia" + xmlns:converters="clr-namespace:StabilityMatrix.Avalonia.Converters" + xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" x:DataType="vmControls:PaintCanvasViewModel"> + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml.cs new file mode 100644 index 000000000..d0f03c9af --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml.cs @@ -0,0 +1,72 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Navigation; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using MainPackageManagerViewModel = StabilityMatrix.Avalonia.ViewModels.PackageManager.MainPackageManagerViewModel; + +namespace StabilityMatrix.Avalonia.Views.PackageManager; + +[Singleton] +public partial class MainPackageManagerView : UserControlBase +{ + public MainPackageManagerView() + { + InitializeComponent(); + + AddHandler(Frame.NavigatedToEvent, OnNavigatedTo, RoutingStrategies.Direct); + EventManager.Instance.OneClickInstallFinished += OnOneClickInstallFinished; + } + + private void OnOneClickInstallFinished(object? sender, bool skipped) + { + if (skipped) + return; + + Dispatcher.UIThread.Invoke(() => + { + var target = this.FindDescendantOfType() + ?.GetVisualChildren() + .OfType - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs index 3ac19d34f..d4e4c9806 100644 --- a/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/PackageManagerPage.axaml.cs @@ -1,60 +1,70 @@ -using System.Linq; -using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; +using System; +using System.ComponentModel; +using System.Linq; using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; using Avalonia.Threading; -using Avalonia.VisualTree; using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; using FluentAvalonia.UI.Navigation; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.PackageManager; using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Models; namespace StabilityMatrix.Avalonia.Views; [Singleton] -public partial class PackageManagerPage : UserControlBase +public partial class PackageManagerPage : UserControlBase, IHandleNavigation { + private readonly INavigationService packageNavigationService; + + private bool hasLoaded; + + private PackageManagerViewModel ViewModel => (PackageManagerViewModel)DataContext!; + + [DesignOnly(true)] + [Obsolete("For XAML use only", true)] public PackageManagerPage() + : this(App.Services.GetRequiredService>()) { } + + public PackageManagerPage(INavigationService packageNavigationService) { + this.packageNavigationService = packageNavigationService; + InitializeComponent(); AddHandler(Frame.NavigatedToEvent, OnNavigatedTo, RoutingStrategies.Direct); - EventManager.Instance.OneClickInstallFinished += OnOneClickInstallFinished; + + packageNavigationService.SetFrame(FrameView); + packageNavigationService.TypedNavigation += NavigationService_OnTypedNavigation; + FrameView.Navigated += FrameView_Navigated; + BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked; } - private void OnOneClickInstallFinished(object? sender, bool skipped) + /// + protected override void OnLoaded(RoutedEventArgs e) { - if (skipped) - return; + base.OnLoaded(e); - Dispatcher.UIThread.Invoke(() => + if (!hasLoaded) { - var target = this.FindDescendantOfType() - ?.GetVisualChildren() - .OfType - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + False + True + - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -