diff --git a/.editorconfig b/.editorconfig index cff102361..ddc54b53d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,9 @@ -root = true +root = true [*.cs] -max_line_length = 100 +max_line_length = 120 csharp_style_var_for_built_in_types = true dotnet_sort_system_directives_first = true + +# ReSharper properties +resharper_csharp_max_line_length = 120 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..446b951ea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80d6cdd12..b7d84081c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,7 @@ jobs: -o out -c Release -r ${{ env.platform-id }} -p:Version=$env:RELEASE_VERSION -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true + -p:PublishReadyToRun=true -p:SentryOrg=${{ secrets.SENTRY_ORG }} -p:SentryProject=${{ secrets.SENTRY_PROJECT }} -p:SentryUploadSymbols=true -p:SentryUploadSources=true @@ -197,4 +198,4 @@ jobs: tag_name: v${{ github.event.inputs.version }} body: ${{ steps.release_notes.outputs.release_notes }} draft: ${{ github.event.inputs.github-release-draft == 'true' }} - prerelease: ${{ github.event.inputs.github-release-prerelease == 'true' }} \ No newline at end of file + prerelease: ${{ github.event.inputs.github-release-prerelease == 'true' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e0c8005..c739ad8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.5.0 +### Added +- Added Inference, a built-in native Stable Diffusion interface, powered by ComfyUI +- Added option to change the Shared Folder method for packages using the three-dots menu on the Packages page +- Added the ability to Favorite models in the Model Browser +- Added "Favorites" sort option to the Model Browser +- Added notification flyout for new available updates. Dismiss to hide until the next update version. +### Changed +- Model Browser page size is now 20 instead of 14 +- Update changelog now only shows the difference between the current version and the latest version +### Fixed +- Fixed [#141](https://github.com/LykosAI/StabilityMatrix/issues/141) - Search not working when sorting by Installed on Model Browser +- Fixed SD.Next not showing "Open Web UI" button when finished loading +- Fixed model index startup errors when `./Models` contains unknown custom folder names +- Fixed ストップ button being cut off in Japanese translation +- Fixed update progress freezing in some cases +- Fixed light theme being default in first time setup window +- Fixed shared folder links not recreating fully when partially missing + ## v2.4.6 ### Added - LDSR / ADetailer shared folder links for Automatic1111 Package diff --git a/README.md b/README.md index 7d3be4b3f..0937d75ed 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,16 @@ [voltaml]: https://github.com/VoltaML/voltaML-fast-stable-diffusion [invokeai]: https://github.com/invoke-ai/InvokeAI [fooocus]: https://github.com/lllyasviel/Fooocus -[fooocus_mre]: https://github.com/MoonRide303/Fooocus-MRE +[fooocus-mre]: https://github.com/MoonRide303/Fooocus-MRE [civitai]: https://civitai.com/ Multi-Platform Package Manager for Stable Diffusion +### ✨ New in 2.5 - [Inference](#inference), a built-in Stable Diffusion interface powered by ComfyUI + ### 🖱️ One click install and update for Stable Diffusion Web UI Packages -- Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai], [Fooocus][fooocus], and [Fooocus MRE][fooocus_mre] +- Supports [Automatic 1111][auto1111], [Comfy UI][comfy], [SD.Next (Vladmandic)][sdnext], [VoltaML][voltaml], [InvokeAI][invokeai], [Fooocus][fooocus], and [Fooocus MRE][fooocus-mre] - Embedded Git and Python dependencies, with no need for either to be globally installed - Fully portable - move Stability Matrix's Data Directory to a new drive or computer at any time @@ -36,7 +38,7 @@ Multi-Platform Package Manager for Stable Diffusion - Automatically imports to the associated model folder depending on the model type - Downloads relevant metadata files and preview image -![header](https://github.com/LykosAI/StabilityMatrix/assets/13956642/a9c5f925-8561-49ba-855b-1b7bf57d7c0d) +![header](https://cdn.lykos.ai/static/sm-banner-rounded.webp) [![Release](https://img.shields.io/github/v/release/LykosAI/StabilityMatrix?label=Latest%20Release&link=https%3A%2F%2Fgithub.com%2FLykosAI%2FStabilityMatrix%2Freleases%2Flatest)][release] @@ -46,6 +48,18 @@ Multi-Platform Package Manager for Stable Diffusion > macOS builds are currently pending: [#45][download-macos] +### Inference - A reimagined built-in Stable Diffusion experience +- Powerful auto-completion and syntax highlighting using a formal language grammar +- Workspaces open in tabs that save and load from `.smproj` project files + +![](https://cdn.lykos.ai/static/sm-banner-inference-rounded.webp) + +- Customizable dockable and float panels +- Generated images contain Inference Project, ComfyUI Nodes, and A1111-compatible metadata +- Drag and drop gallery images or files to load states + +![](https://cdn.lykos.ai/static/sc-inference-drag-load-2.gif) + ### Searchable launch options

@@ -61,13 +75,16 @@ Multi-Platform Package Manager for Stable Diffusion ### Shared model directory for all your packages - Import local models by simple drag and drop -- Option to find CivitAI metadata and preview thumbnails for new local imports -- Toggle visibility of categories like LoRA, VAE, CLIP, etc. +- Option to automatically find CivitAI metadata and preview thumbnails for new local imports

+- Find connected metadata for existing models +

+ +

## Localization Stability Matrix is now available in the following languages, thanks to our community contributors: @@ -76,6 +93,8 @@ Stability Matrix is now available in the following languages, thanks to our comm - kgmkm_mkgm - 🇨🇳 中文(简体,繁体) - jimlovewine +- 🇮🇹 Italiano + - Marco Capelli If you would like to contribute a translation, please create an issue or contact us on Discord. Include an email where we'll send an invite to our [POEditor](https://poeditor.com/) project. diff --git a/StabilityMatrix.Avalonia.Diagnostics/LogViewer/DataStoreLoggerTarget.cs b/StabilityMatrix.Avalonia.Diagnostics/LogViewer/DataStoreLoggerTarget.cs index 22646b047..da8f501a7 100644 --- a/StabilityMatrix.Avalonia.Diagnostics/LogViewer/DataStoreLoggerTarget.cs +++ b/StabilityMatrix.Avalonia.Diagnostics/LogViewer/DataStoreLoggerTarget.cs @@ -71,10 +71,6 @@ protected override void Write(LogEventInfo logEvent) Color = _config!.Colors[logLevel], } ); - - Debug.WriteLine( - $"--- [{logLevel.ToString()[..3]}] {message} - {logEvent.Exception?.Message ?? "no error"}" - ); } #endregion diff --git a/StabilityMatrix.Avalonia.pupnet.conf b/StabilityMatrix.Avalonia.pupnet.conf index fc75bf12e..f663cbd9c 100644 --- a/StabilityMatrix.Avalonia.pupnet.conf +++ b/StabilityMatrix.Avalonia.pupnet.conf @@ -138,7 +138,7 @@ DotnetProjectPath = StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj # '-p:DebugType=None -p:DebugSymbols=false -p:PublishSingleFile=true -p:PublishReadyToRun=true # -p:PublishTrimmed=true -p:TrimMode=link'. Note. This value may use macro variables. Use 'pupnet --help macro' # for reference. See: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish -DotnetPublishArgs = -p:Version=${APP_VERSION} -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false --self-contained +DotnetPublishArgs = -p:Version=${APP_VERSION} -p:PublishReadyToRun=true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false --self-contained # Post-publish (or standalone build) command on Linux (ignored on Windows). It is called after dotnet # publish, but before the final output is built. This could, for example, be a script which copies diff --git a/StabilityMatrix.Avalonia/Animations/ItemsRepeaterArrangeAnimation.cs b/StabilityMatrix.Avalonia/Animations/ItemsRepeaterArrangeAnimation.cs new file mode 100644 index 000000000..507d9872e --- /dev/null +++ b/StabilityMatrix.Avalonia/Animations/ItemsRepeaterArrangeAnimation.cs @@ -0,0 +1,108 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Animations; + +namespace StabilityMatrix.Avalonia.Animations; + +public class ItemsRepeaterArrangeAnimation : AvaloniaObject +{ + public static readonly AttachedProperty EnableItemsArrangeAnimationProperty = + AvaloniaProperty.RegisterAttached( + "EnableItemsArrangeAnimation", + typeof(ItemsRepeaterArrangeAnimation) + ); + + static ItemsRepeaterArrangeAnimation() + { + EnableItemsArrangeAnimationProperty.Changed.AddClassHandler( + OnEnableItemsArrangeAnimationChanged + ); + } + + private static void OnEnableItemsArrangeAnimationChanged( + ItemsRepeater itemsRepeater, + AvaloniaPropertyChangedEventArgs eventArgs + ) + { + if (eventArgs.NewValue is true) + { + itemsRepeater.ElementPrepared += OnElementPrepared; + itemsRepeater.ElementIndexChanged += OnElementIndexChanged; + } + else + { + // itemsRepeater.Opened -= OnOpened; + } + } + + private static void CreateAnimation(Visual item) + { + var compositionVisual = + ElementComposition.GetElementVisual(item) ?? throw new NullReferenceException(); + + if (compositionVisual.ImplicitAnimations is { } animations && animations.HasKey("Offset")) + { + return; + } + + var compositor = compositionVisual.Compositor; + + var offsetAnimation = compositor.CreateVector3KeyFrameAnimation(); + offsetAnimation.Target = "Offset"; + // Using the "this.FinalValue" to indicate the last value of the Offset property + offsetAnimation.InsertExpressionKeyFrame(1.0f, "this.FinalValue"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds(150); + + // Create a new implicit animation collection and bind the offset animation + var implicitAnimationCollection = compositor.CreateImplicitAnimationCollection(); + implicitAnimationCollection["Offset"] = offsetAnimation; + compositionVisual.ImplicitAnimations = implicitAnimationCollection; + } + + private static void OnElementPrepared(object? sender, ItemsRepeaterElementPreparedEventArgs e) + { + if ( + sender is not ItemsRepeater itemsRepeater + || !GetEnableItemsArrangeAnimation(itemsRepeater) + ) + return; + + CreateAnimation(itemsRepeater); + } + + private static void OnElementIndexChanged( + object? sender, + ItemsRepeaterElementIndexChangedEventArgs e + ) + { + if ( + sender is not ItemsRepeater itemsRepeater + || !GetEnableItemsArrangeAnimation(itemsRepeater) + ) + return; + + CreateAnimation(itemsRepeater); + } + + /*private static void OnOpened(object sender, EventArgs e) + { + if (sender is not WindowBase windowBase || !GetEnableScaleShowAnimation(windowBase)) + return; + + // Here we explicitly animate the "Scale" property + // The implementation is the same as `Offset` at the beginning, but just with the Scale property + windowBase.StartWindowScaleAnimation(); + }*/ + + public static bool GetEnableItemsArrangeAnimation(ItemsRepeater element) + { + return element.GetValue(EnableItemsArrangeAnimationProperty); + } + + public static void SetEnableItemsArrangeAnimation(ItemsRepeater element, bool value) + { + element.SetValue(EnableItemsArrangeAnimationProperty, value); + } +} diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index abaec107f..0258b36b2 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="StabilityMatrix.Avalonia.App" xmlns:local="using:StabilityMatrix.Avalonia" + xmlns:idcr="using:Dock.Avalonia.Controls.Recycling" xmlns:styling="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia" - - RequestedThemeVariant="Default"> + RequestedThemeVariant="Dark"> @@ -15,9 +15,18 @@ + + + + + + 700 + + + avares://StabilityMatrix.Avalonia/Assets/Fonts/NotoSansJP#Noto Sans JP @@ -25,11 +34,35 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index cbaa65d24..2633c3f0b 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -3,6 +3,7 @@ using StabilityMatrix.Avalonia.Diagnostics.LogViewer.Extensions; #endif using System; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -16,10 +17,13 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; using Avalonia.Markup.Xaml; +using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Styling; +using Avalonia.Threading; using FluentAvalonia.UI.Controls; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -40,24 +44,31 @@ using StabilityMatrix.Avalonia.Helpers; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.ViewModels.Inference; using StabilityMatrix.Avalonia.ViewModels.PackageManager; using StabilityMatrix.Avalonia.ViewModels.Progress; +using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Avalonia.Views; using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Avalonia.Views.Inference; +using StabilityMatrix.Avalonia.Views.Settings; using StabilityMatrix.Core.Api; using StabilityMatrix.Core.Converters.Json; using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.Cache; using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Configs; +using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Packages; using StabilityMatrix.Core.Models.Settings; @@ -81,6 +92,9 @@ public sealed class App : Application [NotNull] public static IStorageProvider? StorageProvider { get; private set; } + [NotNull] + public static IClipboard? Clipboard { get; private set; } + // ReSharper disable once MemberCanBePrivate.Global [NotNull] public static IConfiguration? Config { get; private set; } @@ -208,6 +222,7 @@ private void ShowMainWindow() VisualRoot = mainWindow; StorageProvider = mainWindow.StorageProvider; + Clipboard = mainWindow.Clipboard ?? throw new NullReferenceException("Clipboard is null"); DesktopLifetime.MainWindow = mainWindow; DesktopLifetime.Exit += OnExit; @@ -237,10 +252,13 @@ internal static void ConfigurePageViewModels(IServiceCollection services) services .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton(); services.AddSingleton( @@ -249,12 +267,14 @@ internal static void ConfigurePageViewModels(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService>(), - provider.GetRequiredService() + provider.GetRequiredService(), + provider.GetRequiredService() ) { Pages = { provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), @@ -277,7 +297,10 @@ internal static void ConfigureDialogViewModels(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); // Dialog view models (singleton) services.AddSingleton(); @@ -286,6 +309,8 @@ internal static void ConfigureDialogViewModels(IServiceCollection services) // Other transients (usually sub view models) services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -295,6 +320,21 @@ internal static void ConfigureDialogViewModels(IServiceCollection services) // Controls services.AddTransient(); + // Inference controls + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // Dialog factory services.AddSingleton>( provider => @@ -313,8 +353,26 @@ internal static void ConfigureDialogViewModels(IServiceCollection services) .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) + .Register(provider.GetRequiredService) ); } @@ -325,17 +383,41 @@ internal static void ConfigureViews(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + // Inference tabs + services.AddTransient(); + services.AddTransient(); + + // Inference controls + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // Dialogs services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); // Controls services.AddTransient(); @@ -378,8 +460,12 @@ private static IServiceCollection ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton( @@ -450,6 +536,15 @@ private static IServiceCollection ConfigureServices() ContentSerializer = new SystemTextJsonContentSerializer(jsonSerializerOptions) }; + // Refit settings for IApiFactory + var defaultSystemTextJsonSettings = + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + defaultSystemTextJsonSettings.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + var apiFactoryRefitSettings = new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer(defaultSystemTextJsonSettings), + }; + // HTTP Policies var retryStatusCodes = new[] { @@ -506,6 +601,18 @@ private static IServiceCollection ConfigureServices() .AddHttpClient("A3Client") .AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy)); + /*services.AddHttpClient("IComfyApi") + .AddPolicyHandler(localTimeout.WrapAsync(localRetryPolicy));*/ + + // Add Refit client factory + services.AddSingleton( + provider => + new ApiFactory(provider.GetRequiredService()) + { + RefitSettings = apiFactoryRefitSettings, + } + ); + ConditionalAddLogViewer(services); // Add logging @@ -587,7 +694,14 @@ private static LoggingConfiguration ConfigureLogging() { var debugTarget = builder .ForTarget("console") - .WriteTo(new DebuggerTarget { Layout = "${message}" }) + .WriteTo( + new DebuggerTarget + { + Layout = + "[${level:format=TriLetter}] " + + "${callsite:includeNamespace=false:captureStackTrace=false}: ${message}" + } + ) .WithAsync(); var fileTarget = builder @@ -612,6 +726,8 @@ private static LoggingConfiguration ConfigureLogging() builder.ForLogger("System.*").WriteToNil(NLog.LogLevel.Warn); builder.ForLogger("Microsoft.*").WriteToNil(NLog.LogLevel.Warn); builder.ForLogger("Microsoft.Extensions.Http.*").WriteToNil(NLog.LogLevel.Warn); + + // Disable console trace logging by default builder .ForLogger("StabilityMatrix.Avalonia.ViewModels.ConsoleViewModel") .WriteToNil(NLog.LogLevel.Debug); @@ -649,6 +765,69 @@ private static LoggingConfiguration ConfigureLogging() return LogManager.Configuration; } + /// + /// Opens a dialog to save the current view as a screenshot. + /// + /// Only available in debug builds. + [Conditional("DEBUG")] + internal static void DebugSaveScreenshot(int dpi = 96) + { + const int scale = 2; + dpi *= scale; + + var results = new List(); + var targets = new List { VisualRoot }; + + foreach (var visual in targets.Where(x => x != null)) + { + var rect = new Rect(visual!.Bounds.Size); + + var pixelSize = new PixelSize((int)rect.Width * scale, (int)rect.Height * scale); + var dpiVector = new Vector(dpi, dpi); + + var ms = new MemoryStream(); + + using (var bitmap = new RenderTargetBitmap(pixelSize, dpiVector)) + { + bitmap.Render(visual); + bitmap.Save(ms); + } + + results.Add(ms); + } + + Dispatcher.UIThread.InvokeAsync(async () => + { + var dest = await StorageProvider.SaveFilePickerAsync( + new FilePickerSaveOptions() + { + SuggestedFileName = "screenshot.png", + ShowOverwritePrompt = true + } + ); + + if (dest?.TryGetLocalPath() is { } localPath) + { + var localFile = new FilePath(localPath); + foreach (var (i, stream) in results.Enumerate()) + { + var name = localFile.NameWithoutExtension; + if (results.Count > 1) + { + name += $"_{i + 1}"; + } + + localFile = localFile.Directory!.JoinFile(name + ".png"); + localFile.Create(); + + await using var fileStream = localFile.Info.OpenWrite(); + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fileStream); + } + } + }); + } + [Conditional("DEBUG")] private static void ConditionalAddLogViewer(IServiceCollection services) { diff --git a/StabilityMatrix.Avalonia/Assets.cs b/StabilityMatrix.Avalonia/Assets.cs index 9e5a6cd10..db08322e1 100644 --- a/StabilityMatrix.Avalonia/Assets.cs +++ b/StabilityMatrix.Avalonia/Assets.cs @@ -26,7 +26,13 @@ internal static class Assets public static AvaloniaResource LicensesJson => new("avares://StabilityMatrix.Avalonia/Assets/licenses.json"); - private const UnixFileMode unix755 = + public static AvaloniaResource ImagePromptLanguageJson => + new("avares://StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json"); + + public static AvaloniaResource ThemeMatrixDarkJson => + new("avares://StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json"); + + private const UnixFileMode Unix755 = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute @@ -48,14 +54,14 @@ internal static class Assets PlatformKind.Linux | PlatformKind.X64, new AvaloniaResource( "avares://StabilityMatrix.Avalonia/Assets/linux-x64/7zzs", - unix755 + Unix755 ) ), ( PlatformKind.MacOS | PlatformKind.Arm, new AvaloniaResource( "avares://StabilityMatrix.Avalonia/Assets/macos-arm64/7zz", - unix755 + Unix755 ) ) ); @@ -130,6 +136,19 @@ internal static class Assets ) ); + public static IReadOnlyList DefaultCompletionTags { get; } = + new[] + { + new RemoteResource( + new Uri("https://cdn.lykos.ai/tags/danbooru.csv"), + "b84a879f1d9c47bf4758d66542598faa565b1571122ae12e7b145da8e7a4c1c6" + ), + new RemoteResource( + new Uri("https://cdn.lykos.ai/tags/e621.csv"), + "ef7ea148ad865ad936d0c1ee57f0f83de723b43056c70b07fd67dbdbb89cae35" + ) + }; + public static Uri DiscordServerUrl { get; } = new("https://discord.com/invite/TUrgfECxHz"); public static Uri PatreonUrl { get; } = new("https://patreon.com/StabilityMatrix"); diff --git a/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json b/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json new file mode 100644 index 000000000..fdf0964e3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Assets/ImagePrompt.tmLanguage.json @@ -0,0 +1,269 @@ +{ + "name": "Image Prompt", + "scopeName": "source.prompt", + "uuid": "A5283894-BA62-4BFE-BB29-7892AE7ACCDC", + "foldingStartMarker": "^.*\b(\\#)\b.*$", + "foldingStopMarker": "(\r?\n){2}", + "patterns": [ + { + "include": "#value" + } + ], + "repository": { + "comment": { + "captures": { + "1": { + "name": "punctuation.definition.comment.prompt" + } + }, + "match": "(#).*$\\n?", + "name": "comment.line.number-sign.prompt" + }, + "escape": { + "begin": "\\\\", + "beginCaptures": { + "0": { + "name": "constant.character.escape.prompt" + } + }, + "end": "[-+.!(){}\\[\\]<\\>]", + "endCaptures": { + "0": { + "name": "constant.character.escape.target.prompt" + } + }, + "name": "meta.structure.escape.prompt", + "patterns": [ + { + "match": "[^-+.!(){}\\[\\]<\\>]", + "name": "invalid.illegal.escape.prompt" + } + ] + }, + "weight": { + "begin": ":", + "beginCaptures": { + "0": { + "name": "punctuation.separator.weight.prompt" + } + }, + "end": "[\\)\\b]", + "name": "meta.structure.weight.prompt", + "patterns": [ + { + "include": "#number", + "name": "constant.numeric.weight.prompt" + }, + { + "match": "[^\\s\\)\\b]", + "name": "invalid.illegal.expected-weight-separator.prompt" + } + ] + }, + "parenthesized": { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "punctuation.definition.array.begin.prompt" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.array.end.prompt" + } + }, + "name": "meta.structure.array.prompt", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#escape" + }, + { + "include": "#weight" + }, + { + "include": "#value" + }, + { + "match": "[^\\s\\)]", + "name": "invalid.illegal.expected-array-separator.prompt" + } + ] + }, + "array": { + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "punctuation.definition.array.begin.prompt" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.array.end.prompt" + } + }, + "name": "meta.structure.array.prompt", + "patterns": [ + { + "include": "#value" + }, + { + "match": "[^\\s\\]]", + "name": "invalid.illegal.expected-array-separator.prompt" + } + ] + }, + "network": { + "begin": "<", + "beginCaptures": { + "0": { + "name": "punctuation.definition.network.begin.prompt" + } + }, + "end": ">", + "endCaptures": { + "0": { + "name": "punctuation.definition.network.end.prompt" + } + }, + "name": "meta.structure.network.prompt", + "patterns": [ + { + "match": "(?<=\\<)([^,:\\<\\>]+)(:)([^,:\\<\\>]+)(:)([-+]?\\d+(?:\\.\\d+)?)", + "captures": { + "1": { + "name": "meta.embedded.network.type.prompt" + }, + "2": { + "name": "punctuation.separator.variable.prompt" + }, + "3": { + "name": "meta.embedded.network.model.prompt" + }, + "4": { + "name": "punctuation.separator.variable.prompt" + }, + "5" : { + "name": "constant.numeric" + } + } + }, + { + "match": "(?<=\\<)([^,:\\<\\>]+)(:)([^,:\\<\\>]+)?", + "captures": { + "1": { + "name": "meta.embedded.network.type.prompt" + }, + "2": { + "name": "punctuation.separator.variable.prompt" + }, + "3": { + "name": "meta.embedded.network.model.prompt" + } + } + }, + { + "match": "(?<=\\<)([^,:\\<\\>]+)", + "captures": { + "1": { + "name": "meta.embedded.network.type.prompt" + } + } + }, + { + "include": "#comment" + }, + { + "include": "#escape" + }, + { + "match": "[^\\s\\>]+", + "name": "invalid.illegal.expected-array-separator.prompt" + } + ] + }, + "separator": { + "match": ",\\s*", + "name": "punctuation.separator.variable.prompt" + }, + "colon": { + "match": ":", + "name": "punctuation.separator.variable.prompt" + }, + "number": { + "match": "(?x) # turn on extended mode\n -? # an optional minus\n (?:\n 0 # a zero\n | # ...or...\n [1-9] # a 1-9 character\n \\d* # followed by zero or more digits\n )\n (?:\n (?:\n \\. # a period\n \\d+ # followed by one or more digits\n )?\n (?:\n [eE] # an e character\n [+-]? # followed by an option +/-\n \\d+ # followed by one or more digits\n )? # make exponent optional\n )? # make decimal portion optional", + "name": "constant.numeric" + }, + "keyword": { + "match": "\\b(?:BREAK|AND)\\b", + "name": "keyword.control" + }, + "whitespace": { + "match": "\\s+", + "name": "meta.embedded.whitespace" + }, + "model": { + "match": "\\b(?[\\w\\d_]+):(?\\w+)(?::(?\\d+(\\.\\d+)?))?\\b", + "name": "meta.embedded.model" + }, + "text": { + "match": "[^,:\\[\\]\\(\\)\\<\\> \\\\]+", + "name": "meta.embedded" + }, + "invalid_reserved" : { + "name": "invalid.illegal.reserved.prompt", + "patterns": [ + { + "match": ":", + "name": "invalid.illegal.reserved.prompt" + }, + { + "match": "\\)", + "name": "invalid.illegal.mismatched.parenthesis.closing.prompt" + }, + { + "match": "\\(", + "name": "invalid.illegal.mismatched.parenthesis.opening.prompt" + } + ] + }, + "value": { + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#escape" + }, + { + "include": "#parenthesized" + }, + { + "include": "#array" + }, + { + "include": "#network" + }, + { + "include": "#separator" + }, + { + "include": "#keyword" + }, + { + "include": "#whitespace" + }, + { + "include": "#text" + }, + { + "include": "#invalid_reserved" + } + ] + } + } +} diff --git a/StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json b/StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json new file mode 100644 index 000000000..18759d8fb --- /dev/null +++ b/StabilityMatrix.Avalonia/Assets/ThemeMatrixDark.json @@ -0,0 +1,694 @@ +{ + "type": "dark", + "colors": { + "dropdown.background": "#525252", + "list.activeSelectionBackground": "#707070", + "quickInputList.focusBackground": "#707070", + "list.inactiveSelectionBackground": "#4e4e4e", + "list.hoverBackground": "#444444", + "list.highlightForeground": "#e58520", + "button.background": "#565656", + "editor.background": "#1e1e1e", + "editor.foreground": "#c5c8c6", + "editor.selectionBackground": "#676b7180", + "minimap.selectionHighlight": "#676b7180", + "editor.selectionHighlightBackground": "#575b6180", + "editor.lineHighlightBackground": "#303030", + "editorLineNumber.activeForeground": "#949494", + "editor.wordHighlightBackground": "#4747a180", + "editor.wordHighlightStrongBackground": "#6767ce80", + "editorCursor.foreground": "#c07020", + "editorWhitespace.foreground": "#505037", + "editorIndentGuide.background": "#505037", + "editorIndentGuide.activeBackground": "#707057", + "editorGroupHeader.tabsBackground": "#282828", + "tab.inactiveBackground": "#404040", + "tab.border": "#303030", + "tab.inactiveForeground": "#d8d8d8", + "tab.lastPinnedBorder": "#505050", + "peekView.border": "#3655b5", + "panelTitle.activeForeground": "#ffffff", + "statusBar.background": "#505050", + "statusBar.debuggingBackground": "#505050", + "statusBar.noFolderBackground": "#505050", + "titleBar.activeBackground": "#505050", + "statusBarItem.remoteBackground": "#3655b5", + "ports.iconRunningProcessForeground": "#CCCCCC", + "activityBar.background": "#353535", + "activityBar.foreground": "#ffffff", + "activityBarBadge.background": "#3655b5", + "sideBar.background": "#272727", + "sideBarSectionHeader.background": "#505050", + "menu.background": "#272727", + "menu.foreground": "#CCCCCC", + "pickerGroup.foreground": "#b0b0b0", + "inputOption.activeBorder": "#3655b5", + "focusBorder": "#3655b5", + "terminal.ansiBlack": "#1e1e1e", + "terminal.ansiRed": "#C4265E", + "terminal.ansiGreen": "#86B42B", + "terminal.ansiYellow": "#B3B42B", + "terminal.ansiBlue": "#6A7EC8", + "terminal.ansiMagenta": "#8C6BC8", + "terminal.ansiCyan": "#56ADBC", + "terminal.ansiWhite": "#e3e3dd", + "terminal.ansiBrightBlack": "#666666", + "terminal.ansiBrightRed": "#f92672", + "terminal.ansiBrightGreen": "#A6E22E", + "terminal.ansiBrightYellow": "#e2e22e", + "terminal.ansiBrightBlue": "#819aff", + "terminal.ansiBrightMagenta": "#AE81FF", + "terminal.ansiBrightCyan": "#66D9EF", + "terminal.ansiBrightWhite": "#f8f8f2" + }, + "tokenColors": [ + { + "settings": { + "foreground": "#C5C8C6" + } + }, + { + "scope": [ + "meta.embedded", + "source.groovy.embedded" + ], + "settings": { + "foreground": "#C5C8C6" + } + }, + { + "name": "Network Type", + "scope": [ + "meta.embedded.network.type" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#3990F6" + } + }, + { + "name": "Network Model", + "scope": [ + "meta.embedded.network.model" + ], + "settings": { + "foreground": "#D0B344" + } + }, + { + "name": "Comment", + "scope": "comment", + "settings": { + "fontStyle": "", + "foreground": "#9A9B99" + } + }, + { + "name": "String", + "scope": "string", + "settings": { + "fontStyle": "", + "foreground": "#9AA83A" + } + }, + { + "name": "String Embedded Source", + "scope": "string source", + "settings": { + "fontStyle": "", + "foreground": "#D08442" + } + }, + { + "name": "Number", + "scope": "constant.numeric", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Built-in constant", + "scope": "constant.language", + "settings": { + "fontStyle": "", + "foreground": "#408080" + } + }, + { + "name": "Escape character", + "scope": "constant.character.escape", + "settings": { + "fontStyle": "", + "foreground": "#408080" + } + }, + { + "name": "Escape sequence target", + "scope": "constant.character.escape.target", + "settings": { + "fontStyle": "", + "foreground": "#C5C8C6" + } + }, + { + "name": "User-defined constant", + "scope": "constant.character, constant.other", + "settings": { + "fontStyle": "", + "foreground": "#8080FF" + } + }, + { + "name": "Keyword", + "scope": "keyword", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Support", + "scope": "support", + "settings": { + "fontStyle": "", + "foreground": "#C7444A" + } + }, + { + "name": "Storage", + "scope": "storage", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Class name", + "scope": "entity.name.class, entity.name.type, entity.name.namespace, entity.name.scope-resolution", + "settings": { + "fontStyle": "", + "foreground": "#9B0000" + } + }, + { + "name": "Inherited class", + "scope": "entity.other.inherited-class", + "settings": { + "fontStyle": "", + "foreground": "#C7444A" + } + }, + { + "name": "Function name", + "scope": "entity.name.function", + "settings": { + "fontStyle": "", + "foreground": "#CE6700" + } + }, + { + "name": "Function argument", + "scope": "variable.parameter", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Tag name", + "scope": "entity.name.tag", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Tag attribute", + "scope": "entity.other.attribute-name", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Library function", + "scope": "support.function", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Keyword", + "scope": "keyword", + "settings": { + "fontStyle": "", + "foreground": "#676867" + } + }, + { + "name": "Class Variable", + "scope": "variable.other, variable.js, punctuation.separator.variable", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Meta Brace", + "scope": "punctuation.section.embedded -(source string source punctuation.section.embedded), meta.brace.erb.html", + "settings": { + "fontStyle": "", + "foreground": "#008200" + } + }, + { + "name": "Invalid", + "scope": "invalid", + "settings": { + "fontStyle": "underline", + "foreground": "#FF0B00" + } + }, + { + "name": "Normal Variable", + "scope": "variable.other.php, variable.other.normal", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Function Object", + "scope": "meta.function-call.object", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Function Call Variable", + "scope": "variable.other.property", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Keyword Control / Special", + "scope": [ + "keyword.control", + "keyword.operator.new.cpp", + "keyword.operator.delete.cpp", + "keyword.other.using", + "keyword.other.operator" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#9872A2" + } + }, + { + "name": "Tag", + "scope": "meta.tag", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "Tag Name", + "scope": "entity.name.tag", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Doctype", + "scope": "meta.doctype, meta.tag.sgml-declaration.doctype, meta.tag.sgml.doctype", + "settings": { + "fontStyle": "", + "foreground": "#9AA83A" + } + }, + { + "name": "Tag Inline Source", + "scope": "meta.tag.inline source, text.html.php.source", + "settings": { + "fontStyle": "", + "foreground": "#9AA83A" + } + }, + { + "name": "Tag Other", + "scope": "meta.tag.other, entity.name.tag.style, entity.name.tag.script, meta.tag.block.script, source.js.embedded punctuation.definition.tag.html, source.css.embedded punctuation.definition.tag.html", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "Tag Attribute", + "scope": "entity.other.attribute-name, meta.tag punctuation.definition.string", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "Tag Value", + "scope": "meta.tag string -source -punctuation, text source text meta.tag string -punctuation", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "Meta Brace", + "scope": "punctuation.section.embedded -(source string source punctuation.section.embedded), meta.brace.erb.html", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "HTML ID", + "scope": "meta.toc-list.id", + "settings": { + "foreground": "#9AA83A" + } + }, + { + "name": "HTML String", + "scope": "string.quoted.double.html, punctuation.definition.string.begin.html, punctuation.definition.string.end.html, punctuation.definition.string.end.html source, string.quoted.double.html source", + "settings": { + "fontStyle": "", + "foreground": "#9AA83A" + } + }, + { + "name": "HTML Tags", + "scope": "punctuation.definition.tag.html, punctuation.definition.tag.begin, punctuation.definition.tag.end", + "settings": { + "fontStyle": "", + "foreground": "#6089B4" + } + }, + { + "name": "CSS ID", + "scope": "meta.selector.css entity.other.attribute-name.id", + "settings": { + "fontStyle": "", + "foreground": "#9872A2" + } + }, + { + "name": "CSS Property Name", + "scope": "support.type.property-name.css", + "settings": { + "fontStyle": "", + "foreground": "#676867" + } + }, + { + "name": "CSS Property Value", + "scope": "meta.property-group support.constant.property-value.css, meta.property-value support.constant.property-value.css", + "settings": { + "fontStyle": "", + "foreground": "#C7444A" + } + }, + { + "name": "JavaScript Variable", + "scope": "variable.language.js", + "settings": { + "foreground": "#CC555A" + } + }, + { + "name": "Template Definition", + "scope": [ + "punctuation.definition.template-expression", + "punctuation.section.embedded.coffee" + ], + "settings": { + "foreground": "#D08442" + } + }, + { + "name": "Reset JavaScript string interpolation expression", + "scope": [ + "meta.template.expression" + ], + "settings": { + "foreground": "#C5C8C6" + } + }, + { + "name": "PHP Function Call", + "scope": "meta.function-call.object.php", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "PHP Single Quote HMTL Fix", + "scope": "punctuation.definition.string.end.php, punctuation.definition.string.begin.php", + "settings": { + "foreground": "#9AA83A" + } + }, + { + "name": "PHP Parenthesis HMTL Fix", + "scope": "source.php.embedded.line.html", + "settings": { + "foreground": "#676867" + } + }, + { + "name": "PHP Punctuation Embedded", + "scope": "punctuation.section.embedded.begin.php, punctuation.section.embedded.end.php", + "settings": { + "fontStyle": "", + "foreground": "#D08442" + } + }, + { + "name": "Ruby Symbol", + "scope": "constant.other.symbol.ruby", + "settings": { + "fontStyle": "", + "foreground": "#9AA83A" + } + }, + { + "name": "Ruby Variable", + "scope": "variable.language.ruby", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "Ruby Special Method", + "scope": "keyword.other.special-method.ruby", + "settings": { + "fontStyle": "", + "foreground": "#D9B700" + } + }, + { + "name": "Ruby Embedded Source", + "scope": [ + "punctuation.section.embedded.begin.ruby", + "punctuation.section.embedded.end.ruby" + ], + "settings": { + "foreground": "#D08442" + } + }, + { + "name": "SQL", + "scope": "keyword.other.DML.sql", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "diff: header", + "scope": "meta.diff, meta.diff.header", + "settings": { + "fontStyle": "italic", + "foreground": "#E0EDDD" + } + }, + { + "name": "diff: deleted", + "scope": "markup.deleted", + "settings": { + "fontStyle": "", + "foreground": "#dc322f" + } + }, + { + "name": "diff: changed", + "scope": "markup.changed", + "settings": { + "fontStyle": "", + "foreground": "#cb4b16" + } + }, + { + "name": "diff: inserted", + "scope": "markup.inserted", + "settings": { + "foreground": "#219186" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#9872A2" + } + }, + { + "name": "Markup Lists", + "scope": "markup.list", + "settings": { + "foreground": "#9AA83A" + } + }, + { + "name": "Markup Styling", + "scope": "markup.bold, markup.italic", + "settings": { + "foreground": "#6089B4" + } + }, + { + "name": "Markup Inline", + "scope": "markup.inline.raw", + "settings": { + "fontStyle": "", + "foreground": "#FF0080" + } + }, + { + "name": "Markup Headings", + "scope": "markup.heading", + "settings": { + "foreground": "#D0B344" + } + }, + { + "name": "Markup Setext Header", + "scope": "markup.heading.setext", + "settings": { + "fontStyle": "", + "foreground": "#D0B344" + } + }, + { + "name": "Markdown Headings", + "scope": "markup.heading.markdown", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markdown Quote", + "scope": "markup.quote.markdown", + "settings": { + "fontStyle": "italic", + "foreground": "" + } + }, + { + "name": "Markdown Bold", + "scope": "markup.bold.markdown", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markdown Link Title/Description", + "scope": "string.other.link.title.markdown,string.other.link.description.markdown", + "settings": { + "foreground": "#AE81FF" + } + }, + { + "name": "Markdown Underline Link/Image", + "scope": "markup.underline.link.markdown,markup.underline.link.image.markdown", + "settings": { + "foreground": "" + } + }, + { + "name": "Markdown Emphasis", + "scope": "markup.italic.markdown", + "settings": { + "fontStyle": "italic" + } + }, + { + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markdown Punctuation Definition Link", + "scope": "markup.list.unnumbered.markdown, markup.list.numbered.markdown", + "settings": { + "foreground": "" + } + }, + { + "name": "Markdown List Punctuation", + "scope": [ + "punctuation.definition.list.begin.markdown" + ], + "settings": { + "foreground": "" + } + }, + { + "scope": "token.info-token", + "settings": { + "foreground": "#6796e6" + } + }, + { + "scope": "token.warn-token", + "settings": { + "foreground": "#cd9731" + } + }, + { + "scope": "token.error-token", + "settings": { + "foreground": "#f44747" + } + }, + { + "scope": "token.debug-token", + "settings": { + "foreground": "#b267e6" + } + }, + { + "name": "this.self", + "scope": "variable.language", + "settings": { + "foreground": "#c7444a" + } + } + ], + "semanticHighlighting": true +} diff --git a/StabilityMatrix.Avalonia/Assets/sdprompt.xshd b/StabilityMatrix.Avalonia/Assets/sdprompt.xshd new file mode 100644 index 000000000..ea538e4ef --- /dev/null +++ b/StabilityMatrix.Avalonia/Assets/sdprompt.xshd @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + [?,.()\[\]{}+\-/%*<>^!|]+ + + + + + + + + + + + + + + + + + + + AND + BREAK + + + + PROMPT + WEIGHT + + + + SELECT + DYNAMIC + + + \b0[xX][0-9a-fA-F]+|(\b\d+(\.[0-9]+)?|\.[0-9]+)([eE][+-]?[0-9]+)? + + + + diff --git a/StabilityMatrix.Avalonia/Assets/sitecustomize.py b/StabilityMatrix.Avalonia/Assets/sitecustomize.py index c51abc9df..8c2718f4d 100644 --- a/StabilityMatrix.Avalonia/Assets/sitecustomize.py +++ b/StabilityMatrix.Avalonia/Assets/sitecustomize.py @@ -6,6 +6,7 @@ """ import sys +import json # Application Program Command escape sequence # This wraps messages sent to the parent process. @@ -20,11 +21,13 @@ def send_apc(msg: str): sys.stdout.write(esc_apc + esc_prefix + msg + esc_st) sys.stdout.flush() +def send_apc_json(type: str, data: str): + """Send an APC Json message.""" + send_apc(json.dumps({"type": type, "data": data})) def send_apc_input(prompt: str): """Apc message for input() prompt.""" - send_apc('{"type":"input","data":"' + str(prompt) + '"}') - + send_apc_json("input", prompt) def audit(event: str, *args): """Main audit hook function.""" diff --git a/StabilityMatrix.Avalonia/Behaviors/ConditionalToolTipBehavior.cs b/StabilityMatrix.Avalonia/Behaviors/ConditionalToolTipBehavior.cs new file mode 100644 index 000000000..1fd572d96 --- /dev/null +++ b/StabilityMatrix.Avalonia/Behaviors/ConditionalToolTipBehavior.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Xaml.Interactivity; + +namespace StabilityMatrix.Avalonia.Behaviors; + +/// +/// Behavior that sets tooltip to null if the DisableOn condition is true. +/// +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class ConditionalToolTipBehavior : Behavior +{ + public static readonly StyledProperty DisableOnProperty = AvaloniaProperty.Register< + ConditionalToolTipBehavior, + bool + >("DisableOn"); + + public bool DisableOn + { + get => GetValue(DisableOnProperty); + set => SetValue(DisableOnProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + + if (DisableOn) + { + ToolTip.SetTip(AssociatedObject!, null); + } + } +} diff --git a/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs b/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs new file mode 100644 index 000000000..ac0164b49 --- /dev/null +++ b/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs @@ -0,0 +1,395 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.Xaml.Interactivity; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using NLog; +using StabilityMatrix.Avalonia.Controls.CodeCompletion; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models.Tokens; +using TextMateSharp.Grammars; + +namespace StabilityMatrix.Avalonia.Behaviors; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class TextEditorCompletionBehavior : Behavior +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private TextEditor textEditor = null!; + + /// + /// The current completion window, if open. + /// Is set to null when the window is closed. + /// + private CompletionWindow? completionWindow; + + public static readonly StyledProperty CompletionProviderProperty = + AvaloniaProperty.Register( + nameof(CompletionProvider) + ); + + public ICompletionProvider? CompletionProvider + { + get => GetValue(CompletionProviderProperty); + set => SetValue(CompletionProviderProperty, value); + } + + public static readonly StyledProperty TokenizerProviderProperty = + AvaloniaProperty.Register( + "TokenizerProvider" + ); + + public ITokenizerProvider? TokenizerProvider + { + get => GetValue(TokenizerProviderProperty); + set => SetValue(TokenizerProviderProperty, value); + } + + public static readonly StyledProperty IsEnabledProperty = AvaloniaProperty.Register< + TextEditorCompletionBehavior, + bool + >("IsEnabled", true); + + public bool IsEnabled + { + get => GetValue(IsEnabledProperty); + set => SetValue(IsEnabledProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + + if (AssociatedObject is not { } editor) + { + throw new NullReferenceException("AssociatedObject is null"); + } + + textEditor = editor; + textEditor.TextArea.TextEntered += TextArea_TextEntered; + textEditor.TextArea.KeyDown += TextArea_KeyDown; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + + textEditor.TextArea.TextEntered -= TextArea_TextEntered; + textEditor.TextArea.KeyDown -= TextArea_KeyDown; + } + + private CompletionWindow CreateCompletionWindow(TextArea textArea) + { + var window = new CompletionWindow(textArea, CompletionProvider!, TokenizerProvider!) + { + WindowManagerAddShadowHint = false, + CloseWhenCaretAtBeginning = true, + CloseAutomatically = true, + IsLightDismissEnabled = true, + CompletionList = { IsFiltering = true } + }; + return window; + } + + public void InvokeManualCompletion() + { + if (CompletionProvider is null) + { + throw new NullReferenceException("CompletionProvider is null"); + } + + // If window already open, skip since handled by completion window + // Unless this is an end char, where we'll open a new window + if (completionWindow is { IsOpen: true }) + { + Logger.ConditionalTrace("Skipping, completion window already open"); + return; + } + + // Get the segment of the token the caret is currently in + if (GetCaretCompletionToken() is not { } completionRequest) + { + Logger.ConditionalTrace("Token segment not found"); + return; + } + + // If type is not available, skip + if (!CompletionProvider.AvailableCompletionTypes.HasFlag(completionRequest.Type)) + { + Logger.ConditionalTrace( + "Skipping, completion type {CompletionType} not available in {AvailableTypes}", + completionRequest.Type, + CompletionProvider.AvailableCompletionTypes + ); + return; + } + + var tokenSegment = completionRequest.Segment; + + var token = textEditor.Document.GetText(tokenSegment); + Logger.ConditionalTrace("Using token {Token} ({@Segment})", token, tokenSegment); + + completionWindow = CreateCompletionWindow(textEditor.TextArea); + completionWindow.StartOffset = tokenSegment.Offset; + completionWindow.EndOffset = tokenSegment.EndOffset; + + completionWindow.UpdateQuery(completionRequest); + + completionWindow.Closed += (_, _) => + { + completionWindow = null; + }; + + completionWindow.Show(); + } + + private void TextArea_TextEntered(object? sender, TextInputEventArgs e) + { + Logger.ConditionalTrace($"Text entered: {e.Text.ToRepr()}"); + + if (!IsEnabled || CompletionProvider is null) + { + Logger.ConditionalTrace("Skipping, not enabled"); + return; + } + + if (e.Text is not { } triggerText) + { + Logger.ConditionalTrace("Skipping, null trigger text"); + return; + } + + if (!triggerText.All(IsCompletionChar)) + { + Logger.ConditionalTrace($"Skipping, invalid trigger text: {triggerText.ToRepr()}"); + return; + } + + InvokeManualCompletion(); + } + + private void TextArea_KeyDown(object? sender, KeyEventArgs e) + { + if (e is { Key: Key.Space, KeyModifiers: KeyModifiers.Control }) + { + InvokeManualCompletion(); + e.Handled = true; + } + } + + /// + /// Highlights the text segment in the text editor + /// + private void HighlightTextSegment(ISegment segment) + { + textEditor.TextArea.Selection = Selection.Create(textEditor.TextArea, segment); + } + + private static bool IsCompletionChar(char c) + { + const string extraAllowedChars = "._-:<"; + return char.IsLetterOrDigit(c) || extraAllowedChars.Contains(c); + } + + private static bool IsCompletionEndChar(char c) + { + const string endChars = ":"; + return endChars.Contains(c); + } + + /// + /// Gets a segment of the token the caret is currently in for completions. + /// Returns null if caret is not on a valid completion token (i.e. comments) + /// + private EditorCompletionRequest? GetCaretCompletionToken() + { + var caret = textEditor.CaretOffset; + + // Get the line the caret is on + var line = textEditor.Document.GetLineByOffset(caret); + var lineText = textEditor.Document.GetText(line.Offset, line.Length); + + var caretAbsoluteOffset = caret - line.Offset; + + // Tokenize + var result = TokenizerProvider!.TokenizeLine(lineText); + + var currentTokenIndex = -1; + IToken? currentToken = null; + // Get the token the caret is after + foreach (var (i, token) in result.Tokens.Enumerate()) + { + // If we see a line comment token anywhere, return null + var isComment = token.Scopes.Any(s => s.Contains("comment.line")); + if (isComment) + { + Logger.Trace("Caret is in a comment"); + return null; + } + + // Find match + if (caretAbsoluteOffset >= token.StartIndex && caretAbsoluteOffset <= token.EndIndex) + { + currentTokenIndex = i; + currentToken = token; + break; + } + } + + // Still not found + if (currentToken is null || currentTokenIndex == -1) + { + Logger.Info( + $"Could not find token at caret offset {caret} for line {lineText.ToRepr()}" + ); + return null; + } + + var startOffset = currentToken.StartIndex + line.Offset; + var endOffset = currentToken.EndIndex + line.Offset; + + // Cap the offsets by the line offsets + var segment = new TextSegment + { + StartOffset = Math.Max(startOffset, line.Offset), + EndOffset = Math.Min(endOffset, line.EndOffset) + }; + + // Check if this is an extra network request + if (currentToken.Scopes.Contains("meta.structure.network.prompt")) + { + // (case for initial '<') + // - Current token is "punctuation.definition.network.begin.prompt" + if (currentToken.Scopes.Contains("punctuation.definition.network.begin.prompt")) + { + // Offset the segment + var offsetSegment = new TextSegment + { + StartOffset = segment.StartOffset + 1, + EndOffset = segment.EndOffset + }; + return new EditorCompletionRequest + { + Text = "", + Segment = offsetSegment, + Type = CompletionType.ExtraNetworkType + }; + } + + // Next steps require a previous token + if (result.Tokens.ElementAtOrDefault(currentTokenIndex - 1) is not { } prevToken) + { + return null; + } + + // (case for initial ' PromptExtraNetworkType.Lora, + "lyco" => PromptExtraNetworkType.LyCORIS, + "embedding" => PromptExtraNetworkType.Embedding, + _ => null + }; + + if (networkTypeResult is not { } networkType) + { + return null; + } + + // Use offset segment to not replace the ':' + var offsetSegment = new TextSegment + { + StartOffset = segment.StartOffset + 1, + EndOffset = segment.EndOffset + }; + + return new EditorCompletionRequest + { + Text = "", + Segment = offsetSegment, + Type = CompletionType.ExtraNetwork, + ExtraNetworkTypes = networkType, + }; + } + + // (case for already in model token ' PromptExtraNetworkType.Lora, + "lyco" => PromptExtraNetworkType.LyCORIS, + "embedding" => PromptExtraNetworkType.Embedding, + _ => null + }; + + if (networkTypeResult is not { } networkType) + { + return null; + } + + return new EditorCompletionRequest + { + Text = textEditor.Document.GetText(segment), + Segment = segment, + Type = CompletionType.ExtraNetwork, + ExtraNetworkTypes = networkType, + }; + } + } + + // Otherwise treat as tag + return new EditorCompletionRequest + { + Text = textEditor.Document.GetText(segment), + Segment = segment, + Type = CompletionType.Tag + }; + } +} diff --git a/StabilityMatrix.Avalonia/Behaviors/TextEditorToolTipBehavior.cs b/StabilityMatrix.Avalonia/Behaviors/TextEditorToolTipBehavior.cs new file mode 100644 index 000000000..6c1411a17 --- /dev/null +++ b/StabilityMatrix.Avalonia/Behaviors/TextEditorToolTipBehavior.cs @@ -0,0 +1,255 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Xaml.Interactivity; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using NLog; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Core.Extensions; +using TextMateSharp.Grammars; + +namespace StabilityMatrix.Avalonia.Behaviors; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class TextEditorToolTipBehavior : Behavior +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private TextEditor textEditor = null!; + + /// + /// The current ToolTip, if open. + /// Is set to null when the Tooltip is closed. + /// + private ToolTip? toolTip; + + public static readonly StyledProperty TokenizerProviderProperty = + AvaloniaProperty.Register( + "TokenizerProvider" + ); + + public ITokenizerProvider? TokenizerProvider + { + get => GetValue(TokenizerProviderProperty); + set => SetValue(TokenizerProviderProperty, value); + } + + public static readonly StyledProperty IsEnabledProperty = AvaloniaProperty.Register< + TextEditorCompletionBehavior, + bool + >("IsEnabled", true); + + public bool IsEnabled + { + get => GetValue(IsEnabledProperty); + set => SetValue(IsEnabledProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + + if (AssociatedObject is not { } editor) + { + throw new NullReferenceException("AssociatedObject is null"); + } + + textEditor = editor; + textEditor.PointerHover += TextEditor_OnPointerHover; + textEditor.PointerHoverStopped += TextEditor_OnPointerHoverStopped; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + + textEditor.PointerHover -= TextEditor_OnPointerHover; + textEditor.PointerHoverStopped -= TextEditor_OnPointerHoverStopped; + } + + /*private void OnVisualLinesChanged(object? sender, EventArgs e) + { + _toolTip?.Close(this); + }*/ + + private void TextEditor_OnPointerHoverStopped(object? sender, PointerEventArgs e) + { + if (!IsEnabled) + return; + + if (sender is TextEditor editor) + { + ToolTip.SetIsOpen(editor, false); + e.Handled = true; + } + } + + private void TextEditor_OnPointerHover(object? sender, PointerEventArgs e) + { + if (!IsEnabled) + return; + + TextViewPosition? position; + + var textArea = textEditor.TextArea; + + try + { + position = textArea.TextView.GetPositionFloor( + e.GetPosition(textArea.TextView) + textArea.TextView.ScrollOffset + ); + } + catch (ArgumentOutOfRangeException) + { + // TODO: check why this happens + e.Handled = true; + return; + } + + if (!position.HasValue || position.Value.Location.IsEmpty || position.Value.IsAtEndOfLine) + { + return; + } + + /*var args = new ToolTipRequestEventArgs { InDocument = position.HasValue }; + + args.LogicalPosition = position.Value.Location; + args.Position = textEditor.Document.GetOffset(position.Value.Line, position.Value.Column);*/ + + // Get the ToolTip data + if (GetCaretToolTipData(position.Value) is not { } data) + { + return; + } + + if (toolTip == null) + { + toolTip = new ToolTip { MaxWidth = 400 }; + + ToolTip.SetShowDelay(textEditor, 0); + ToolTip.SetPlacement(textEditor, PlacementMode.Pointer); + ToolTip.SetTip(textEditor, toolTip); + + toolTip + .GetPropertyChangedObservable(ToolTip.IsOpenProperty) + .Subscribe(c => + { + if (c.NewValue as bool? != true) + { + toolTip = null; + } + }); + } + + toolTip.Content = new TextBlock { Text = data.Message, TextWrapping = TextWrapping.Wrap }; + + e.Handled = true; + ToolTip.SetIsOpen(textEditor, true); + toolTip.InvalidateVisual(); + } + + /// + /// Get ToolTip data to show at the caret position, can be null if no ToolTip should be shown. + /// + private ToolTipData? GetCaretToolTipData(TextViewPosition position) + { + var logicalPosition = position.Location; + var pointerOffset = textEditor.Document.GetOffset( + logicalPosition.Line, + logicalPosition.Column + ); + + var line = textEditor.Document.GetLineByOffset(pointerOffset); + var lineText = textEditor.Document.GetText(line.Offset, line.Length); + + var lineOffset = pointerOffset - line.Offset; + /*var caret = textEditor.CaretOffset; + + // Get the line the caret is on + var line = textEditor.Document.GetLineByOffset(caret); + var lineText = textEditor.Document.GetText(line.Offset, line.Length); + + var caretAbsoluteOffset = caret - line.Offset;*/ + + // Tokenize + var result = TokenizerProvider!.TokenizeLine(lineText); + + var currentTokenIndex = -1; + IToken? currentToken = null; + // Get the token the caret is after + foreach (var (i, token) in result.Tokens.Enumerate()) + { + // If we see a line comment token anywhere, return null + var isComment = token.Scopes.Any(s => s.Contains("comment.line")); + if (isComment) + { + Logger.Trace("Caret is in a comment"); + return null; + } + + // Find match + if (lineOffset >= token.StartIndex && lineOffset <= token.EndIndex) + { + currentTokenIndex = i; + currentToken = token; + break; + } + } + + // Still not found + if (currentToken is null || currentTokenIndex == -1) + { + Logger.Info( + $"Could not find token at pointer offset {pointerOffset} for line {lineText.ToRepr()}" + ); + return null; + } + + var startOffset = currentToken.StartIndex + line.Offset; + var endOffset = currentToken.EndIndex + line.Offset; + + // Cap the offsets by the line offsets + var segment = new TextSegment + { + StartOffset = Math.Max(startOffset, line.Offset), + EndOffset = Math.Min(endOffset, line.EndOffset) + }; + + // Only return for supported scopes + // Attempt with first current, then next and previous + foreach (var tokenOffset in new[] { 0, 1, -1 }) + { + if (result.Tokens.ElementAtOrDefault(currentTokenIndex + tokenOffset) is { } token) + { + // Check supported scopes + if ( + token.Scopes.Where(s => s.Contains("invalid")).ToArray() is + { Length: > 0 } results + ) + { + // Special cases + if (results.Contains("invalid.illegal.mismatched.parenthesis.closing.prompt")) + { + return new ToolTipData(segment, "Mismatched closing parenthesis ')'"); + } + if (results.Contains("invalid.illegal.mismatched.parenthesis.opening.prompt")) + { + return new ToolTipData(segment, "Mismatched opening parenthesis '('"); + } + + return new ToolTipData(segment, "Syntax error: " + string.Join(", ", results)); + } + } + } + + return null; + } + + internal record ToolTipData(ISegment Segment, string Message); +} diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml b/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml new file mode 100644 index 000000000..667a1e22f --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml @@ -0,0 +1,61 @@ + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml.cs b/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml.cs new file mode 100644 index 000000000..8d8f7e723 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBox.axaml.cs @@ -0,0 +1,2761 @@ +/* + * The MIT License (MIT) + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + */ +// Port from: https://github.com/cyotek/Cyotek.Windows.Forms.ImageBox to AvaloniaUI +// Modified from: https://github.com/sn4k3/UVtools/blob/master/UVtools.AvaloniaControls/AdvancedImageBox.axaml.cs + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.IO; +using System.Runtime.CompilerServices; +using AsyncAwaitBestPractices; +using AsyncImageLoader; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using Bitmap = Avalonia.Media.Imaging.Bitmap; +using Brushes = Avalonia.Media.Brushes; +using Color = Avalonia.Media.Color; +using Pen = Avalonia.Media.Pen; +using Point = Avalonia.Point; +using Size = Avalonia.Size; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class AdvancedImageBox : TemplatedControl +{ + #region Bindable Base + /// + /// Multicast event for property change notifications. + /// + private PropertyChangedEventHandler? _propertyChanged; + + public new event PropertyChangedEventHandler PropertyChanged + { + add => _propertyChanged += value; + remove => _propertyChanged -= value; + } + + protected bool RaiseAndSetIfChanged( + ref T field, + T value, + [CallerMemberName] string? propertyName = null + ) + { + if (EqualityComparer.Default.Equals(field, value)) + return false; + field = value; + RaisePropertyChanged(propertyName); + return true; + } + + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) { } + + /// + /// Notifies listeners that a property value has changed. + /// + /// + /// Name of the property used to notify listeners. This + /// value is optional and can be provided automatically when invoked from compilers + /// that support . + /// + protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null) + { + var e = new PropertyChangedEventArgs(propertyName); + OnPropertyChanged(e); + _propertyChanged?.Invoke(this, e); + } + #endregion + + #region Sub Classes + + /// + /// Represents available levels of zoom in an control + /// + public class ZoomLevelCollection : IList + { + #region Public Constructors + + /// + /// Initializes a new instance of the class. + /// + public ZoomLevelCollection() + { + List = new SortedList(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The default values to populate the collection with. + /// Thrown if the collection parameter is null + public ZoomLevelCollection(IEnumerable collection) + : this() + { + if (collection == null) + { + throw new ArgumentNullException(nameof(collection)); + } + + AddRange(collection); + } + + #endregion + + #region Public Class Properties + + /// + /// Returns the default zoom levels + /// + public static ZoomLevelCollection Default => + new( + new[] + { + 7, + 10, + 13, + 16, + 20, + 24, + // 10 increments + 30, + 40, + 50, + 60, + 70, + // 100 increments + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + // 400 increments + 1200, + 1600, + 2000, + 2400, + // 800 increments + 3200, + 4000, + 4800, + // 1000 increments + 5800, + 6800, + 7800, + 8800 + } + ); + + #endregion + + #region Public Properties + + /// + /// Gets the number of elements contained in the . + /// + /// + /// The number of elements contained in the . + /// + public int Count => List.Count; + + /// + /// Gets a value indicating whether the is read-only. + /// + /// true if this instance is read only; otherwise, false. + /// true if the is read-only; otherwise, false. + /// + public bool IsReadOnly => false; + + /// + /// Gets or sets the zoom level at the specified index. + /// + /// The index. + public int this[int index] + { + get => List.Values[index]; + set + { + List.RemoveAt(index); + Add(value); + } + } + + #endregion + + #region Protected Properties + + /// + /// Gets or sets the backing list. + /// + protected SortedList List { get; set; } + + #endregion + + #region Public Members + + /// + /// Adds an item to the . + /// + /// The object to add to the . + public void Add(int item) + { + List.Add(item, item); + } + + /// + /// Adds a range of items to the . + /// + /// The items to add to the collection. + /// Thrown if the collection parameter is null. + public void AddRange(IEnumerable collection) + { + if (collection == null) + { + throw new ArgumentNullException(nameof(collection)); + } + + foreach (var value in collection) + { + Add(value); + } + } + + /// + /// Removes all items from the . + /// + public void Clear() + { + List.Clear(); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// true if is found in the ; otherwise, false. + public bool Contains(int item) + { + return List.ContainsKey(item); + } + + /// + /// Copies a range of elements this collection into a destination . + /// + /// The that receives the data. + /// A 64-bit integer that represents the index in the at which storing begins. + public void CopyTo(int[] array, int arrayIndex) + { + for (var i = 0; i < Count; i++) + { + array[arrayIndex + i] = List.Values[i]; + } + } + + /// + /// Finds the index of a zoom level matching or nearest to the specified value. + /// + /// The zoom level. + public int FindNearest(int zoomLevel) + { + var nearestValue = List.Values[0]; + var nearestDifference = Math.Abs(nearestValue - zoomLevel); + for (var i = 1; i < Count; i++) + { + var value = List.Values[i]; + var difference = Math.Abs(value - zoomLevel); + if (difference < nearestDifference) + { + nearestValue = value; + nearestDifference = difference; + } + } + return nearestValue; + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// A that can be used to iterate through the collection. + public IEnumerator GetEnumerator() + { + return List.Values.GetEnumerator(); + } + + /// + /// Determines the index of a specific item in the . + /// + /// The object to locate in the . + /// The index of if found in the list; otherwise, -1. + public int IndexOf(int item) + { + return List.IndexOfKey(item); + } + + /// + /// Not implemented. + /// + /// The index. + /// The item. + /// Not implemented + public void Insert(int index, int item) + { + throw new NotImplementedException(); + } + + /// + /// Returns the next increased zoom level for the given current zoom. + /// + /// The current zoom level. + /// When positive, constrain maximum zoom to this value + /// The next matching increased zoom level for the given current zoom if applicable, otherwise the nearest zoom. + public int NextZoom(int zoomLevel, int constrainZoomLevel = 0) + { + var index = IndexOf(FindNearest(zoomLevel)); + if (index < Count - 1) + index++; + + return constrainZoomLevel > 0 && this[index] >= constrainZoomLevel + ? constrainZoomLevel + : this[index]; + } + + /// + /// Returns the next decreased zoom level for the given current zoom. + /// + /// The current zoom level. + /// When positive, constrain minimum zoom to this value + /// The next matching decreased zoom level for the given current zoom if applicable, otherwise the nearest zoom. + public int PreviousZoom(int zoomLevel, int constrainZoomLevel = 0) + { + var index = IndexOf(FindNearest(zoomLevel)); + if (index > 0) + index--; + + return constrainZoomLevel > 0 && this[index] <= constrainZoomLevel + ? constrainZoomLevel + : this[index]; + } + + /// + /// Removes the first occurrence of a specific object from the . + /// + /// The object to remove from the . + /// true if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . + public bool Remove(int item) + { + return List.Remove(item); + } + + /// + /// Removes the element at the specified index of the . + /// + /// The zero-based index of the element to remove. + public void RemoveAt(int index) + { + List.RemoveAt(index); + } + + /// + /// Copies the elements of the to a new array. + /// + /// An array containing copies of the elements of the . + public int[] ToArray() + { + var results = new int[Count]; + CopyTo(results, 0); + + return results; + } + + #endregion + + #region IList Members + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + #endregion + } + + #endregion + + #region Enums + + /// + /// Determines the sizing mode of an image hosted in an control. + /// + public enum SizeModes : byte + { + /// + /// The image is displayed according to current zoom and scroll properties. + /// + Normal, + + /// + /// The image is stretched to fill the client area of the control. + /// + Stretch, + + /// + /// The image is stretched to fill as much of the client area of the control as possible, whilst retaining the same aspect ratio for the width and height. + /// + Fit + } + + [Flags] + public enum MouseButtons : byte + { + None = 0, + LeftButton = 1, + MiddleButton = 2, + RightButton = 4 + } + + /// + /// Describes the zoom action occurring + /// + [Flags] + public enum ZoomActions : byte + { + /// + /// No action. + /// + None = 0, + + /// + /// The control is increasing the zoom. + /// + ZoomIn = 1, + + /// + /// The control is decreasing the zoom. + /// + ZoomOut = 2, + + /// + /// The control zoom was reset. + /// + ActualSize = 4 + } + + public enum SelectionModes + { + /// + /// No selection. + /// + None, + + /// + /// Rectangle selection. + /// + Rectangle, + + /// + /// Zoom selection. + /// + Zoom + } + + #endregion + + #region UI Controls + + [NotNull] + public ScrollBar? HorizontalScrollBar { get; private set; } + + [NotNull] + public ScrollBar? VerticalScrollBar { get; private set; } + + [NotNull] + public ContentPresenter? ViewPort { get; private set; } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + public bool IsViewPortLoaded => ViewPort is not null; + + public Vector Offset + { + get => new(HorizontalScrollBar.Value, VerticalScrollBar.Value); + set + { + HorizontalScrollBar.Value = value.X; + VerticalScrollBar.Value = value.Y; + RaisePropertyChanged(); + TriggerRender(); + } + } + + public Size ViewPortSize => ViewPort.Bounds.Size; + #endregion + + #region Private Members + private Point _startMousePosition; + private Vector _startScrollPosition; + private bool _isPanning; + private bool _isSelecting; + private Bitmap? _trackerImage; + private bool _canRender = true; + private Point _pointerPosition; + #endregion + + #region Properties + public static readonly DirectProperty CanRenderProperty = + AvaloniaProperty.RegisterDirect( + nameof(CanRender), + o => o.CanRender + ); + + /// + /// Gets or sets if control can render the image + /// + public bool CanRender + { + get => _canRender; + set + { + if (!SetAndRaise(CanRenderProperty, ref _canRender, value)) + return; + if (_canRender) + TriggerRender(); + } + } + + public static readonly StyledProperty GridCellSizeProperty = AvaloniaProperty.Register< + AdvancedImageBox, + byte + >(nameof(GridCellSize), 15); + + /// + /// Gets or sets the grid cell size + /// + public byte GridCellSize + { + get => GetValue(GridCellSizeProperty); + set => SetValue(GridCellSizeProperty, value); + } + + public static readonly StyledProperty GridColorProperty = + AvaloniaProperty.Register( + nameof(GridColor), + SolidColorBrush.Parse("#181818") + ); + + /// + /// Gets or sets the color used to create the checkerboard style background + /// + public ISolidColorBrush GridColor + { + get => GetValue(GridColorProperty); + set => SetValue(GridColorProperty, value); + } + + public static readonly StyledProperty GridColorAlternateProperty = + AvaloniaProperty.Register( + nameof(GridColorAlternate), + SolidColorBrush.Parse("#252525") + ); + + /// + /// Gets or sets the color used to create the checkerboard style background + /// + public ISolidColorBrush GridColorAlternate + { + get => GetValue(GridColorAlternateProperty); + set => SetValue(GridColorAlternateProperty, value); + } + + public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register< + AdvancedImageBox, + string? + >("Source"); + + public string? Source + { + get => GetValue(SourceProperty); + set + { + SetValue(SourceProperty, value); + // Also set the image + if (value is not null) + { + var loader = ImageLoader.AsyncImageLoader; + + Dispatcher.UIThread + .InvokeAsync(async () => + { + Image = await loader.ProvideImageAsync(value); + }) + .SafeFireAndForget(); + } + } + } + + public static readonly StyledProperty ImageProperty = AvaloniaProperty.Register< + AdvancedImageBox, + Bitmap? + >(nameof(Image)); + + /// + /// Gets or sets the image to be displayed + /// + public Bitmap? Image + { + get => GetValue(ImageProperty); + set => SetValue(ImageProperty, value); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ImageProperty) + { + var (oldImage, newImage) = change.GetOldAndNewValue(); + + Vector? offsetBackup = null; + + if (newImage is null) + { + SelectNone(); + } + else if (IsViewPortLoaded) + { + if (oldImage is null) + { + ZoomToFit(); + RestoreSizeMode(); + } + else if (newImage.Size != oldImage.Size) + { + offsetBackup = Offset; + + var zoomFactorScale = + (float)GetZoomLevelToFit(newImage) / GetZoomLevelToFit(oldImage); + var imageScale = newImage.Size / oldImage.Size; + + Debug.WriteLine($"Image scale: {imageScale}"); + + /*var oldScaledSize = oldImage.Size * ZoomFactor; + var newScaledSize = newImage.Size * ZoomFactor; + + Debug.WriteLine($"Old scaled {oldScaledSize} -> new scaled {newScaledSize}");*/ + + var currentZoom = Zoom; + var currentFactor = ZoomFactor; + // var currentOffset = Offset; + + // Scale zoom and offset to new size + Zoom = (int)Math.Floor(Zoom * zoomFactorScale); + /*Offset = new Vector( + Offset.X * imageScale.X, + Offset.Y * imageScale.Y + );*/ + + Debug.WriteLine($"Zoom changed from {currentZoom} to {Zoom}"); + Debug.WriteLine($"Zoom factor changed from {currentFactor} to {ZoomFactor}"); + } + + if (offsetBackup is not null) + { + Offset = offsetBackup.Value; + } + + UpdateViewPort(); + TriggerRender(); + } + + RaisePropertyChanged(nameof(IsImageLoaded)); + } + } + + /*public WriteableBitmap? ImageAsWriteableBitmap + { + get + { + if (Image is null) + return null; + return (WriteableBitmap)Image; + } + }*/ + + public bool IsImageLoaded => Image is not null; + + public static readonly DirectProperty TrackerImageProperty = + AvaloniaProperty.RegisterDirect( + nameof(TrackerImage), + o => o.TrackerImage, + (o, v) => o.TrackerImage = v + ); + + /// + /// Gets or sets an image to follow the mouse pointer + /// + public Bitmap? TrackerImage + { + get => _trackerImage; + set + { + if (!SetAndRaise(TrackerImageProperty, ref _trackerImage, value)) + return; + TriggerRender(); + RaisePropertyChanged(nameof(HaveTrackerImage)); + } + } + + public bool HaveTrackerImage => _trackerImage is not null; + + public static readonly StyledProperty TrackerImageAutoZoomProperty = + AvaloniaProperty.Register(nameof(TrackerImageAutoZoom), true); + + /// + /// Gets or sets if the tracker image will be scaled to the current zoom + /// + public bool TrackerImageAutoZoom + { + get => GetValue(TrackerImageAutoZoomProperty); + set => SetValue(TrackerImageAutoZoomProperty, value); + } + + public static readonly StyledProperty IsTrackerImageEnabledProperty = + AvaloniaProperty.Register("IsTrackerImageEnabled"); + + public bool IsTrackerImageEnabled + { + get => GetValue(IsTrackerImageEnabledProperty); + set => SetValue(IsTrackerImageEnabledProperty, value); + } + + public bool IsHorizontalBarVisible + { + get + { + if (Image is null) + return false; + if (SizeMode != SizeModes.Normal) + return false; + return ScaledImageWidth > ViewPortSize.Width; + } + } + + public bool IsVerticalBarVisible + { + get + { + if (Image is null) + return false; + if (SizeMode != SizeModes.Normal) + return false; + return ScaledImageHeight > ViewPortSize.Height; + } + } + + public static readonly StyledProperty ShowGridProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(ShowGrid), true); + + /// + /// Gets or sets the grid visibility when reach high zoom levels + /// + public bool ShowGrid + { + get => GetValue(ShowGridProperty); + set => SetValue(ShowGridProperty, value); + } + + public static readonly DirectProperty PointerPositionProperty = + AvaloniaProperty.RegisterDirect( + nameof(PointerPosition), + o => o.PointerPosition + ); + + /// + /// Gets the current pointer position + /// + public Point PointerPosition + { + get => _pointerPosition; + private set => SetAndRaise(PointerPositionProperty, ref _pointerPosition, value); + } + + public static readonly DirectProperty IsPanningProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsPanning), + o => o.IsPanning + ); + + /// + /// Gets if control is currently panning + /// + public bool IsPanning + { + get => _isPanning; + protected set + { + if (!SetAndRaise(IsPanningProperty, ref _isPanning, value)) + return; + _startScrollPosition = Offset; + + if (value) + { + Cursor = new Cursor(StandardCursorType.SizeAll); + //this.OnPanStart(EventArgs.Empty); + } + else + { + Cursor = Cursor.Default; + //this.OnPanEnd(EventArgs.Empty); + } + } + } + + public static readonly DirectProperty IsSelectingProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsSelecting), + o => o.IsSelecting + ); + + /// + /// Gets if control is currently selecting a ROI + /// + public bool IsSelecting + { + get => _isSelecting; + protected set => SetAndRaise(IsSelectingProperty, ref _isSelecting, value); + } + + /// + /// Gets the center point of the viewport + /// + public Point CenterPoint + { + get + { + var viewport = GetImageViewPort(); + return new(viewport.Width / 2, viewport.Height / 2); + } + } + + public static readonly StyledProperty AutoPanProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(AutoPan), true); + + /// + /// Gets or sets if the control can pan with the mouse + /// + public bool AutoPan + { + get => GetValue(AutoPanProperty); + set => SetValue(AutoPanProperty, value); + } + + public static readonly StyledProperty PanWithMouseButtonsProperty = + AvaloniaProperty.Register( + nameof(PanWithMouseButtons), + MouseButtons.LeftButton | MouseButtons.MiddleButton + ); + + /// + /// Gets or sets the mouse buttons to pan the image + /// + public MouseButtons PanWithMouseButtons + { + get => GetValue(PanWithMouseButtonsProperty); + set => SetValue(PanWithMouseButtonsProperty, value); + } + + public static readonly StyledProperty PanWithArrowsProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(PanWithArrows), true); + + /// + /// Gets or sets if the control can pan with the keyboard arrows + /// + public bool PanWithArrows + { + get => GetValue(PanWithArrowsProperty); + set => SetValue(PanWithArrowsProperty, value); + } + + public static readonly StyledProperty SelectWithMouseButtonsProperty = + AvaloniaProperty.Register( + nameof(SelectWithMouseButtons), + MouseButtons.LeftButton + ); + + /// + /// Gets or sets the mouse buttons to select a region on image + /// + public MouseButtons SelectWithMouseButtons + { + get => GetValue(SelectWithMouseButtonsProperty); + set => SetValue(SelectWithMouseButtonsProperty, value); + } + + public static readonly StyledProperty InvertMousePanProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(InvertMousePan)); + + /// + /// Gets or sets if mouse pan is inverted + /// + public bool InvertMousePan + { + get => GetValue(InvertMousePanProperty); + set => SetValue(InvertMousePanProperty, value); + } + + public static readonly StyledProperty AutoCenterProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(AutoCenter), true); + + /// + /// Gets or sets if image is auto centered + /// + public bool AutoCenter + { + get => GetValue(AutoCenterProperty); + set => SetValue(AutoCenterProperty, value); + } + + public static readonly StyledProperty SizeModeProperty = AvaloniaProperty.Register< + AdvancedImageBox, + SizeModes + >(nameof(SizeMode)); + + /// + /// Gets or sets the image size mode + /// + public SizeModes SizeMode + { + get => GetValue(SizeModeProperty); + set + { + SetValue(SizeModeProperty, value); + + // Run changed if loaded + if (IsViewPortLoaded) + { + SizeModeChanged(); + } + + RaisePropertyChanged(nameof(IsHorizontalBarVisible)); + RaisePropertyChanged(nameof(IsVerticalBarVisible)); + } + } + + private void SizeModeChanged() + { + switch (SizeMode) + { + case SizeModes.Normal: + HorizontalScrollBar.Visibility = ScrollBarVisibility.Auto; + VerticalScrollBar.Visibility = ScrollBarVisibility.Auto; + break; + case SizeModes.Stretch: + case SizeModes.Fit: + HorizontalScrollBar.Visibility = ScrollBarVisibility.Hidden; + VerticalScrollBar.Visibility = ScrollBarVisibility.Hidden; + break; + default: + throw new ArgumentOutOfRangeException(nameof(SizeMode), SizeMode, null); + } + } + + public static readonly StyledProperty AllowZoomProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >(nameof(AllowZoom), true); + + /// + /// Gets or sets if zoom is allowed + /// + public bool AllowZoom + { + get => GetValue(AllowZoomProperty); + set => SetValue(AllowZoomProperty, value); + } + + public static readonly DirectProperty< + AdvancedImageBox, + ZoomLevelCollection + > ZoomLevelsProperty = AvaloniaProperty.RegisterDirect( + nameof(ZoomLevels), + o => o.ZoomLevels, + (o, v) => o.ZoomLevels = v + ); + + ZoomLevelCollection _zoomLevels = ZoomLevelCollection.Default; + + /// + /// Gets or sets the zoom levels. + /// + /// The zoom levels. + public ZoomLevelCollection ZoomLevels + { + get => _zoomLevels; + set => SetAndRaise(ZoomLevelsProperty, ref _zoomLevels, value); + } + + public static readonly StyledProperty MinZoomProperty = AvaloniaProperty.Register< + AdvancedImageBox, + int + >(nameof(MinZoom), 10); + + /// + /// Gets or sets the minimum possible zoom. + /// + /// The zoom. + public int MinZoom + { + get => GetValue(MinZoomProperty); + set => SetValue(MinZoomProperty, value); + } + + public static readonly StyledProperty MaxZoomProperty = AvaloniaProperty.Register< + AdvancedImageBox, + int + >(nameof(MaxZoom), 6400); + + /// + /// Gets or sets the maximum possible zoom. + /// + /// The zoom. + public int MaxZoom + { + get => GetValue(MaxZoomProperty); + set => SetValue(MaxZoomProperty, value); + } + + public static readonly StyledProperty ConstrainZoomOutToFitLevelProperty = + AvaloniaProperty.Register(nameof(ConstrainZoomOutToFitLevel), true); + + /// + /// Gets or sets if the zoom out should constrain to fit image as the lowest zoom level. + /// + public bool ConstrainZoomOutToFitLevel + { + get => GetValue(ConstrainZoomOutToFitLevelProperty); + set => SetValue(ConstrainZoomOutToFitLevelProperty, value); + } + + public static readonly DirectProperty OldZoomProperty = + AvaloniaProperty.RegisterDirect(nameof(OldZoom), o => o.OldZoom); + + private int _oldZoom = 100; + + /// + /// Gets the previous zoom value + /// + /// The zoom. + public int OldZoom + { + get => _oldZoom; + private set => SetAndRaise(OldZoomProperty, ref _oldZoom, value); + } + + public static readonly StyledProperty ZoomProperty = AvaloniaProperty.Register< + AdvancedImageBox, + int + >(nameof(Zoom), 100); + + /// + /// Gets or sets the zoom. + /// + /// The zoom. + public int Zoom + { + get => GetValue(ZoomProperty); + set + { + var minZoom = MinZoom; + if (ConstrainZoomOutToFitLevel) + minZoom = Math.Max(ZoomLevelToFit, minZoom); + var newZoom = Math.Clamp(value, minZoom, MaxZoom); + + var previousZoom = Zoom; + if (previousZoom == newZoom) + return; + OldZoom = previousZoom; + SetValue(ZoomProperty, newZoom); + + UpdateViewPort(); + TriggerRender(); + + RaisePropertyChanged(nameof(IsHorizontalBarVisible)); + RaisePropertyChanged(nameof(IsVerticalBarVisible)); + } + } + + /// + /// Gets if the image have zoom. + /// True if zoomed in or out + /// False if no zoom applied + /// + public bool IsActualSize => Zoom == 100; + + /// + /// Gets the zoom factor, the zoom / 100.0 + /// + public double ZoomFactor => Zoom / 100.0; + + /// + /// Gets the zoom to fit level which shows all the image + /// + public int ZoomLevelToFit => Image is null ? 100 : GetZoomLevelToFit(Image); + + private int GetZoomLevelToFit(IImage image) + { + double zoom; + double aspectRatio; + + if (image.Size.Width > image.Size.Height) + { + aspectRatio = ViewPortSize.Width / image.Size.Width; + zoom = aspectRatio * 100.0; + + if (ViewPortSize.Height < image.Size.Height * zoom / 100.0) + { + aspectRatio = ViewPortSize.Height / image.Size.Height; + zoom = aspectRatio * 100.0; + } + } + else + { + aspectRatio = ViewPortSize.Height / image.Size.Height; + zoom = aspectRatio * 100.0; + + if (ViewPortSize.Width < image.Size.Width * zoom / 100.0) + { + aspectRatio = ViewPortSize.Width / image.Size.Width; + zoom = aspectRatio * 100.0; + } + } + + return (int)zoom; + } + + /// + /// Gets the width of the scaled image. + /// + /// The width of the scaled image. + public double ScaledImageWidth => Image?.Size.Width * ZoomFactor ?? 0; + + /// + /// Gets the height of the scaled image. + /// + /// The height of the scaled image. + public double ScaledImageHeight => Image?.Size.Height * ZoomFactor ?? 0; + + public static readonly StyledProperty PixelGridColorProperty = + AvaloniaProperty.Register( + nameof(PixelGridColor), + Brushes.DimGray + ); + + /// + /// Gets or sets the color of the pixel grid. + /// + /// The color of the pixel grid. + public ISolidColorBrush PixelGridColor + { + get => GetValue(PixelGridColorProperty); + set => SetValue(PixelGridColorProperty, value); + } + + public static readonly StyledProperty PixelGridZoomThresholdProperty = + AvaloniaProperty.Register(nameof(PixelGridZoomThreshold), 13); + + /// + /// Minimum size of zoomed pixel's before the pixel grid will be drawn + /// + public int PixelGridZoomThreshold + { + get => GetValue(PixelGridZoomThresholdProperty); + set => SetValue(PixelGridZoomThresholdProperty, value); + } + + public static readonly StyledProperty SelectionModeProperty = + AvaloniaProperty.Register(nameof(SelectionMode)); + + public static readonly StyledProperty IsPixelGridEnabledProperty = + AvaloniaProperty.Register("IsPixelGridEnabled", true); + + /// + /// Whether or not to draw the pixel grid at the + /// + public bool IsPixelGridEnabled + { + get => GetValue(IsPixelGridEnabledProperty); + set => SetValue(IsPixelGridEnabledProperty, value); + } + + public SelectionModes SelectionMode + { + get => GetValue(SelectionModeProperty); + set => SetValue(SelectionModeProperty, value); + } + + public static readonly StyledProperty SelectionColorProperty = + AvaloniaProperty.Register( + nameof(SelectionColor), + new SolidColorBrush(new Color(127, 0, 128, 255)) + ); + + public ISolidColorBrush SelectionColor + { + get => GetValue(SelectionColorProperty); + set => SetValue(SelectionColorProperty, value); + } + + public static readonly StyledProperty SelectionRegionProperty = AvaloniaProperty.Register< + AdvancedImageBox, + Rect + >(nameof(SelectionRegion), EmptyRect); + + public Rect SelectionRegion + { + get => GetValue(SelectionRegionProperty); + set + { + SetValue(SelectionRegionProperty, value); + //if (!RaiseAndSetIfChanged(ref _selectionRegion, value)) return; + TriggerRender(); + RaisePropertyChanged(nameof(HaveSelection)); + RaisePropertyChanged(nameof(SelectionRegionNet)); + RaisePropertyChanged(nameof(SelectionPixelSize)); + } + } + + public Rectangle SelectionRegionNet + { + get + { + var rect = SelectionRegion; + return new Rectangle( + (int)Math.Ceiling(rect.X), + (int)Math.Ceiling(rect.Y), + (int)rect.Width, + (int)rect.Height + ); + } + } + + public PixelSize SelectionPixelSize + { + get + { + var rect = SelectionRegion; + return new PixelSize((int)rect.Width, (int)rect.Height); + } + } + + public bool HaveSelection => !IsRectEmpty(SelectionRegion); + + private BitmapInterpolationMode? _bitmapInterpolationMode; + + /// + /// Gets or sets the current Bitmap Interpolation Mode + /// + public BitmapInterpolationMode BitmapInterpolationMode + { + get => _bitmapInterpolationMode ??= RenderOptions.GetBitmapInterpolationMode(this); + set + { + if (_bitmapInterpolationMode == value) + return; + _bitmapInterpolationMode = value; + RenderOptions.SetBitmapInterpolationMode(this, value); + } + } + + #endregion + + #region Constructor + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + // FocusableProperty.OverrideDefaultValue(typeof(AdvancedImageBox), true); + AffectsRender(ShowGridProperty); + + HorizontalScrollBar = + e.NameScope.Find("PART_HorizontalScrollBar") + ?? throw new NullReferenceException(); + VerticalScrollBar = + e.NameScope.Find("PART_VerticalScrollBar") + ?? throw new NullReferenceException(); + ViewPort = + e.NameScope.Find("PART_ViewPort") + ?? throw new NullReferenceException(); + + SizeModeChanged(); + + HorizontalScrollBar.Scroll += ScrollBarOnScroll; + VerticalScrollBar.Scroll += ScrollBarOnScroll; + + // ViewPort.PointerPressed += ViewPortOnPointerPressed; + // ViewPort.PointerExited += ViewPortOnPointerExited; + // ViewPort.PointerMoved += ViewPortOnPointerMoved; + // ViewPort!.PointerWheelChanged += ViewPort_OnPointerWheelChanged; + } + + #endregion + + #region Render methods + public void TriggerRender(bool renderOnlyCursorTracker = false) + { + if (!_canRender) + return; + if (renderOnlyCursorTracker && _trackerImage is null) + return; + + var isHighZoom = ZoomFactor > PixelGridZoomThreshold; + + // If we're in high zoom, switch off bitmap interpolation mode + // Otherwise use high quality + BitmapInterpolationMode = isHighZoom + ? BitmapInterpolationMode.None + : BitmapInterpolationMode.HighQuality; + + InvalidateVisual(); + } + + private void RenderBackgroundGrid(DrawingContext context) + { + var size = GridCellSize; + + var square1Drawing = new GeometryDrawing + { + Brush = GridColorAlternate, + Geometry = new RectangleGeometry(new Rect(0.0, 0.0, size, size)) + }; + + var square2Drawing = new GeometryDrawing + { + Brush = GridColorAlternate, + Geometry = new RectangleGeometry(new Rect(size, size, size, size)) + }; + + var drawingGroup = new DrawingGroup { Children = { square1Drawing, square2Drawing } }; + + var tileBrush = new DrawingBrush(drawingGroup) + { + AlignmentX = AlignmentX.Left, + AlignmentY = AlignmentY.Top, + DestinationRect = new RelativeRect(new Size(2 * size, 2 * size), RelativeUnit.Absolute), + Stretch = Stretch.None, + TileMode = TileMode.Tile, + }; + + context.FillRectangle(GridColor, Bounds); + context.FillRectangle(tileBrush, Bounds); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var gridCellSize = GridCellSize; + + if (ShowGrid & gridCellSize > 0 && (!IsHorizontalBarVisible || !IsVerticalBarVisible)) + { + RenderBackgroundGrid(context); + } + + var zoomFactor = ZoomFactor; + + var shouldDrawPixelGrid = + IsPixelGridEnabled + && SizeMode == SizeModes.Normal + && zoomFactor > PixelGridZoomThreshold; + + // Draw Grid + /*var viewPortSize = ViewPortSize; + if (ShowGrid & gridCellSize > 0 && (!IsHorizontalBarVisible || !IsVerticalBarVisible)) + { + // draw the background + var gridColor = GridColor; + var altColor = GridColorAlternate; + var currentColor = gridColor; + for (var y = 0; y < viewPortSize.Height; y += gridCellSize) + { + var firstRowColor = currentColor; + + for (var x = 0; x < viewPortSize.Width; x += gridCellSize) + { + context.FillRectangle(currentColor, new Rect(x, y, gridCellSize, gridCellSize)); + currentColor = ReferenceEquals(currentColor, gridColor) ? altColor : gridColor; + } + + if (Equals(firstRowColor, currentColor)) + currentColor = ReferenceEquals(currentColor, gridColor) ? altColor : gridColor; + } + }*/ + /*else + { + context.FillRectangle(Background, new Rect(0, 0, Viewport.Width, Viewport.Height)); + }*/ + + var image = Image; + if (image is null) + return; + var imageViewPort = GetImageViewPort(); + + // Draw iamge + context.DrawImage(image, GetSourceImageRegion(), imageViewPort); + + if (HaveTrackerImage && _pointerPosition is { X: >= 0, Y: >= 0 }) + { + var destSize = TrackerImageAutoZoom + ? new Size( + _trackerImage!.Size.Width * zoomFactor, + _trackerImage.Size.Height * zoomFactor + ) + : image.Size; + + var destPos = new Point( + _pointerPosition.X - destSize.Width / 2, + _pointerPosition.Y - destSize.Height / 2 + ); + context.DrawImage(_trackerImage!, new Rect(destPos, destSize)); + } + + //SkiaContext.SkCanvas.dr + // Draw pixel grid + if (shouldDrawPixelGrid) + { + var offsetX = Offset.X % zoomFactor; + var offsetY = Offset.Y % zoomFactor; + + Pen pen = new(PixelGridColor); + for ( + var x = imageViewPort.X + zoomFactor - offsetX; + x < imageViewPort.Right; + x += zoomFactor + ) + { + context.DrawLine( + pen, + new Point(x, imageViewPort.X), + new Point(x, imageViewPort.Bottom) + ); + } + + for ( + var y = imageViewPort.Y + zoomFactor - offsetY; + y < imageViewPort.Bottom; + y += zoomFactor + ) + { + context.DrawLine( + pen, + new Point(imageViewPort.Y, y), + new Point(imageViewPort.Right, y) + ); + } + + context.DrawRectangle(pen, imageViewPort); + } + + if (!IsRectEmpty(SelectionRegion)) + { + var rect = GetOffsetRectangle(SelectionRegion); + var selectionColor = SelectionColor; + context.FillRectangle(selectionColor, rect); + var color = Color.FromArgb( + 255, + selectionColor.Color.R, + selectionColor.Color.G, + selectionColor.Color.B + ); + context.DrawRectangle(new Pen(color.ToUInt32()), rect); + } + } + + private bool UpdateViewPort() + { + if (Image is null) + { + HorizontalScrollBar.Maximum = 0; + VerticalScrollBar.Maximum = 0; + return true; + } + + var scaledImageWidth = ScaledImageWidth; + var scaledImageHeight = ScaledImageHeight; + var width = scaledImageWidth - HorizontalScrollBar.ViewportSize; + var height = scaledImageHeight - VerticalScrollBar.ViewportSize; + //var width = scaledImageWidth <= Viewport.Width ? Viewport.Width : scaledImageWidth; + //var height = scaledImageHeight <= Viewport.Height ? Viewport.Height : scaledImageHeight; + + var changed = false; + if (Math.Abs(HorizontalScrollBar.Maximum - width) > 0.01) + { + HorizontalScrollBar.Maximum = width; + changed = true; + } + + if (Math.Abs(VerticalScrollBar.Maximum - scaledImageHeight) > 0.01) + { + VerticalScrollBar.Maximum = height; + changed = true; + } + + /*if (changed) + { + var newContainer = new ContentControl + { + Width = width, + Height = height + }; + FillContainer.Content = SizedContainer = newContainer; + Debug.WriteLine($"Updated ViewPort: {DateTime.Now.Ticks}"); + //TriggerRender(); + }*/ + + return changed; + } + #endregion + + #region Events and Overrides + + private void ScrollBarOnScroll(object? sender, ScrollEventArgs e) + { + TriggerRender(); + } + + /*protected override void OnScrollChanged(ScrollChangedEventArgs e) + { + Debug.WriteLine($"ViewportDelta: {e.ViewportDelta} | OffsetDelta: {e.OffsetDelta} | ExtentDelta: {e.ExtentDelta}"); + if (!e.ViewportDelta.IsDefault) + { + UpdateViewPort(); + } + + TriggerRender(); + + base.OnScrollChanged(e); + }*/ + + /// + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + + e.PreventGestureRecognition(); + e.Handled = true; + + if (Image is null) + return; + + if (AllowZoom && SizeMode == SizeModes.Normal) + { + // The MouseWheel event can contain multiple "spins" of the wheel so we need to adjust accordingly + //double spins = Math.Abs(e.Delta.Y); + //Debug.WriteLine(e.GetPosition(this)); + // TODO: Really should update the source method to handle multiple increments rather than calling it multiple times + /*for (int i = 0; i < spins; i++) + {*/ + ProcessMouseZoom(e.Delta.Y > 0, e.GetPosition(ViewPort)); + //} + } + } + + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (e.Handled || _isPanning || _isSelecting || Image is null) + return; + + var pointer = e.GetCurrentPoint(this); + + if (SelectionMode != SelectionModes.None) + { + if ( + !( + pointer.Properties.IsLeftButtonPressed + && (SelectWithMouseButtons & MouseButtons.LeftButton) != 0 + || pointer.Properties.IsMiddleButtonPressed + && (SelectWithMouseButtons & MouseButtons.MiddleButton) != 0 + || pointer.Properties.IsRightButtonPressed + && (SelectWithMouseButtons & MouseButtons.RightButton) != 0 + ) + ) + return; + IsSelecting = true; + } + else + { + if ( + !( + pointer.Properties.IsLeftButtonPressed + && (PanWithMouseButtons & MouseButtons.LeftButton) != 0 + || pointer.Properties.IsMiddleButtonPressed + && (PanWithMouseButtons & MouseButtons.MiddleButton) != 0 + || pointer.Properties.IsRightButtonPressed + && (PanWithMouseButtons & MouseButtons.RightButton) != 0 + ) + || !AutoPan + || SizeMode != SizeModes.Normal + ) + return; + + IsPanning = true; + } + + var location = pointer.Position; + + if (location.X > ViewPortSize.Width) + return; + if (location.Y > ViewPortSize.Height) + return; + _startMousePosition = location; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.Handled) + return; + + IsPanning = false; + IsSelecting = false; + } + + /// + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + + PointerPosition = new Point(-1, -1); + TriggerRender(true); + e.Handled = true; + } + + /*private void ViewPortOnPointerExited(object? sender, PointerEventArgs e) + { + PointerPosition = new Point(-1, -1); + TriggerRender(true); + e.Handled = true; + }*/ + + /*protected override void OnPointerLeave(PointerEventArgs e) + { + base.OnPointerLeave(e); + PointerPosition = new Point(-1, -1); + TriggerRender(true); + e.Handled = true; + }*/ + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (e.Handled) + return; + + var pointer = e.GetCurrentPoint(ViewPort); + PointerPosition = pointer.Position; + + if (!_isPanning && !_isSelecting) + { + TriggerRender(true); + return; + } + + if (_isPanning) + { + double x; + double y; + + if (!InvertMousePan) + { + x = _startScrollPosition.X + (_startMousePosition.X - _pointerPosition.X); + y = _startScrollPosition.Y + (_startMousePosition.Y - _pointerPosition.Y); + } + else + { + x = (_startScrollPosition.X - (_startMousePosition.X - _pointerPosition.X)); + y = (_startScrollPosition.Y - (_startMousePosition.Y - _pointerPosition.Y)); + } + + Offset = new Vector(x, y); + } + else if (_isSelecting) + { + var viewPortPoint = new Point( + Math.Min(_pointerPosition.X, ViewPort.Bounds.Right), + Math.Min(_pointerPosition.Y, ViewPort.Bounds.Bottom) + ); + + double x; + double y; + double w; + double h; + + var imageOffset = GetImageViewPort().Position; + + if (viewPortPoint.X < _startMousePosition.X) + { + x = viewPortPoint.X; + w = _startMousePosition.X - viewPortPoint.X; + } + else + { + x = _startMousePosition.X; + w = viewPortPoint.X - _startMousePosition.X; + } + + if (viewPortPoint.Y < _startMousePosition.Y) + { + y = viewPortPoint.Y; + h = _startMousePosition.Y - viewPortPoint.Y; + } + else + { + y = _startMousePosition.Y; + h = viewPortPoint.Y - _startMousePosition.Y; + } + + x -= imageOffset.X - Offset.X; + y -= imageOffset.Y - Offset.Y; + + var zoomFactor = ZoomFactor; + x /= zoomFactor; + y /= zoomFactor; + w /= zoomFactor; + h /= zoomFactor; + + if (w > 0 && h > 0) + { + SelectionRegion = FitRectangle(new Rect(x, y, w, h)); + } + } + + e.Handled = true; + } + + /*protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (e.Handled || !ViewPort.IsPointerOver) return; + + var pointer = e.GetCurrentPoint(ViewPort); + PointerPosition = pointer.Position; + + if (!_isPanning && !_isSelecting) + { + TriggerRender(true); + return; + } + + if (_isPanning) + { + double x; + double y; + + if (!InvertMousePan) + { + x = _startScrollPosition.X + (_startMousePosition.X - _pointerPosition.X); + y = _startScrollPosition.Y + (_startMousePosition.Y - _pointerPosition.Y); + } + else + { + x = (_startScrollPosition.X - (_startMousePosition.X - _pointerPosition.X)); + y = (_startScrollPosition.Y - (_startMousePosition.Y - _pointerPosition.Y)); + } + + Offset = new Vector(x, y); + } + else if (_isSelecting) + { + double x; + double y; + double w; + double h; + + var imageOffset = GetImageViewPort().Position; + + if (_pointerPosition.X < _startMousePosition.X) + { + x = _pointerPosition.X; + w = _startMousePosition.X - _pointerPosition.X; + } + else + { + x = _startMousePosition.X; + w = _pointerPosition.X - _startMousePosition.X; + } + + if (_pointerPosition.Y < _startMousePosition.Y) + { + y = _pointerPosition.Y; + h = _startMousePosition.Y - _pointerPosition.Y; + } + else + { + y = _startMousePosition.Y; + h = _pointerPosition.Y - _startMousePosition.Y; + } + + x -= imageOffset.X - Offset.X; + y -= imageOffset.Y - Offset.Y; + + var zoomFactor = ZoomFactor; + x /= zoomFactor; + y /= zoomFactor; + w /= zoomFactor; + h /= zoomFactor; + + if (w > 0 && h > 0) + { + + SelectionRegion = FitRectangle(new Rect(x, y, w, h)); + } + } + + e.Handled = true; + }*/ + + /// + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (SizeMode == SizeModes.Fit) + { + try + { + ZoomToFit(); + } + catch (Exception exception) + { + Debug.WriteLine(exception); + } + + try + { + RestoreSizeMode(); + } + catch (Exception exception) + { + Debug.WriteLine(exception); + } + } + } + + #endregion + + #region Zoom and Size modes + private void ProcessMouseZoom(bool isZoomIn, Point cursorPosition) => + PerformZoom(isZoomIn ? ZoomActions.ZoomIn : ZoomActions.ZoomOut, true, cursorPosition); + + /// + /// Returns an appropriate zoom level based on the specified action, relative to the current zoom level. + /// + /// The action to determine the zoom level. + /// Thrown if an unsupported action is specified. + private int GetZoomLevel(ZoomActions action) + { + var result = action switch + { + ZoomActions.None => Zoom, + ZoomActions.ZoomIn => _zoomLevels.NextZoom(Zoom), + ZoomActions.ZoomOut => _zoomLevels.PreviousZoom(Zoom), + ZoomActions.ActualSize => 100, + _ => throw new ArgumentOutOfRangeException(nameof(action), action, null), + }; + return result; + } + + /// + /// Resets the property whilsts retaining the original . + /// + protected void RestoreSizeMode() + { + if (SizeMode != SizeModes.Normal) + { + var previousZoom = Zoom; + SizeMode = SizeModes.Normal; + Zoom = previousZoom; // Stop the zoom getting reset to 100% before calculating the new zoom + } + } + + private void PerformZoom(ZoomActions action, bool preservePosition) => + PerformZoom(action, preservePosition, CenterPoint); + + private void PerformZoom(ZoomActions action, bool preservePosition, Point relativePoint) + { + var currentPixel = PointToImage(relativePoint); + var currentZoom = Zoom; + var newZoom = GetZoomLevel(action); + + /*if (preservePosition && Zoom != currentZoom) + CanRender = false;*/ + + RestoreSizeMode(); + Zoom = newZoom; + + if (preservePosition && Zoom != currentZoom) + { + ScrollTo(currentPixel, relativePoint); + } + } + + /// + /// Zooms into the image + /// + public void ZoomIn() => ZoomIn(true); + + /// + /// Zooms into the image + /// + /// true if the current scrolling position should be preserved relative to the new zoom level, false to reset. + public void ZoomIn(bool preservePosition) + { + PerformZoom(ZoomActions.ZoomIn, preservePosition); + } + + /// + /// Zooms out of the image + /// + public void ZoomOut() => ZoomOut(true); + + /// + /// Zooms out of the image + /// + /// true if the current scrolling position should be preserved relative to the new zoom level, false to reset. + public void ZoomOut(bool preservePosition) + { + PerformZoom(ZoomActions.ZoomOut, preservePosition); + } + + /// + /// Zooms to the maximum size for displaying the entire image within the bounds of the control. + /// + public void ZoomToFit() + { + if (!IsImageLoaded) + return; + Zoom = ZoomLevelToFit; + } + + /// + /// Adjusts the view port to fit the given region + /// + /// The X co-ordinate of the selection region. + /// The Y co-ordinate of the selection region. + /// The width of the selection region. + /// The height of the selection region. + /// Give a margin to rectangle by a value to zoom-out that pixel value + public void ZoomToRegion(double x, double y, double width, double height, double margin = 0) + { + ZoomToRegion(new Rect(x, y, width, height), margin); + } + + /// + /// Adjusts the view port to fit the given region + /// + /// The X co-ordinate of the selection region. + /// The Y co-ordinate of the selection region. + /// The width of the selection region. + /// The height of the selection region. + /// Give a margin to rectangle by a value to zoom-out that pixel value + public void ZoomToRegion(int x, int y, int width, int height, double margin = 0) + { + ZoomToRegion(new Rect(x, y, width, height), margin); + } + + /// + /// Adjusts the view port to fit the given region + /// + /// The rectangle to fit the view port to. + /// Give a margin to rectangle by a value to zoom-out that pixel value + public void ZoomToRegion(Rectangle rectangle, double margin = 0) => + ZoomToRegion(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, margin); + + /// + /// Adjusts the view port to fit the given region + /// + /// The rectangle to fit the view port to. + /// Give a margin to rectangle by a value to zoom-out that pixel value + public void ZoomToRegion(Rect rectangle, double margin = 0) + { + if (margin > 0) + rectangle = rectangle.Inflate(margin); + var ratioX = ViewPortSize.Width / rectangle.Width; + var ratioY = ViewPortSize.Height / rectangle.Height; + var zoomFactor = Math.Min(ratioX, ratioY); + var cx = rectangle.X + rectangle.Width / 2; + var cy = rectangle.Y + rectangle.Height / 2; + + CanRender = false; + Zoom = (int)(zoomFactor * 100); // This function sets the zoom so viewport will change + CenterAt(new Point(cx, cy)); // If i call this here, it will move to the wrong position due wrong viewport + } + + /// + /// Zooms to current selection region + /// + public void ZoomToSelectionRegion(double margin = 0) + { + if (!HaveSelection) + return; + ZoomToRegion(SelectionRegion, margin); + } + + /// + /// Resets the zoom to 100%. + /// + public void PerformActualSize() + { + SizeMode = SizeModes.Normal; + //SetZoom(100, ImageZoomActions.ActualSize | (Zoom < 100 ? ImageZoomActions.ZoomIn : ImageZoomActions.ZoomOut)); + Zoom = 100; + } + #endregion + + #region Utility methods + /// + /// Determines whether the specified rectangle is empty + /// + private static bool IsRectEmpty(Rect rect) + { + return rect == EmptyRect; + } + + /// + /// Static empty rectangle + /// + private static readonly Rect EmptyRect = new(); + + /// + /// Determines whether the specified point is located within the image view port + /// + /// The point. + /// + /// true if the specified point is located within the image view port; otherwise, false. + /// + public bool IsPointInImage(Point point) => GetImageViewPort().Contains(point); + + /// + /// Determines whether the specified point is located within the image view port + /// + /// The X co-ordinate of the point to check. + /// The Y co-ordinate of the point to check. + /// + /// true if the specified point is located within the image view port; otherwise, false. + /// + public bool IsPointInImage(int x, int y) => IsPointInImage(new Point(x, y)); + + /// + /// Determines whether the specified point is located within the image view port + /// + /// The X co-ordinate of the point to check. + /// The Y co-ordinate of the point to check. + /// + /// true if the specified point is located within the image view port; otherwise, false. + /// + public bool IsPointInImage(double x, double y) => IsPointInImage(new Point(x, y)); + + /// + /// Converts the given client size point to represent a coordinate on the source image. + /// + /// The X co-ordinate of the point to convert. + /// The Y co-ordinate of the point to convert. + /// + /// if set to true and the point is outside the bounds of the source image, it will be mapped to the nearest edge. + /// + /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point + public Point PointToImage(double x, double y, bool fitToBounds = true) => + PointToImage(new Point(x, y), fitToBounds); + + /// + /// Converts the given client size point to represent a coordinate on the source image. + /// + /// The X co-ordinate of the point to convert. + /// The Y co-ordinate of the point to convert. + /// + /// if set to true and the point is outside the bounds of the source image, it will be mapped to the nearest edge. + /// + /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point + public Point PointToImage(int x, int y, bool fitToBounds = true) + { + return PointToImage(new Point(x, y), fitToBounds); + } + + /// + /// Converts the given client size point to represent a coordinate on the source image. + /// + /// The source point. + /// + /// if set to true and the point is outside the bounds of the source image, it will be mapped to the nearest edge. + /// + /// Point.Empty if the point could not be matched to the source image, otherwise the new translated point + public Point PointToImage(Point point, bool fitToBounds = true) + { + double x; + double y; + + var viewport = GetImageViewPort(); + + if (!fitToBounds || viewport.Contains(point)) + { + x = (point.X + Offset.X - viewport.X) / ZoomFactor; + y = (point.Y + Offset.Y - viewport.Y) / ZoomFactor; + + var image = Image; + if (fitToBounds) + { + x = Math.Clamp(x, 0, image!.Size.Width - 1); + y = Math.Clamp(y, 0, image.Size.Height - 1); + } + } + else + { + x = 0; // Return Point.Empty if we couldn't match + y = 0; + } + + return new(x, y); + } + + /// + /// Returns the source repositioned to include the current image offset and scaled by the current zoom level + /// + /// The source to offset. + /// A which has been repositioned to match the current zoom level and image offset + public Point GetOffsetPoint(System.Drawing.Point source) + { + var offset = GetOffsetPoint(new Point(source.X, source.Y)); + + return new((int)offset.X, (int)offset.Y); + } + + /// + /// Returns the source co-ordinates repositioned to include the current image offset and scaled by the current zoom level + /// + /// The source X co-ordinate. + /// The source Y co-ordinate. + /// A which has been repositioned to match the current zoom level and image offset + public Point GetOffsetPoint(int x, int y) + { + return GetOffsetPoint(new System.Drawing.Point(x, y)); + } + + /// + /// Returns the source co-ordinates repositioned to include the current image offset and scaled by the current zoom level + /// + /// The source X co-ordinate. + /// The source Y co-ordinate. + /// A which has been repositioned to match the current zoom level and image offset + public Point GetOffsetPoint(double x, double y) + { + return GetOffsetPoint(new Point(x, y)); + } + + /// + /// Returns the source repositioned to include the current image offset and scaled by the current zoom level + /// + /// The source to offset. + /// A which has been repositioned to match the current zoom level and image offset + public Point GetOffsetPoint(Point source) + { + var viewport = GetImageViewPort(); + var scaled = GetScaledPoint(source); + var offsetX = viewport.Left + Offset.X; + var offsetY = viewport.Top + Offset.Y; + + return new(scaled.X + offsetX, scaled.Y + offsetY); + } + + /// + /// Returns the source scaled according to the current zoom level and repositioned to include the current image offset + /// + /// The source to offset. + /// A which has been resized and repositioned to match the current zoom level and image offset + public Rect GetOffsetRectangle(Rect source) + { + var viewport = GetImageViewPort(); + var scaled = GetScaledRectangle(source); + var offsetX = viewport.Left - Offset.X; + var offsetY = viewport.Top - Offset.Y; + + return new(new Point(scaled.Left + offsetX, scaled.Top + offsetY), scaled.Size); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level and repositioned to include the current image offset + /// + /// The X co-ordinate of the source rectangle. + /// The Y co-ordinate of the source rectangle. + /// The width of the rectangle. + /// The height of the rectangle. + /// A which has been resized and repositioned to match the current zoom level and image offset + public Rectangle GetOffsetRectangle(int x, int y, int width, int height) + { + return GetOffsetRectangle(new Rectangle(x, y, width, height)); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level and repositioned to include the current image offset + /// + /// The X co-ordinate of the source rectangle. + /// The Y co-ordinate of the source rectangle. + /// The width of the rectangle. + /// The height of the rectangle. + /// A which has been resized and repositioned to match the current zoom level and image offset + public Rect GetOffsetRectangle(double x, double y, double width, double height) + { + return GetOffsetRectangle(new Rect(x, y, width, height)); + } + + /// + /// Returns the source scaled according to the current zoom level and repositioned to include the current image offset + /// + /// The source to offset. + /// A which has been resized and repositioned to match the current zoom level and image offset + public Rectangle GetOffsetRectangle(Rectangle source) + { + var viewport = GetImageViewPort(); + var scaled = GetScaledRectangle(source); + var offsetX = viewport.Left + Offset.X; + var offsetY = viewport.Top + Offset.Y; + + return new( + new System.Drawing.Point((int)(scaled.Left + offsetX), (int)(scaled.Top + offsetY)), + new System.Drawing.Size((int)scaled.Size.Width, (int)scaled.Size.Height) + ); + } + + /// + /// Fits a given to match image boundaries + /// + /// The rectangle. + /// + /// A structure remapped to fit the image boundaries + /// + public Rectangle FitRectangle(Rectangle rectangle) + { + var image = Image; + if (image is null) + return Rectangle.Empty; + var x = rectangle.X; + var y = rectangle.Y; + var w = rectangle.Width; + var h = rectangle.Height; + + if (x < 0) + { + x = 0; + } + + if (y < 0) + { + y = 0; + } + + if (x + w > image.Size.Width) + { + w = (int)(image.Size.Width - x); + } + + if (y + h > image.Size.Height) + { + h = (int)(image.Size.Height - y); + } + + return new(x, y, w, h); + } + + /// + /// Fits a given to match image boundaries + /// + /// The rectangle. + /// + /// A structure remapped to fit the image boundaries + /// + public Rect FitRectangle(Rect rectangle) + { + var image = Image; + if (image is null) + return EmptyRect; + var x = rectangle.X; + var y = rectangle.Y; + var w = rectangle.Width; + var h = rectangle.Height; + + if (x < 0) + { + w -= -x; + x = 0; + } + + if (y < 0) + { + h -= -y; + y = 0; + } + + if (x + w > image.Size.Width) + { + w = image.Size.Width - x; + } + + if (y + h > image.Size.Height) + { + h = image.Size.Height - y; + } + + return new(x, y, w, h); + } + #endregion + + #region Navigate / Scroll methods + /// + /// Scrolls the control to the given point in the image, offset at the specified display point + /// + /// The X co-ordinate of the point to scroll to. + /// The Y co-ordinate of the point to scroll to. + /// The X co-ordinate relative to the x parameter. + /// The Y co-ordinate relative to the y parameter. + public void ScrollTo(double x, double y, double relativeX, double relativeY) => + ScrollTo(new Point(x, y), new Point(relativeX, relativeY)); + + /// + /// Scrolls the control to the given point in the image, offset at the specified display point + /// + /// The X co-ordinate of the point to scroll to. + /// The Y co-ordinate of the point to scroll to. + /// The X co-ordinate relative to the x parameter. + /// The Y co-ordinate relative to the y parameter. + public void ScrollTo(int x, int y, int relativeX, int relativeY) => + ScrollTo(new Point(x, y), new Point(relativeX, relativeY)); + + /// + /// Scrolls the control to the given point in the image, offset at the specified display point + /// + /// The point of the image to attempt to scroll to. + /// The relative display point to offset scrolling by. + public void ScrollTo(Point imageLocation, Point relativeDisplayPoint) + { + //CanRender = false; + var zoomFactor = ZoomFactor; + var x = imageLocation.X * zoomFactor - relativeDisplayPoint.X; + var y = imageLocation.Y * zoomFactor - relativeDisplayPoint.Y; + + _canRender = true; + Offset = new Vector(x, y); + + /*Debug.WriteLine( + $"X/Y: {x},{y} | \n" + + $"Offset: {Offset} | \n" + + $"ZoomFactor: {ZoomFactor} | \n" + + $"Image Location: {imageLocation}\n" + + $"MAX: {HorizontalScrollBar.Maximum},{VerticalScrollBar.Maximum} \n" + + $"ViewPort: {Viewport.Width},{Viewport.Height} \n" + + $"Container: {HorizontalScrollBar.ViewportSize},{VerticalScrollBar.ViewportSize} \n" + + $"Relative: {relativeDisplayPoint}");*/ + } + + /// + /// Centers the given point in the image in the center of the control + /// + /// The point of the image to attempt to center. + public void CenterAt(System.Drawing.Point imageLocation) => + ScrollTo( + new Point(imageLocation.X, imageLocation.Y), + new Point(ViewPortSize.Width / 2, ViewPortSize.Height / 2) + ); + + /// + /// Centers the given point in the image in the center of the control + /// + /// The point of the image to attempt to center. + public void CenterAt(Point imageLocation) => + ScrollTo(imageLocation, new Point(ViewPortSize.Width / 2, ViewPortSize.Height / 2)); + + /// + /// Centers the given point in the image in the center of the control + /// + /// The X co-ordinate of the point to center. + /// The Y co-ordinate of the point to center. + public void CenterAt(int x, int y) => CenterAt(new Point(x, y)); + + /// + /// Centers the given point in the image in the center of the control + /// + /// The X co-ordinate of the point to center. + /// The Y co-ordinate of the point to center. + public void CenterAt(double x, double y) => CenterAt(new Point(x, y)); + + /// + /// Resets the viewport to show the center of the image. + /// + public void CenterToImage() + { + Offset = new Vector(HorizontalScrollBar.Maximum / 2, VerticalScrollBar.Maximum / 2); + } + #endregion + + #region Selection / ROI methods + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The X co-ordinate of the point to scale. + /// The Y co-ordinate of the point to scale. + /// A which has been scaled to match the current zoom level + public Point GetScaledPoint(int x, int y) + { + return GetScaledPoint(new Point(x, y)); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The X co-ordinate of the point to scale. + /// The Y co-ordinate of the point to scale. + /// A which has been scaled to match the current zoom level + public PointF GetScaledPoint(float x, float y) + { + return GetScaledPoint(new PointF(x, y)); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been scaled to match the current zoom level + public Point GetScaledPoint(Point source) + { + return new(source.X * ZoomFactor, source.Y * ZoomFactor); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been scaled to match the current zoom level + public PointF GetScaledPoint(PointF source) + { + return new((float)(source.X * ZoomFactor), (float)(source.Y * ZoomFactor)); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level + /// + /// The X co-ordinate of the source rectangle. + /// The Y co-ordinate of the source rectangle. + /// The width of the rectangle. + /// The height of the rectangle. + /// A which has been scaled to match the current zoom level + public Rect GetScaledRectangle(int x, int y, int width, int height) + { + return GetScaledRectangle(new Rect(x, y, width, height)); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level + /// + /// The X co-ordinate of the source rectangle. + /// The Y co-ordinate of the source rectangle. + /// The width of the rectangle. + /// The height of the rectangle. + /// A which has been scaled to match the current zoom level + public RectangleF GetScaledRectangle(float x, float y, float width, float height) + { + return GetScaledRectangle(new RectangleF(x, y, width, height)); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level + /// + /// The location of the source rectangle. + /// The size of the source rectangle. + /// A which has been scaled to match the current zoom level + public Rect GetScaledRectangle(Point location, Size size) + { + return GetScaledRectangle(new Rect(location, size)); + } + + /// + /// Returns the source rectangle scaled according to the current zoom level + /// + /// The location of the source rectangle. + /// The size of the source rectangle. + /// A which has been scaled to match the current zoom level + public RectangleF GetScaledRectangle(PointF location, SizeF size) + { + return GetScaledRectangle(new RectangleF(location, size)); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been scaled to match the current zoom level + public Rect GetScaledRectangle(Rect source) + { + return new( + source.Left * ZoomFactor, + source.Top * ZoomFactor, + source.Width * ZoomFactor, + source.Height * ZoomFactor + ); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been scaled to match the current zoom level + public RectangleF GetScaledRectangle(RectangleF source) + { + return new( + (float)(source.Left * ZoomFactor), + (float)(source.Top * ZoomFactor), + (float)(source.Width * ZoomFactor), + (float)(source.Height * ZoomFactor) + ); + } + + /// + /// Returns the source size scaled according to the current zoom level + /// + /// The width of the size to scale. + /// The height of the size to scale. + /// A which has been resized to match the current zoom level + public SizeF GetScaledSize(float width, float height) + { + return GetScaledSize(new SizeF(width, height)); + } + + /// + /// Returns the source size scaled according to the current zoom level + /// + /// The width of the size to scale. + /// The height of the size to scale. + /// A which has been resized to match the current zoom level + public Size GetScaledSize(int width, int height) + { + return GetScaledSize(new Size(width, height)); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been resized to match the current zoom level + public SizeF GetScaledSize(SizeF source) + { + return new((float)(source.Width * ZoomFactor), (float)(source.Height * ZoomFactor)); + } + + /// + /// Returns the source scaled according to the current zoom level + /// + /// The source to scale. + /// A which has been resized to match the current zoom level + public Size GetScaledSize(Size source) + { + return new(source.Width * ZoomFactor, source.Height * ZoomFactor); + } + + /// + /// Creates a selection region which encompasses the entire image + /// + /// Thrown if no image is currently set + public void SelectAll() + { + var image = Image; + if (image is null) + return; + SelectionRegion = new Rect(0, 0, image.Size.Width, image.Size.Height); + } + + /// + /// Clears any existing selection region + /// + public void SelectNone() + { + SelectionRegion = EmptyRect; + } + + #endregion + + #region Viewport and image region methods + /// + /// Gets the source image region. + /// + /// + public Rect GetSourceImageRegion() + { + var image = Image; + if (image is null) + return EmptyRect; + + switch (SizeMode) + { + case SizeModes.Normal: + var offset = Offset; + var viewPort = GetImageViewPort(); + var zoomFactor = ZoomFactor; + var sourceLeft = offset.X / zoomFactor; + var sourceTop = offset.Y / zoomFactor; + var sourceWidth = viewPort.Width / zoomFactor; + var sourceHeight = viewPort.Height / zoomFactor; + + return new Rect(sourceLeft, sourceTop, sourceWidth, sourceHeight); + } + + return new Rect(0, 0, image.Size.Width, image.Size.Height); + } + + /// + /// Gets the image view port. + /// + /// + public Rect GetImageViewPort() + { + var viewPortSize = ViewPortSize; + if (!IsImageLoaded || viewPortSize is { Width: 0, Height: 0 }) + return EmptyRect; + + double xOffset = 0; + double yOffset = 0; + double width; + double height; + + switch (SizeMode) + { + case SizeModes.Normal: + if (AutoCenter) + { + xOffset = ( + !IsHorizontalBarVisible ? (viewPortSize.Width - ScaledImageWidth) / 2 : 0 + ); + yOffset = ( + !IsVerticalBarVisible ? (viewPortSize.Height - ScaledImageHeight) / 2 : 0 + ); + } + + width = Math.Min(ScaledImageWidth - Math.Abs(Offset.X), viewPortSize.Width); + height = Math.Min(ScaledImageHeight - Math.Abs(Offset.Y), viewPortSize.Height); + break; + case SizeModes.Stretch: + width = viewPortSize.Width; + height = viewPortSize.Height; + break; + case SizeModes.Fit: + var image = Image; + var scaleFactor = Math.Min( + viewPortSize.Width / image!.Size.Width, + viewPortSize.Height / image.Size.Height + ); + + width = Math.Floor(image.Size.Width * scaleFactor); + height = Math.Floor(image.Size.Height * scaleFactor); + + if (AutoCenter) + { + xOffset = (viewPortSize.Width - width) / 2; + yOffset = (viewPortSize.Height - height) / 2; + } + + break; + default: + throw new ArgumentOutOfRangeException(nameof(SizeMode), SizeMode, null); + } + + return new(xOffset, yOffset, width, height); + } + #endregion + + #region Image methods + public void LoadImage(string path) + { + Image = new Bitmap(path); + } + + public Bitmap? GetSelectedBitmap() + { + if (!HaveSelection || Image is null) + return null; + + using var stream = new MemoryStream(); + Image.Save(stream); + var image = WriteableBitmap.Decode(stream); + stream.Dispose(); + + var selection = SelectionRegionNet; + var pixelSize = SelectionPixelSize; + using var frameBuffer = image.Lock(); + + var newBitmap = new WriteableBitmap( + pixelSize, + image.Dpi, + frameBuffer.Format, + AlphaFormat.Unpremul + ); + using var newFrameBuffer = newBitmap.Lock(); + + var i = 0; + + unsafe + { + var inputPixels = (uint*)(void*)frameBuffer.Address; + var targetPixels = (uint*)(void*)newFrameBuffer.Address; + + for (var y = selection.Y; y < selection.Bottom; y++) + { + var thisY = y * frameBuffer.Size.Width; + for (var x = selection.X; x < selection.Right; x++) + { + targetPixels![i++] = inputPixels![thisY + x]; + } + } + } + + return newBitmap; + } + #endregion +} diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml new file mode 100644 index 000000000..d9032f215 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml.cs b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml.cs new file mode 100644 index 000000000..104967e49 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/AdvancedImageBoxView.axaml.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Helpers; +using StabilityMatrix.Core.Helper; + +namespace StabilityMatrix.Avalonia.Controls; + +public partial class AdvancedImageBoxView : UserControl +{ + public AdvancedImageBoxView() + { + InitializeComponent(); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + var copyMenuItem = this.FindControl("CopyMenuItem")!; + copyMenuItem.Command = new AsyncRelayCommand(FlyoutCopy); + } + + private static async Task FlyoutCopy(Bitmap? image) + { + if (image is null || !Compat.IsWindows) return; + + await Task.Run(() => + { + if (Compat.IsWindows) + { + WindowsClipboard.SetBitmap(image); + } + }); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml b/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml new file mode 100644 index 000000000..b2cf9dc8e --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml @@ -0,0 +1,72 @@ + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml.cs new file mode 100644 index 000000000..ea7d573f8 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/BatchSizeCard.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls; + +public class BatchSizeCard : TemplatedControl +{ +} \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs index d5dde1dc3..5556bc38d 100644 --- a/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs +++ b/StabilityMatrix.Avalonia/Controls/BetterContentDialog.cs @@ -1,15 +1,18 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.Reflection; using AsyncAwaitBestPractices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using FluentAvalonia.UI.Controls; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using Brushes = Avalonia.Media.Brushes; namespace StabilityMatrix.Avalonia.Controls; @@ -95,6 +98,8 @@ static BetterContentDialog() } #endregion + private FABorder? backgroundPart; + protected override Type StyleKeyOverride { get; } = typeof(ContentDialog); public static readonly StyledProperty IsFooterVisibleProperty = AvaloniaProperty.Register< @@ -156,11 +161,45 @@ public Thickness ContentMargin set => SetValue(ContentMarginProperty, value); } + public static readonly StyledProperty CloseOnClickOutsideProperty = + AvaloniaProperty.Register("CloseOnClickOutside"); + + /// + /// Whether to close the dialog when clicking outside of it (on the blurred background) + /// + public bool CloseOnClickOutside + { + get => GetValue(CloseOnClickOutsideProperty); + set => SetValue(CloseOnClickOutsideProperty, value); + } + public BetterContentDialog() { AddHandler(LoadedEvent, OnLoaded); } + /// + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (CloseOnClickOutside) + { + if (e.Source is Popup || backgroundPart is null) + return; + + var point = e.GetPosition(this); + + if ( + !backgroundPart.Bounds.Contains(point) + && (Content as Control)?.DataContext is ContentDialogViewModelBase vm + ) + { + vm.OnCloseButtonClick(); + } + } + } + private void TryBindButtons() { if ((Content as Control)?.DataContext is ContentDialogViewModelBase viewModel) @@ -243,10 +282,10 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - var background = e.NameScope.Find("BackgroundElement"); - if (background is not null) + backgroundPart = e.NameScope.Find("BackgroundElement"); + if (backgroundPart is not null) { - background.Margin = ContentMargin; + backgroundPart.Margin = ContentMargin; } } diff --git a/StabilityMatrix.Avalonia/Controls/Card.cs b/StabilityMatrix.Avalonia/Controls/Card.cs index 24df9e404..dce1629fc 100644 --- a/StabilityMatrix.Avalonia/Controls/Card.cs +++ b/StabilityMatrix.Avalonia/Controls/Card.cs @@ -1,5 +1,7 @@ using System; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; namespace StabilityMatrix.Avalonia.Controls; @@ -7,9 +9,45 @@ public class Card : ContentControl { protected override Type StyleKeyOverride => typeof(Card); + // ReSharper disable MemberCanBePrivate.Global + public static readonly StyledProperty IsCardVisualsEnabledProperty = + AvaloniaProperty.Register("IsCardVisualsEnabled", true); + + /// + /// Whether to show card visuals. + /// When false, the card will have a padding of 0 and be transparent. + /// + public bool IsCardVisualsEnabled + { + get => GetValue(IsCardVisualsEnabledProperty); + set => SetValue(IsCardVisualsEnabledProperty, value); + } + + // ReSharper restore MemberCanBePrivate.Global + public Card() { MinHeight = 8; MinWidth = 8; } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + // When IsCardVisualsEnabled is false, add the disabled pseudo class + if (change.Property == IsCardVisualsEnabledProperty) + { + PseudoClasses.Set("disabled", !change.GetNewValue()); + } + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + PseudoClasses.Set("disabled", !IsCardVisualsEnabled); + } } diff --git a/StabilityMatrix.Avalonia/Controls/CheckerboardBorder.cs b/StabilityMatrix.Avalonia/Controls/CheckerboardBorder.cs new file mode 100644 index 000000000..92b878aa0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CheckerboardBorder.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class CheckerboardBorder : Control +{ + public static readonly StyledProperty GridCellSizeProperty = AvaloniaProperty.Register< + AdvancedImageBox, + byte + >(nameof(GridCellSize), 15); + + public byte GridCellSize + { + get => GetValue(GridCellSizeProperty); + set => SetValue(GridCellSizeProperty, value); + } + + public static readonly StyledProperty GridColorProperty = + AvaloniaProperty.Register( + nameof(GridColor), + SolidColorBrush.Parse("#181818") + ); + + /// + /// Gets or sets the color used to create the checkerboard style background + /// + public ISolidColorBrush GridColor + { + get => GetValue(GridColorProperty); + set => SetValue(GridColorProperty, value); + } + + public static readonly StyledProperty GridColorAlternateProperty = + AvaloniaProperty.Register( + nameof(GridColorAlternate), + SolidColorBrush.Parse("#252525") + ); + + /// + /// Gets or sets the color used to create the checkerboard style background + /// + public ISolidColorBrush GridColorAlternate + { + get => GetValue(GridColorAlternateProperty); + set => SetValue(GridColorAlternateProperty, value); + } + + static CheckerboardBorder() + { + AffectsRender(GridCellSizeProperty); + AffectsRender(GridColorProperty); + AffectsRender(GridColorAlternateProperty); + } + + /// + public override void Render(DrawingContext context) + { + var size = GridCellSize; + + var square1Drawing = new GeometryDrawing + { + Brush = GridColorAlternate, + Geometry = new RectangleGeometry(new Rect(0.0, 0.0, size, size)) + }; + + var square2Drawing = new GeometryDrawing + { + Brush = GridColorAlternate, + Geometry = new RectangleGeometry(new Rect(size, size, size, size)) + }; + + var drawingGroup = new DrawingGroup { Children = { square1Drawing, square2Drawing } }; + + var tileBrush = new DrawingBrush(drawingGroup) + { + AlignmentX = AlignmentX.Left, + AlignmentY = AlignmentY.Top, + DestinationRect = new RelativeRect(new Size(2 * size, 2 * size), RelativeUnit.Absolute), + Stretch = Stretch.None, + TileMode = TileMode.Tile, + }; + + context.FillRectangle(GridColor, Bounds); + // context.DrawRectangle(new Pen(Brushes.Blue), new Rect(0.5, 0.5, Bounds.Width - 1.0, Bounds.Height - 1.0)); + + context.FillRectangle(tileBrush, Bounds); + + // base.Render(context); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs new file mode 100644 index 000000000..615e09e84 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs @@ -0,0 +1,152 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Styles; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Provides entries in AvaloniaEdit completion window. +/// +public class CompletionData : ICompletionData +{ + /// + public string Text { get; } + + /// + public string? Description { get; init; } + + /// + public ImageSource? ImageSource { get; set; } + + /// + /// Title of the image. + /// + public string? ImageTitle { get; set; } + + /// + /// Subtitle of the image. + /// + public string? ImageSubtitle { get; set; } + + /// + public IconData? Icon { get; init; } + + private InlineCollection? _textInlines; + + /// + /// Get the current inlines + /// + public InlineCollection TextInlines => _textInlines ??= CreateInlines(); + + /// + public double Priority { get; init; } + + public CompletionData(string text) + { + Text = text; + } + + /// + /// Create text block inline runs from text. + /// + private InlineCollection CreateInlines() + { + // Create a span for each character in the text. + var chars = Text.ToCharArray(); + var inlines = new InlineCollection(); + + foreach (var c in chars) + { + var run = new Run(c.ToString()); + inlines.Add(run); + } + + return inlines; + } + + /// + public void Complete( + TextArea textArea, + ISegment completionSegment, + InsertionRequestEventArgs eventArgs, + Func? prepareText = null + ) + { + var text = Text; + + if (prepareText is not null) + { + text = prepareText(this); + } + + // Capture initial offset before replacing text, since it will change + var initialOffset = completionSegment.Offset; + + // Replace text + textArea.Document.Replace(completionSegment, text); + + // Append text if requested + if (eventArgs.AppendText is { } appendText && !string.IsNullOrEmpty(appendText)) + { + var end = initialOffset + text.Length; + textArea.Document.Insert(end, appendText); + textArea.Caret.Offset = end + appendText.Length; + } + } + + /// + public void UpdateCharHighlighting(string searchText) + { + if (TextInlines is null) + { + throw new NullReferenceException("TextContent is null"); + } + + var defaultColor = ThemeColors.CompletionForegroundBrush; + var highlightColor = ThemeColors.CompletionSelectionForegroundBrush; + + // Match characters in the text with the search text from the start + foreach (var (i, currentChar) in Text.Enumerate()) + { + var inline = TextInlines[i]; + + // If longer than text, set to default color + if (i >= searchText.Length) + { + inline.Foreground = defaultColor; + continue; + } + + // If char matches, highlight it + if (currentChar == searchText[i]) + { + inline.Foreground = highlightColor; + } + // For mismatch, set to default color + else + { + inline.Foreground = defaultColor; + } + } + } + + /// + public void ResetCharHighlighting() + { + // TODO: handle light theme foreground variant + var defaultColor = ThemeColors.CompletionForegroundBrush; + + foreach (var inline in TextInlines) + { + inline.Foreground = defaultColor; + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs new file mode 100644 index 000000000..75f9dfdbb --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Projektanker.Icons.Avalonia; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Avalonia.Styles; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public static class CompletionIcons +{ + public static readonly IconData General = + new() { FAIcon = "fa-solid fa-star-of-life", Foreground = ThemeColors.LightSteelBlue, }; + + public static readonly IconData Artist = + new() { FAIcon = "fa-solid fa-palette", Foreground = ThemeColors.AmericanYellow, }; + + public static readonly IconData Character = + new() { FAIcon = "fa-solid fa-user", Foreground = ThemeColors.LuminousGreen, }; + + public static readonly IconData Copyright = + new() { FAIcon = "fa-solid fa-copyright", Foreground = ThemeColors.DeepMagenta, }; + + public static readonly IconData Species = + new() + { + FAIcon = "fa-solid fa-dragon", + FontSize = 14, + Foreground = ThemeColors.HalloweenOrange, + }; + + public static readonly IconData Invalid = + new() + { + FAIcon = "fa-solid fa-question", + Foreground = ThemeColors.CompletionForegroundBrush, + }; + + public static readonly IconData Keyword = + new() { FAIcon = "fa-solid fa-key", Foreground = ThemeColors.CompletionForegroundBrush, }; + + public static readonly IconData Model = + new() { FAIcon = "fa-solid fa-cube", Foreground = ThemeColors.CompletionForegroundBrush, }; + + public static readonly IconData ModelType = + new() { FAIcon = "fa-solid fa-shapes", Foreground = ThemeColors.BrilliantAzure, }; + + public static IconData? GetIconForTagType(TagType tagType) + { + return tagType switch + { + TagType.General => General, + TagType.Artist => Artist, + TagType.Character => Character, + TagType.Species => Species, + TagType.Invalid => Invalid, + TagType.Copyright => Copyright, + _ => null + }; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs new file mode 100644 index 000000000..9150209b3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs @@ -0,0 +1,670 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml.Templates; +using AvaloniaEdit.Utils; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The listbox used inside the CompletionWindow, contains CompletionListBox. +/// +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class CompletionList : TemplatedControl +{ + private CompletionListBox? _listBox; + + public CompletionList() + { + AddHandler(DoubleTappedEvent, OnDoubleTapped); + } + + /// + /// If true, the CompletionList is filtered to show only matching items. Also enables search by substring. + /// If false, enables the old behavior: no filtering, search by string.StartsWith. + /// + public bool IsFiltering { get; set; } = true; + + /// + /// Dependency property for . + /// + public static readonly StyledProperty EmptyTemplateProperty = + AvaloniaProperty.Register(nameof(EmptyTemplate)); + + /// + /// Content of EmptyTemplate will be shown when CompletionList contains no items. + /// If EmptyTemplate is null, nothing will be shown. + /// + public ControlTemplate EmptyTemplate + { + get => GetValue(EmptyTemplateProperty); + set => SetValue(EmptyTemplateProperty, value); + } + + /// + /// Dependency property for . + /// + public static readonly StyledProperty FooterTextProperty = AvaloniaProperty.Register< + CompletionList, + string? + >("FooterText", "Press Enter to insert, Tab to replace"); + + /// + /// Gets/Sets the text displayed in the footer of the completion list. + /// + public string? FooterText + { + get => GetValue(FooterTextProperty); + set => SetValue(FooterTextProperty, value); + } + + /// + /// Is raised when the completion list indicates that the user has chosen + /// an entry to be completed. + /// + public event EventHandler? InsertionRequested; + + /// + /// Raised when the completion list indicates that it should be closed. + /// + public event EventHandler? CloseRequested; + + /// + /// Raises the InsertionRequested event. + /// + public void RequestInsertion( + ICompletionData item, + RoutedEventArgs triggeringEvent, + string? appendText = null + ) + { + InsertionRequested?.Invoke( + this, + new InsertionRequestEventArgs + { + Item = item, + TriggeringEvent = triggeringEvent, + AppendText = appendText + } + ); + } + + /// + /// Raises the CloseRequested event. + /// + public void RequestClose() + { + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _listBox = e.NameScope.Find("PART_ListBox") as CompletionListBox; + if (_listBox is not null) + { + // _listBox.ItemsSource = _completionData; + _listBox.ItemsSource = FilteredCompletionData; + } + } + + /// + /// Gets the list box. + /// + public CompletionListBox? ListBox + { + get + { + if (_listBox == null) + ApplyTemplate(); + return _listBox; + } + } + + /// + /// Dictionary of keys that request insertion of the completion + /// mapped to strings that will be appended to the completion when selected. + /// The string may be empty. + /// + public Dictionary CompletionAcceptKeys { get; init; } = + new() { [Key.Enter] = "", [Key.Tab] = "" }; + + /// + /// Gets the scroll viewer used in this list box. + /// + public ScrollViewer? ScrollViewer => _listBox?.ScrollViewer; + + private readonly ObservableCollection _completionData = new(); + + /// + /// Gets the list to which completion data can be added. + /// + public IList CompletionData => _completionData; + + public ObservableCollection FilteredCompletionData { get; } = new(); + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + HandleKey(e); + } + } + + /// + /// Handles a key press. Used to let the completion list handle key presses while the + /// focus is still on the text editor. + /// + [SuppressMessage("ReSharper", "SwitchStatementHandlesSomeKnownEnumValuesWithDefault")] + public void HandleKey(KeyEventArgs e) + { + if (_listBox == null) + return; + + // We have to do some key handling manually, because the default doesn't work with + // our simulated events. + // Also, the default PageUp/PageDown implementation changes the focus, so we avoid it. + switch (e.Key) + { + case Key.Down: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex + 1); + break; + case Key.Up: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex - 1); + break; + case Key.PageDown: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex + _listBox.VisibleItemCount); + break; + case Key.PageUp: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex - _listBox.VisibleItemCount); + break; + case Key.Home: + e.Handled = true; + _listBox.SelectIndex(0); + break; + case Key.End: + e.Handled = true; + _listBox.SelectIndex(_listBox.ItemCount - 1); + break; + default: + // Check insertion keys + if ( + CompletionAcceptKeys.TryGetValue(e.Key, out var appendText) + && CurrentList?.Count > 0 + ) + { + e.Handled = true; + + if (SelectedItem is { } item) + { + RequestInsertion(item, e, appendText); + } + else + { + RequestClose(); + } + } + + break; + } + } + + protected void OnDoubleTapped(object? sender, RoutedEventArgs e) + { + //TODO TEST + if ( + ((AvaloniaObject?)e.Source) + .VisualAncestorsAndSelf() + .TakeWhile(obj => obj != this) + .Any(obj => obj is ListBoxItem) + ) + { + e.Handled = true; + + if (SelectedItem is { } item) + { + RequestInsertion(item, e); + } + else + { + RequestClose(); + } + } + } + + /// + /// Gets/Sets the selected item. + /// + /// + /// The setter of this property does not scroll to the selected item. + /// You might want to also call . + /// + public ICompletionData? SelectedItem + { + get => _listBox?.SelectedItem as ICompletionData; + set + { + if (_listBox == null && value != null) + ApplyTemplate(); + if (_listBox != null) // may still be null if ApplyTemplate fails, or if listBox and value both are null + _listBox.SelectedItem = value; + } + } + + /// + /// Scrolls the specified item into view. + /// + public void ScrollIntoView(ICompletionData item) + { + if (_listBox == null) + ApplyTemplate(); + _listBox?.ScrollIntoView(item); + } + + /// + /// Occurs when the SelectedItem property changes. + /// + public event EventHandler SelectionChanged + { + add => AddHandler(SelectingItemsControl.SelectionChangedEvent, value); + remove => RemoveHandler(SelectingItemsControl.SelectionChangedEvent, value); + } + + // SelectItem gets called twice for every typed character (once from FormatLine), this helps execute SelectItem only once + private string? _currentText; + + private ObservableCollection? _currentList; + + public List? CurrentList => ListBox?.Items.Cast().ToList(); + + /// + /// Selects the best match, and filter the items if turned on using . + /// + public void SelectItem(string text, bool fullUpdate = false) + { + if (text == _currentText) + { + return; + } + + using var _ = new CodeTimer(); + + if (_listBox == null) + { + ApplyTemplate(); + } + + if (IsFiltering) + { + SelectItemFilteringLive(text, fullUpdate); + } + else + { + SelectItemWithStart(text); + } + + _currentText = text; + } + + private IReadOnlyList FilterItems( + IEnumerable items, + string query + ) + { + using var _ = new CodeTimer(); + + // Order first by quality, then by priority + var matchingItems = items + .Select(item => new { Item = item, Quality = GetMatchQuality(item.Text, query) }) + .Where(x => x.Quality > 0) + .OrderByDescending(x => x.Quality) + .ThenByDescending(x => x.Item.Priority) + .Select(x => x.Item) + .ToList(); + + return matchingItems; + } + + /// + /// Filters CompletionList items to show only those matching given query, and selects the best match. + /// + private void SelectItemFilteringLive(string query, bool fullUpdate = false) + { + var listToFilter = _completionData; + + // if the user just typed one more character, don't filter all data but just filter what we are already displaying + if ( + !fullUpdate + && FilteredCompletionData.Count > 0 + && !string.IsNullOrEmpty(_currentText) + && !string.IsNullOrEmpty(query) + && query.StartsWith(_currentText, StringComparison.Ordinal) + ) + { + listToFilter = FilteredCompletionData; + } + + var matchingItems = FilterItems(listToFilter, query); + + // Close if no items match + if (matchingItems.Count == 0) + { + RequestClose(); + return; + } + + // Fast path if both only 1 item, and item is the same + if ( + FilteredCompletionData.Count == 1 + && matchingItems.Count == 1 + && FilteredCompletionData[0] == matchingItems[0] + ) + { + // Just update the character highlighting + matchingItems[0].UpdateCharHighlighting(query); + } + else + { + // Clear current items and set new ones + FilteredCompletionData.Clear(); + + foreach (var item in matchingItems) + { + item.UpdateCharHighlighting(query); + FilteredCompletionData.Add(item); + } + + // Set index to 0 if not already + if (_listBox != null && _listBox.SelectedIndex != 0) + { + _listBox.SelectedIndex = 0; + } + } + } + + /// + /// Filters CompletionList items to show only those matching given query, and selects the best match. + /// + private void SelectItemFiltering(string query, bool fullUpdate = false) + { + if (_listBox is null) + throw new NullReferenceException("ListBox not set"); + + var listToFilter = _completionData; + + // if the user just typed one more character, don't filter all data but just filter what we are already displaying + if ( + !fullUpdate + && _currentList != null + && !string.IsNullOrEmpty(_currentText) + && !string.IsNullOrEmpty(query) + && query.StartsWith(_currentText, StringComparison.Ordinal) + ) + { + listToFilter = _currentList; + } + + // Order first by quality, then by priority + var matchingItems = listToFilter + .Select(item => new { Item = item, Quality = GetMatchQuality(item.Text, query) }) + .Where(x => x.Quality > 0) + .OrderByDescending(x => x.Quality) + .ThenByDescending(x => x.Item.Priority) + .ToImmutableArray(); + + /*var matchingItems = + from item in listToFilter + let quality = GetMatchQuality(item.Text, query) + where quality > 0 + orderby quality + select new { Item = item, Quality = quality };*/ + + var suggestedItem = + _listBox.SelectedIndex != -1 ? (ICompletionData)_listBox.SelectedItem! : null; + + var listBoxItems = new ObservableCollection(); + var bestIndex = -1; + var bestQuality = -1; + double bestPriority = 0; + var i = 0; + foreach (var matchingItem in matchingItems) + { + var priority = + matchingItem.Item == suggestedItem + ? double.PositiveInfinity + : matchingItem.Item.Priority; + var quality = matchingItem.Quality; + if (quality > bestQuality || quality == bestQuality && priority > bestPriority) + { + bestIndex = i; + bestPriority = priority; + bestQuality = quality; + } + + // Add to listbox + listBoxItems.Add(matchingItem.Item); + + // Update the character highlighting + matchingItem.Item.UpdateCharHighlighting(query); + + i++; + } + + _currentList = listBoxItems; + //_listBox.Items = null; Makes no sense? Tooltip disappeared because of this + _listBox.ItemsSource = listBoxItems; + SelectIndex(bestIndex); + } + + /// + /// Selects the item that starts with the specified query. + /// + private void SelectItemWithStart(string query) + { + if (string.IsNullOrEmpty(query)) + return; + + var suggestedIndex = _listBox?.SelectedIndex ?? -1; + if (suggestedIndex == -1) + { + return; + } + + var bestIndex = -1; + var bestQuality = -1; + double bestPriority = 0; + for (var i = 0; i < _completionData.Count; ++i) + { + var quality = GetMatchQuality(_completionData[i].Text, query); + if (quality < 0) + continue; + + var priority = _completionData[i].Priority; + bool useThisItem; + if (bestQuality < quality) + { + useThisItem = true; + } + else + { + if (bestIndex == suggestedIndex) + { + useThisItem = false; + } + else if (i == suggestedIndex) + { + // prefer recommendedItem, regardless of its priority + useThisItem = bestQuality == quality; + } + else + { + useThisItem = bestQuality == quality && bestPriority < priority; + } + } + if (useThisItem) + { + bestIndex = i; + bestPriority = priority; + bestQuality = quality; + } + } + SelectIndexCentered(bestIndex); + } + + private void SelectIndexCentered(int index) + { + if (_listBox is null) + { + throw new NullReferenceException("ListBox not set"); + } + + if (index < 0) + { + _listBox.ClearSelection(); + } + else + { + var firstItem = _listBox.FirstVisibleItem; + if (index < firstItem || firstItem + _listBox.VisibleItemCount <= index) + { + // CenterViewOn does nothing as CompletionListBox.ScrollViewer is null + _listBox.CenterViewOn(index); + _listBox.SelectIndex(index); + } + else + { + _listBox.SelectIndex(index); + } + } + } + + private void SelectIndex(int index) + { + if (_listBox is null) + { + throw new NullReferenceException("ListBox not set"); + } + + if (index == _listBox.SelectedIndex) + return; + + if (index < 0) + { + _listBox.ClearSelection(); + } + else + { + _listBox.SelectedIndex = index; + } + } + + private int GetMatchQuality(string itemText, string query) + { + if (itemText == null) + throw new ArgumentNullException(nameof(itemText), "ICompletionData.Text returned null"); + + // Qualities: + // 8 = full match case sensitive + // 7 = full match + // 6 = match start case sensitive + // 5 = match start + // 4 = match CamelCase when length of query is 1 or 2 characters + // 3 = match substring case sensitive + // 2 = match substring + // 1 = match CamelCase + // -1 = no match + if (query == itemText) + return 8; + if (string.Equals(itemText, query, StringComparison.CurrentCultureIgnoreCase)) + return 7; + + if (itemText.StartsWith(query, StringComparison.CurrentCulture)) + return 6; + if (itemText.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) + return 5; + + bool? camelCaseMatch = null; + if (query.Length <= 2) + { + camelCaseMatch = CamelCaseMatch(itemText, query); + if (camelCaseMatch == true) + return 4; + } + + // search by substring, if filtering (i.e. new behavior) turned on + if (IsFiltering) + { + if (itemText.Contains(query, StringComparison.CurrentCulture)) + return 3; + if (itemText.Contains(query, StringComparison.CurrentCultureIgnoreCase)) + return 2; + } + + if (!camelCaseMatch.HasValue) + camelCaseMatch = CamelCaseMatch(itemText, query); + if (camelCaseMatch == true) + return 1; + + return -1; + } + + private static bool CamelCaseMatch(string text, string query) + { + // We take the first letter of the text regardless of whether or not it's upper case so we match + // against camelCase text as well as PascalCase text ("cct" matches "camelCaseText") + var theFirstLetterOfEachWord = text.AsEnumerable() + .Take(1) + .Concat(text.AsEnumerable().Skip(1).Where(char.IsUpper)); + + var i = 0; + foreach (var letter in theFirstLetterOfEachWord) + { + if (i > query.Length - 1) + return true; // return true here for CamelCase partial match ("CQ" matches "CodeQualityAnalysis") + if (char.ToUpperInvariant(query[i]) != char.ToUpperInvariant(letter)) + return false; + i++; + } + if (i >= query.Length) + return true; + return false; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs new file mode 100644 index 000000000..4313be177 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using AvaloniaEdit.Utils; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The list box used inside the CompletionList. +/// +public class CompletionListBox : ListBox +{ + internal ScrollViewer? ScrollViewer; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + ScrollViewer = e.NameScope.Find("PART_ScrollViewer") as ScrollViewer; + } + + /// + /// Gets the number of the first visible item. + /// + public int FirstVisibleItem + { + get + { + if (ScrollViewer == null || ScrollViewer.Extent.Height == 0) + { + return 0; + } + + return (int)(ItemCount * ScrollViewer.Offset.Y / ScrollViewer.Extent.Height); + } + set + { + value = value.CoerceValue(0, ItemCount - VisibleItemCount); + if (ScrollViewer != null) + { + ScrollViewer.Offset = ScrollViewer.Offset.WithY( + (double)value / ItemCount * ScrollViewer.Extent.Height + ); + } + } + } + + /// + /// Gets the number of visible items. + /// + public int VisibleItemCount + { + get + { + if (ScrollViewer == null || ScrollViewer.Extent.Height == 0) + { + return 10; + } + return Math.Max( + 3, + (int) + Math.Ceiling( + ItemCount * ScrollViewer.Viewport.Height / ScrollViewer.Extent.Height + ) + ); + } + } + + /// + /// Removes the selection. + /// + public void ClearSelection() + { + SelectedIndex = -1; + } + + /// + /// Selects the item with the specified index and scrolls it into view. + /// + public void SelectIndex(int index) + { + if (index >= ItemCount) + index = ItemCount - 1; + if (index < 0) + index = 0; + SelectedIndex = index; + if (SelectedItem is { } item) + { + ScrollIntoView(item); + } + } + + /// + /// Centers the view on the item with the specified index. + /// + public void CenterViewOn(int index) + { + FirstVisibleItem = index - VisibleItemCount / 2; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml new file mode 100644 index 000000000..6e4a937b3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml new file mode 100644 index 000000000..32b22eb79 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs new file mode 100644 index 000000000..62d2154d0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs @@ -0,0 +1,334 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Utils; +using StabilityMatrix.Avalonia.Models.TagCompletion; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The code completion window. +/// +public class CompletionWindow : CompletionWindowBase +{ + private readonly ICompletionProvider completionProvider; + private readonly ITokenizerProvider tokenizerProvider; + + private PopupWithCustomPosition? _toolTip; + private ContentControl? _toolTipContent; + + /// + /// Max number of items in the completion list. + /// + public int MaxListLength { get; set; } = 40; + + /// + /// Gets the completion list used in this completion window. + /// + public CompletionList CompletionList { get; } = new(); + + /// + /// Whether selection tooltips are shown. + /// + public bool IsSelectionTooltipEnabled { get; set; } + + /// + /// Creates a new code completion window. + /// + public CompletionWindow( + TextArea textArea, + ICompletionProvider completionProvider, + ITokenizerProvider tokenizerProvider + ) + : base(textArea) + { + this.completionProvider = completionProvider; + this.tokenizerProvider = tokenizerProvider; + + CloseAutomatically = true; + MaxHeight = 225; + Width = 350; + Child = CompletionList; + // prevent user from resizing window to 0x0 + MinHeight = 15; + MinWidth = 30; + + _toolTipContent = new ContentControl(); + _toolTipContent.Classes.Add("ToolTip"); + + _toolTip = new PopupWithCustomPosition + { + IsLightDismissEnabled = true, + PlacementTarget = this, + Placement = PlacementMode.RightEdgeAlignedTop, + Child = _toolTipContent, + }; + + LogicalChildren.Add(_toolTip); + + //_toolTip.Closed += (o, e) => ((Popup)o).Child = null; + + AttachEvents(); + } + + protected override void OnClosed() + { + base.OnClosed(); + + if (_toolTip != null) + { + _toolTip.IsOpen = false; + _toolTip = null; + _toolTipContent = null; + } + } + + #region ToolTip handling + + private void CompletionList_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + // Skip if tooltip not enabled + if (!IsSelectionTooltipEnabled) + return; + + if (_toolTipContent == null || _toolTip == null) + return; + + var item = CompletionList.SelectedItem; + if (item?.Description is { } descriptionText) + { + _toolTipContent.Content = new TextBlock + { + Text = descriptionText, + TextWrapping = TextWrapping.Wrap + }; + + _toolTip.IsOpen = false; // Popup needs to be closed to change position + + // Calculate offset for tooltip + var popupRoot = Host as PopupRoot; + if (CompletionList.CurrentList != null) + { + double yOffset = 0; + var itemContainer = CompletionList.ListBox!.ContainerFromItem(item); + if (popupRoot != null && itemContainer != null) + { + var position = itemContainer.TranslatePoint(new Point(0, 0), popupRoot); + if (position.HasValue) + yOffset = position.Value.Y; + } + + _toolTip.Offset = new Point(2, yOffset); + } + + _toolTip.PlacementTarget = popupRoot; + _toolTip.IsOpen = true; + } + else + { + _toolTip.IsOpen = false; + } + } + + #endregion + + private void CompletionList_InsertionRequested(object? sender, InsertionRequestEventArgs e) + { + Hide(); + + // The window must close before Complete() is called. + // If the Complete callback pushes stacked input handlers, we don't want to pop those when the CC window closes. + var length = EndOffset - StartOffset; + e.Item.Complete( + TextArea, + new AnchorSegment(TextArea.Document, StartOffset, length), + e, + completionProvider.PrepareInsertionText + ); + } + + private void CompletionList_CloseRequested(object? sender, EventArgs e) + { + Hide(); + } + + private void AttachEvents() + { + CompletionList.CloseRequested += CompletionList_CloseRequested; + CompletionList.InsertionRequested += CompletionList_InsertionRequested; + CompletionList.SelectionChanged += CompletionList_SelectionChanged; + TextArea.Caret.PositionChanged += CaretPositionChanged; + TextArea.PointerWheelChanged += TextArea_MouseWheel; + TextArea.TextInput += TextArea_PreviewTextInput; + } + + /// + protected override void DetachEvents() + { + CompletionList.CloseRequested -= CompletionList_CloseRequested; + CompletionList.InsertionRequested -= CompletionList_InsertionRequested; + CompletionList.SelectionChanged -= CompletionList_SelectionChanged; + TextArea.Caret.PositionChanged -= CaretPositionChanged; + TextArea.PointerWheelChanged -= TextArea_MouseWheel; + TextArea.TextInput -= TextArea_PreviewTextInput; + base.DetachEvents(); + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + CompletionList.HandleKey(e); + } + } + + private void TextArea_PreviewTextInput(object? sender, TextInputEventArgs e) + { + e.Handled = RaiseEventPair( + this, + null, + TextInputEvent, + new TextInputEventArgs { Text = e.Text } + ); + } + + private void TextArea_MouseWheel(object? sender, PointerWheelEventArgs e) + { + e.Handled = RaiseEventPair(GetScrollEventTarget(), null, PointerWheelChangedEvent, e); + } + + private Control GetScrollEventTarget() + { + /*if (CompletionList == null) + return this;*/ + return CompletionList.ScrollViewer ?? CompletionList.ListBox ?? (Control)CompletionList; + } + + /// + /// Gets/Sets whether the completion window should close automatically. + /// The default value is true. + /// + public bool CloseAutomatically { get; set; } + + /// + protected override bool CloseOnFocusLost => CloseAutomatically; + + /// + /// When this flag is set, code completion closes if the caret moves to the + /// beginning of the allowed range. This is useful in Ctrl+Space and "complete when typing", + /// but not in dot-completion. + /// Has no effect if CloseAutomatically is false. + /// + public bool CloseWhenCaretAtBeginning { get; set; } + + private void CaretPositionChanged(object? sender, EventArgs e) + { + Debug.WriteLine($"Caret Position changed: {e}"); + var offset = TextArea.Caret.Offset; + if (offset == StartOffset) + { + if (CloseAutomatically && CloseWhenCaretAtBeginning) + { + Hide(); + } + else + { + CompletionList.SelectItem(string.Empty); + + IsVisible = CompletionList.ListBox!.ItemCount != 0; + } + return; + } + if (offset < StartOffset || offset > EndOffset) + { + if (CloseAutomatically) + { + Hide(); + } + } + else + { + var document = TextArea.Document; + if (document != null) + { + var newText = document.GetText(StartOffset, offset - StartOffset); + Debug.WriteLine("CaretPositionChanged newText: " + newText); + + if (lastSearchRequest is not { } lastRequest) + { + return; + } + + // CompletionList.SelectItem(newText); + Dispatcher.UIThread.Post(() => UpdateQuery(lastRequest with { Text = newText })); + // UpdateQuery(newText); + + IsVisible = CompletionList.ListBox!.ItemCount != 0; + } + } + } + + private TextCompletionRequest? lastSearchRequest; + private int lastCompletionLength; + + /// + /// Update the completion window's current search term. + /// + public void UpdateQuery(TextCompletionRequest completionRequest) + { + var searchTerm = completionRequest.Text; + + // Fast path if the search term starts with the last search term + // and the last completion count was less than the max list length + // (such we won't get new results by searching again) + if ( + lastSearchRequest is not null + && completionRequest.Type == lastSearchRequest.Type + && searchTerm.StartsWith(lastSearchRequest.Text) + && lastCompletionLength < MaxListLength + ) + { + CompletionList.SelectItem(searchTerm); + lastSearchRequest = completionRequest; + return; + } + + var results = completionProvider.GetCompletions(completionRequest, MaxListLength, true); + CompletionList.CompletionData.Clear(); + CompletionList.CompletionData.AddRange(results); + + CompletionList.SelectItem(searchTerm, true); + + lastSearchRequest = completionRequest; + lastCompletionLength = CompletionList.CompletionData.Count; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs new file mode 100644 index 000000000..de486c57c --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs @@ -0,0 +1,416 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Base class for completion windows. Handles positioning the window at the caret. +/// +[SuppressMessage("ReSharper", "MemberCanBeProtected.Global")] +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class CompletionWindowBase : Popup +{ + protected override Type StyleKeyOverride => typeof(PopupRoot); + + /// + /// Gets the parent TextArea. + /// + public TextArea TextArea { get; } + + private readonly Window? _parentWindow; + private TextDocument? _document; + + /// + /// Gets/Sets the start of the text range in which the completion window stays open. + /// This text portion is used to determine the text used to select an entry in the completion list by typing. + /// + public int StartOffset { get; set; } + + /// + /// Gets/Sets the end of the text range in which the completion window stays open. + /// This text portion is used to determine the text used to select an entry in the completion list by typing. + /// + public int EndOffset { get; set; } + + /// + /// Gets whether the window was opened above the current line. + /// + protected bool IsUp { get; private set; } + + /// + /// Creates a new CompletionWindowBase. + /// + public CompletionWindowBase(TextArea textArea) + { + TextArea = textArea ?? throw new ArgumentNullException(nameof(textArea)); + _parentWindow = textArea.GetVisualRoot() as Window ?? + throw new InvalidOperationException("CompletionWindow requires a visual root."); + + AddHandler(PointerReleasedEvent, OnMouseUp, handledEventsToo: true); + + StartOffset = EndOffset = TextArea.Caret.Offset; + + PlacementTarget = TextArea.TextView; + Placement = PlacementMode.AnchorAndGravity; + PlacementAnchor = global::Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor.TopLeft; + PlacementGravity = global::Avalonia.Controls.Primitives.PopupPositioning.PopupGravity.BottomRight; + + //Deactivated += OnDeactivated; //Not needed? + + Closed += (_, _) => DetachEvents(); + + AttachEvents(); + + Initialize(); + } + + protected virtual void OnClosed() + { + DetachEvents(); + } + + private void Initialize() + { + if (_document != null && StartOffset != TextArea.Caret.Offset) + { + SetPosition(new TextViewPosition(_document.GetLocation(StartOffset))); + } + else + { + SetPosition(TextArea.Caret.Position); + } + } + + public void Show() + { + UpdatePosition(); + + Open(); + Height = double.NaN; + MinHeight = 0; + } + + public void Hide() + { + Close(); + OnClosed(); + } + + #region Event Handlers + + private void AttachEvents() + { + ((ISetLogicalParent)this).SetParent(TextArea.GetVisualRoot() as ILogical); + + _document = TextArea.Document; + if (_document != null) + { + _document.Changing += TextArea_Document_Changing; + } + + // LostKeyboardFocus seems to be more reliable than PreviewLostKeyboardFocus - see SD-1729 + TextArea.LostFocus += TextAreaLostFocus; + TextArea.TextView.ScrollOffsetChanged += TextViewScrollOffsetChanged; + TextArea.DocumentChanged += TextAreaDocumentChanged; + if (_parentWindow != null) + { + _parentWindow.PositionChanged += ParentWindow_LocationChanged; + _parentWindow.Deactivated += ParentWindow_Deactivated; + } + + // close previous completion windows of same type + foreach (var x in TextArea.StackedInputHandlers.OfType()) + { + if (x.Window.GetType() == GetType()) + TextArea.PopStackedInputHandler(x); + } + + _myInputHandler = new InputHandler(this); + TextArea.PushStackedInputHandler(_myInputHandler); + } + + /// + /// Detaches events from the text area. + /// + protected virtual void DetachEvents() + { + ((ISetLogicalParent)this).SetParent(null); + + if (_document != null) + { + _document.Changing -= TextArea_Document_Changing; + } + TextArea.LostFocus -= TextAreaLostFocus; + TextArea.TextView.ScrollOffsetChanged -= TextViewScrollOffsetChanged; + TextArea.DocumentChanged -= TextAreaDocumentChanged; + if (_parentWindow != null) + { + _parentWindow.PositionChanged -= ParentWindow_LocationChanged; + _parentWindow.Deactivated -= ParentWindow_Deactivated; + } + TextArea.PopStackedInputHandler(_myInputHandler); + } + + #region InputHandler + + private InputHandler? _myInputHandler; + + /// + /// A dummy input handler (that justs invokes the default input handler). + /// This is used to ensure the completion window closes when any other input handler + /// becomes active. + /// + private sealed class InputHandler : TextAreaStackedInputHandler + { + internal readonly CompletionWindowBase Window; + + public InputHandler(CompletionWindowBase window) + : base(window.TextArea) + { + Debug.Assert(window != null); + Window = window; + } + + public override void Detach() + { + base.Detach(); + Window.Hide(); + } + + public override void OnPreviewKeyDown(KeyEventArgs e) + { + // prevents crash when typing deadchar while CC window is open + if (e.Key == Key.DeadCharProcessed) + return; + e.Handled = RaiseEventPair(Window, null, KeyDownEvent, + new KeyEventArgs { Key = e.Key }); + } + + public override void OnPreviewKeyUp(KeyEventArgs e) + { + if (e.Key == Key.DeadCharProcessed) + return; + e.Handled = RaiseEventPair(Window, null, KeyUpEvent, + new KeyEventArgs { Key = e.Key }); + } + } + #endregion + + private void TextViewScrollOffsetChanged(object? sender, EventArgs e) + { + ILogicalScrollable textView = TextArea; + var visibleRect = new Rect(textView.Offset.X, textView.Offset.Y, textView.Viewport.Width, textView.Viewport.Height); + //close completion window when the user scrolls so far that the anchor position is leaving the visible area + if (visibleRect.Contains(_visualLocation) || visibleRect.Contains(_visualLocationTop)) + { + UpdatePosition(); + } + else + { + Hide(); + } + } + + private void TextAreaDocumentChanged(object? sender, EventArgs e) + { + Hide(); + } + + private void TextAreaLostFocus(object? sender, RoutedEventArgs e) + { + Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); + } + + private void ParentWindow_Deactivated(object? sender, EventArgs e) + { + Hide(); + } + + private void ParentWindow_LocationChanged(object? sender, EventArgs e) + { + UpdatePosition(); + } + + /* + private void OnDeactivated(object sender, EventArgs e) + { + Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); + }*/ + + #endregion + + /// + /// Raises a tunnel/bubble event pair for a control. + /// + /// The control for which the event should be raised. + /// The tunneling event. + /// The bubbling event. + /// The event args to use. + /// The value of the event args. + protected static bool RaiseEventPair(Control target, RoutedEvent? previewEvent, RoutedEvent @event, RoutedEventArgs args) + { + if (target == null) + throw new ArgumentNullException(nameof(target)); + if (args == null) + throw new ArgumentNullException(nameof(args)); + if (previewEvent != null) + { + args.RoutedEvent = previewEvent; + target.RaiseEvent(args); + } + args.RoutedEvent = @event ?? throw new ArgumentNullException(nameof(@event)); + target.RaiseEvent(args); + return args.Handled; + } + + // Special handler: handledEventsToo + private void OnMouseUp(object? sender, PointerReleasedEventArgs e) + { + ActivateParentWindow(); + } + + /// + /// Activates the parent window. + /// + protected virtual void ActivateParentWindow() + { + _parentWindow?.Activate(); + } + + private void CloseIfFocusLost() + { + if (CloseOnFocusLost) + { + Debug.WriteLine("CloseIfFocusLost: this.IsFocues=" + IsFocused + " IsTextAreaFocused=" + IsTextAreaFocused); + if (!IsFocused && !IsTextAreaFocused) + { + Hide(); + } + } + } + + /// + /// Gets whether the completion window should automatically close when the text editor looses focus. + /// + protected virtual bool CloseOnFocusLost => true; + + private bool IsTextAreaFocused + { + get + { + if (_parentWindow != null && !_parentWindow.IsActive) + return false; + return TextArea.IsFocused; + } + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled && e.Key == Key.Escape) + { + e.Handled = true; + Hide(); + } + } + + private Point _visualLocation; + private Point _visualLocationTop; + + /// + /// Positions the completion window at the specified position. + /// + protected void SetPosition(TextViewPosition position) + { + var textView = TextArea.TextView; + + _visualLocation = textView.GetVisualPosition(position, VisualYPosition.LineBottom); + _visualLocationTop = textView.GetVisualPosition(position, VisualYPosition.LineTop); + + UpdatePosition(); + } + + /// + /// Updates the position of the CompletionWindow based on the parent TextView position and the screen working area. + /// It ensures that the CompletionWindow is completely visible on the screen. + /// + protected void UpdatePosition() + { + var textView = TextArea.TextView; + + var position = _visualLocation - textView.ScrollOffset; + + this.HorizontalOffset = position.X; + this.VerticalOffset = position.Y; + } + + // TODO: check if needed + //protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + //{ + // base.OnRenderSizeChanged(sizeInfo); + // if (sizeInfo.HeightChanged && IsUp) + // { + // this.Top += sizeInfo.PreviousSize.Height - sizeInfo.NewSize.Height; + // } + //} + + /// + /// Gets/sets whether the completion window should expect text insertion at the start offset, + /// which not go into the completion region, but before it. + /// + /// This property allows only a single insertion, it is reset to false + /// when that insertion has occurred. + public bool ExpectInsertionBeforeStart { get; set; } + + protected virtual void TextArea_Document_Changing(object? sender, DocumentChangeEventArgs e) + { + if (e.Offset + e.RemovalLength == StartOffset && e.RemovalLength > 0) + { + Hide(); // removal immediately in front of completion segment: close the window + // this is necessary when pressing backspace after dot-completion + } + if (e.Offset == StartOffset && e.RemovalLength == 0 && ExpectInsertionBeforeStart) + { + StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.AfterInsertion); + ExpectInsertionBeforeStart = false; + } + else + { + StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.BeforeInsertion); + } + EndOffset = e.GetNewOffset(EndOffset, AnchorMovementType.AfterInsertion); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs new file mode 100644 index 000000000..2d24ca555 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Models; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Describes an entry in the . +/// +/// +/// Note that the CompletionList uses data binding against the properties in this interface. +/// Thus, your implementation of the interface must use public properties; not explicit interface implementation. +/// +public interface ICompletionData +{ + /// + /// Gets the text. This property is used to filter the list of visible elements. + /// + string Text { get; } + + /// + /// Gets the description. + /// + public string? Description { get; } + + /// + /// Gets the image. + /// + ImageSource? ImageSource { get; } + + /// + /// Title of the image. + /// + string? ImageTitle { get; } + + /// + /// Subtitle of the image. + /// + string? ImageSubtitle { get; } + + /// + /// Whether the image is available. + /// + [MemberNotNullWhen(true, nameof(ImageSource))] + bool HasImage => ImageSource != null; + + /// + /// Gets the icon shown on the left. + /// + IconData? Icon { get; } + + /// + /// Gets inline text fragments. + /// + InlineCollection TextInlines { get; } + + /// + /// Gets the priority. This property is used in the selection logic. You can use it to prefer selecting those items + /// which the user is accessing most frequently. + /// + double Priority { get; } + + /// + /// Perform the completion. + /// + /// The text area on which completion is performed. + /// The text segment that was used by the completion window if + /// the user types (segment between CompletionWindow.StartOffset and CompletionWindow.EndOffset). + /// The EventArgs used for the insertion request. + /// These can be TextCompositionEventArgs, KeyEventArgs, MouseEventArgs, depending on how + /// the insertion was triggered. + /// Optional function to transform the text to be inserted + void Complete( + TextArea textArea, + ISegment completionSegment, + InsertionRequestEventArgs eventArgs, + Func? prepareText = null + ); + + /// + /// Update the text character highlighting + /// + void UpdateCharHighlighting(string searchText); + + /// + /// Reset the text character highlighting + /// + void ResetCharHighlighting(); +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/InsertionRequestEventArgs.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/InsertionRequestEventArgs.cs new file mode 100644 index 000000000..52b721cc3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/InsertionRequestEventArgs.cs @@ -0,0 +1,12 @@ +using System; +using Avalonia.Interactivity; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +public class InsertionRequestEventArgs : EventArgs +{ + public required ICompletionData Item { get; init; } + public required RoutedEventArgs TriggeringEvent { get; init; } + + public string? AppendText { get; init; } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs new file mode 100644 index 000000000..e26b50db9 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +internal class PopupWithCustomPosition : Popup +{ + public Point Offset + { + get => new(HorizontalOffset, VerticalOffset); + set + { + HorizontalOffset = value.X; + VerticalOffset = value.Y; + + // this.Revalidate(VerticalOffsetProperty); + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/ComfyUpscalerTemplateSelector.cs b/StabilityMatrix.Avalonia/Controls/ComfyUpscalerTemplateSelector.cs new file mode 100644 index 000000000..6d47504cd --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ComfyUpscalerTemplateSelector.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Metadata; +using StabilityMatrix.Core.Models.Api.Comfy; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class ComfyUpscalerTemplateSelector : IDataTemplate +{ + // ReSharper disable once CollectionNeverUpdated.Global + [Content] + public Dictionary Templates { get; } = new(); + + // Check if we can accept the provided data + public bool Match(object? data) + { + return data is ComfyUpscaler; + } + + // Build the DataTemplate here + public Control Build(object? data) + { + if (data is not ComfyUpscaler card) + throw new ArgumentException(null, nameof(data)); + + if (Templates.TryGetValue(card.Type, out var type)) + { + return type.Build(card)!; + } + + // Fallback to None + return Templates[ComfyUpscalerType.None].Build(card)!; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/Dock/DockUserControlBase.cs b/StabilityMatrix.Avalonia/Controls/Dock/DockUserControlBase.cs new file mode 100644 index 000000000..d634c0cc3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Dock/DockUserControlBase.cs @@ -0,0 +1,128 @@ +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Threading; +using Dock.Avalonia.Controls; +using Dock.Model; +using Dock.Model.Avalonia.Json; +using Dock.Model.Core; +using Dock.Serializer; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.ViewModels.Base; + +namespace StabilityMatrix.Avalonia.Controls.Dock; + +/// +/// Base for Dock controls +/// Expects a named "Dock" in the XAML +/// +public abstract class DockUserControlBase : DropTargetUserControlBase +{ + private DockControl? baseDock; + private readonly DockSerializer dockSerializer = new(typeof(AvaloniaList<>)); + private readonly DockState dockState = new(); + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + baseDock = + this.FindControl("Dock") + ?? throw new NullReferenceException("DockControl not found"); + + if (baseDock.Layout is { } layout) + { + dockState.Save(layout); + // Dispatcher.UIThread.Post(() => dockState.Save(layout), DispatcherPriority.Background); + } + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + // Attach handlers for view state saving and loading + if (DataContext is InferenceTabViewModelBase vm) + { + vm.SaveViewStateRequested += DataContext_OnSaveViewStateRequested; + vm.LoadViewStateRequested += DataContext_OnLoadViewStateRequested; + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + + // Detach handlers for view state saving and loading + if (DataContext is InferenceTabViewModelBase vm) + { + vm.SaveViewStateRequested -= DataContext_OnSaveViewStateRequested; + vm.LoadViewStateRequested -= DataContext_OnLoadViewStateRequested; + } + } + + private void DataContext_OnSaveViewStateRequested(object? sender, SaveViewStateEventArgs args) + { + var saveTcs = new TaskCompletionSource(); + + Dispatcher.UIThread.Post(() => + { + var state = new ViewState { DockLayout = SaveDockLayout() }; + saveTcs.SetResult(state); + }); + + args.StateTask ??= saveTcs.Task; + } + + private void DataContext_OnLoadViewStateRequested(object? sender, LoadViewStateEventArgs args) + { + if (args.State?.DockLayout is { } layout) + { + // Provided + LoadDockLayout(layout); + } + else + { + // Restore default + RestoreDockLayout(); + } + } + + private void LoadDockLayout(JsonObject data) + { + LoadDockLayout(data.ToJsonString()); + } + + private void LoadDockLayout(string data) + { + if (baseDock is null) + return; + + if (dockSerializer.Deserialize(data) is { } layout) + { + baseDock.Layout = layout; + } + } + + private void RestoreDockLayout() + { + // TODO: idk this doesn't work + if (baseDock?.Layout != null) + { + dockState.Restore(baseDock.Layout); + } + } + + protected string? SaveDockLayout() + { + return baseDock is null ? null : dockSerializer.Serialize(baseDock.Layout); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/DropTargetTemplatedControlBase.cs b/StabilityMatrix.Avalonia/Controls/DropTargetTemplatedControlBase.cs new file mode 100644 index 000000000..daa54ab21 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DropTargetTemplatedControlBase.cs @@ -0,0 +1,31 @@ +using Avalonia.Input; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Controls; + +public abstract class DropTargetTemplatedControlBase : TemplatedControlBase +{ + protected DropTargetTemplatedControlBase() + { + AddHandler(DragDrop.DropEvent, DropHandler); + AddHandler(DragDrop.DragOverEvent, DragOverHandler); + + DragDrop.SetAllowDrop(this, true); + } + + protected virtual void DragOverHandler(object? sender, DragEventArgs e) + { + if (DataContext is IDropTarget dropTarget) + { + dropTarget.DragOver(sender, e); + } + } + + protected virtual void DropHandler(object? sender, DragEventArgs e) + { + if (DataContext is IDropTarget dropTarget) + { + dropTarget.Drop(sender, e); + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/DropTargetUserControlBase.cs b/StabilityMatrix.Avalonia/Controls/DropTargetUserControlBase.cs new file mode 100644 index 000000000..f78a254e6 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DropTargetUserControlBase.cs @@ -0,0 +1,31 @@ +using Avalonia.Input; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Controls; + +public abstract class DropTargetUserControlBase : UserControlBase +{ + protected DropTargetUserControlBase() + { + AddHandler(DragDrop.DropEvent, DropHandler); + AddHandler(DragDrop.DragOverEvent, DragOverHandler); + + DragDrop.SetAllowDrop(this, true); + } + + private void DragOverHandler(object? sender, DragEventArgs e) + { + if (DataContext is IDropTarget dropTarget) + { + dropTarget.DragOver(sender, e); + } + } + + private void DropHandler(object? sender, DragEventArgs e) + { + if (DataContext is IDropTarget dropTarget) + { + dropTarget.Drop(sender, e); + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/EditorCommands.cs b/StabilityMatrix.Avalonia/Controls/EditorCommands.cs new file mode 100644 index 000000000..ba2e90979 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/EditorCommands.cs @@ -0,0 +1,16 @@ +using AvaloniaEdit; +using CommunityToolkit.Mvvm.Input; + +namespace StabilityMatrix.Avalonia.Controls; + +public static class EditorCommands +{ + public static RelayCommand CopyCommand { get; } = + new(editor => editor?.Copy(), editor => editor?.CanCopy ?? false); + + public static RelayCommand CutCommand { get; } = + new(editor => editor?.Cut(), editor => editor?.CanCut ?? false); + + public static RelayCommand PasteCommand { get; } = + new(editor => editor?.Paste(), editor => editor?.CanPaste ?? false); +} diff --git a/StabilityMatrix.Avalonia/Controls/EditorFlyouts.axaml b/StabilityMatrix.Avalonia/Controls/EditorFlyouts.axaml new file mode 100644 index 000000000..e52851b1d --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/EditorFlyouts.axaml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml b/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml new file mode 100644 index 000000000..fe399fce2 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml.cs b/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml.cs new file mode 100644 index 000000000..c6637b315 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/FrameCarousel.axaml.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using FluentAvalonia.UI.Navigation; +using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class FrameCarousel : SelectingItemsControl +{ + public static readonly StyledProperty ContentTemplateProperty = AvaloniaProperty.Register( + "ContentTemplate"); + + public IDataTemplate? ContentTemplate + { + get => GetValue(ContentTemplateProperty); + set => SetValue(ContentTemplateProperty, value); + } + + public static readonly StyledProperty SourcePageTypeProperty = AvaloniaProperty.Register( + "SourcePageType"); + + public Type SourcePageType + { + get => GetValue(SourcePageTypeProperty); + set => SetValue(SourcePageTypeProperty, value); + } + + private Frame? frame; + private int previousIndex = -1; + + private static readonly FrameNavigationOptions ForwardNavigationOptions + = new() + { + TransitionInfoOverride = new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromRight, + FromHorizontalOffset = 200 + } + }; + + private static readonly FrameNavigationOptions BackNavigationOptions + = new() + { + TransitionInfoOverride = new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromLeft, + FromHorizontalOffset = 200 + } + }; + + private static readonly FrameNavigationOptions DirectionlessNavigationOptions + = new() + { + TransitionInfoOverride = new SuppressNavigationTransitionInfo() + }; + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + frame = e.NameScope.Find("PART_Frame") + ?? throw new NullReferenceException("Frame not found"); + + frame.NavigationPageFactory = new FrameNavigationFactory(SourcePageType); + + if (SelectedItem is not null) + { + frame.NavigateFromObject(SelectedItem, DirectionlessNavigationOptions); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (frame is null) return; + + if (change.Property == SelectedItemProperty) + { + if (change.GetNewValue() is not { } value) return; + + if (SelectedIndex > previousIndex) + { + // Going forward + frame.NavigateFromObject(value, ForwardNavigationOptions); + } + else if (SelectedIndex < previousIndex) + { + // Going back + frame.NavigateFromObject(value, BackNavigationOptions); + } + else + { + frame.NavigateFromObject(value, DirectionlessNavigationOptions); + } + + previousIndex = SelectedIndex; + } + else if (change.Property == ItemCountProperty) + { + // On item count change to 0, clear the frame cache + var value = change.GetNewValue(); + + if (value == 0) + { + var pageCache = frame.GetPrivateField("_pageCache"); + pageCache?.Clear(); + } + } + } + + /// + /// Moves to the next item in the carousel. + /// + public void Next() + { + if (SelectedIndex < ItemCount - 1) + { + ++SelectedIndex; + } + } + + /// + /// Moves to the previous item in the carousel. + /// + public void Previous() + { + if (SelectedIndex > 0) + { + --SelectedIndex; + } + } + + internal class FrameNavigationFactory : INavigationPageFactory + { + private readonly Type _sourcePageType; + + public FrameNavigationFactory(Type sourcePageType) + { + _sourcePageType = sourcePageType; + } + + /// + public Control GetPage(Type srcType) + { + return (Control) Activator.CreateInstance(srcType)!; + } + + /// + public Control GetPageFromObject(object target) + { + var view = GetPage(_sourcePageType); + view.DataContext = target; + return view; + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml b/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml new file mode 100644 index 000000000..c37657984 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml.cs new file mode 100644 index 000000000..555b87d4a --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/FreeUCard.axaml.cs @@ -0,0 +1,5 @@ +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls; + +public class FreeUCard : TemplatedControl { } diff --git a/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml b/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml new file mode 100644 index 000000000..50a74bd76 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml.cs new file mode 100644 index 000000000..b36e1fe10 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ImageFolderCard.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia.Input; + +namespace StabilityMatrix.Avalonia.Controls; + +public class ImageFolderCard : DropTargetTemplatedControlBase +{ + /// + protected override void DropHandler(object? sender, DragEventArgs e) + { + base.DropHandler(sender, e); + e.Handled = true; + } + + /// + protected override void DragOverHandler(object? sender, DragEventArgs e) + { + base.DragOverHandler(sender, e); + e.Handled = true; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml b/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml new file mode 100644 index 000000000..3fe4071e1 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml.cs new file mode 100644 index 000000000..f36dff785 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ImageGalleryCard.axaml.cs @@ -0,0 +1,7 @@ +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls; + +public class ImageGalleryCard : TemplatedControl +{ +} diff --git a/StabilityMatrix.Avalonia/Controls/LineDashFrame.cs b/StabilityMatrix.Avalonia/Controls/LineDashFrame.cs new file mode 100644 index 000000000..1356d5137 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/LineDashFrame.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using FluentAvalonia.UI.Controls; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class LineDashFrame : Frame +{ + protected override Type StyleKeyOverride { get; } = typeof(Frame); + + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register("Stroke"); + + public ISolidColorBrush Stroke + { + get => GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + public static readonly StyledProperty StrokeThicknessProperty = + AvaloniaProperty.Register("StrokeThickness"); + + public double StrokeThickness + { + get => GetValue(StrokeThicknessProperty); + set => SetValue(StrokeThicknessProperty, value); + } + + public static readonly StyledProperty StrokeDashLineProperty = + AvaloniaProperty.Register("StrokeDashLine"); + + public double StrokeDashLine + { + get => GetValue(StrokeDashLineProperty); + set => SetValue(StrokeDashLineProperty, value); + } + + public static readonly StyledProperty StrokeDashSpaceProperty = + AvaloniaProperty.Register("StrokeDashSpace"); + + public double StrokeDashSpace + { + get => GetValue(StrokeDashSpaceProperty); + set => SetValue(StrokeDashSpaceProperty, value); + } + + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register("Fill"); + + public ISolidColorBrush Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + public LineDashFrame() + { + UseLayoutRounding = true; + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if ( + change.Property == StrokeProperty + || change.Property == StrokeThicknessProperty + || change.Property == StrokeDashLineProperty + || change.Property == StrokeDashSpaceProperty + || change.Property == FillProperty + ) + { + InvalidateVisual(); + } + } + + /// + public override void Render(DrawingContext context) + { + base.Render(context); + + var width = Bounds.Width; + var height = Bounds.Height; + + context.DrawRectangle(Fill, null, new Rect(0, 0, width, height)); + + var dashPen = new Pen(Stroke, StrokeThickness) + { + DashStyle = new DashStyle(GetDashArray(width), 0) + }; + + context.DrawLine(dashPen, new Point(0, 0), new Point(width, 0)); + context.DrawLine(dashPen, new Point(0, height), new Point(width, height)); + context.DrawLine(dashPen, new Point(0, 0), new Point(0, height)); + context.DrawLine(dashPen, new Point(width, 0), new Point(width, height)); + } + + private IEnumerable GetDashArray(double length) + { + var availableLength = length - StrokeDashLine; + var lines = (int)Math.Round(availableLength / (StrokeDashLine + StrokeDashSpace)); + availableLength -= lines * StrokeDashLine; + var actualSpacing = availableLength / lines; + + yield return StrokeDashLine / StrokeThickness; + yield return actualSpacing / StrokeThickness; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/ModelCard.axaml b/StabilityMatrix.Avalonia/Controls/ModelCard.axaml new file mode 100644 index 000000000..a3d19de98 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ModelCard.axaml @@ -0,0 +1,139 @@ + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/ModelCard.axaml.cs b/StabilityMatrix.Avalonia/Controls/ModelCard.axaml.cs new file mode 100644 index 000000000..e997a8741 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ModelCard.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls; + +public class ModelCard : TemplatedControl +{ +} \ No newline at end of file diff --git a/StabilityMatrix.Avalonia/Controls/Paginator.axaml b/StabilityMatrix.Avalonia/Controls/Paginator.axaml new file mode 100644 index 000000000..52cedd315 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Paginator.axaml @@ -0,0 +1,62 @@ + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/Paginator.axaml.cs b/StabilityMatrix.Avalonia/Controls/Paginator.axaml.cs new file mode 100644 index 000000000..9342ec12c --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/Paginator.axaml.cs @@ -0,0 +1,166 @@ +using System.Diagnostics.CodeAnalysis; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls.Primitives; +using AvaloniaEdit.Utils; +using CommunityToolkit.Mvvm.Input; + +namespace StabilityMatrix.Avalonia.Controls; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class Paginator : TemplatedControl +{ + private bool isFirstTemplateApplied; + private ICommand? firstPageCommandBinding; + private ICommand? previousPageCommandBinding; + private ICommand? nextPageCommandBinding; + private ICommand? lastPageCommandBinding; + + public static readonly StyledProperty CurrentPageNumberProperty = + AvaloniaProperty.Register("CurrentPageNumber", 1); + + public int CurrentPageNumber + { + get => GetValue(CurrentPageNumberProperty); + set => SetValue(CurrentPageNumberProperty, value); + } + + public static readonly StyledProperty TotalPagesProperty = AvaloniaProperty.Register< + Paginator, + int + >("TotalPages", 1); + + public int TotalPages + { + get => GetValue(TotalPagesProperty); + set => SetValue(TotalPagesProperty, value); + } + + public static readonly StyledProperty FirstPageCommandProperty = + AvaloniaProperty.Register("FirstPageCommand"); + + public ICommand? FirstPageCommand + { + get => GetValue(FirstPageCommandProperty); + set => SetValue(FirstPageCommandProperty, value); + } + + public static readonly StyledProperty PreviousPageCommandProperty = + AvaloniaProperty.Register("PreviousPageCommand"); + + public ICommand? PreviousPageCommand + { + get => GetValue(PreviousPageCommandProperty); + set => SetValue(PreviousPageCommandProperty, value); + } + + public static readonly StyledProperty NextPageCommandProperty = + AvaloniaProperty.Register("NextPageCommand"); + + public ICommand? NextPageCommand + { + get => GetValue(NextPageCommandProperty); + set => SetValue(NextPageCommandProperty, value); + } + + public static readonly StyledProperty LastPageCommandProperty = + AvaloniaProperty.Register("LastPageCommand"); + + public ICommand? LastPageCommand + { + get => GetValue(LastPageCommandProperty); + set => SetValue(LastPageCommandProperty, value); + } + + public static readonly StyledProperty CanNavForwardProperty = AvaloniaProperty.Register< + Paginator, + bool + >("CanNavForward"); + + public bool CanNavForward + { + get => GetValue(CanNavForwardProperty); + set => SetValue(CanNavForwardProperty, value); + } + + public static readonly StyledProperty CanNavBackProperty = AvaloniaProperty.Register< + Paginator, + bool + >("CanNavBack"); + + public bool CanNavBack + { + get => GetValue(CanNavBackProperty); + set => SetValue(CanNavBackProperty, value); + } + + /// + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (!isFirstTemplateApplied) + { + firstPageCommandBinding = FirstPageCommand; + previousPageCommandBinding = PreviousPageCommand; + nextPageCommandBinding = NextPageCommand; + lastPageCommandBinding = LastPageCommand; + isFirstTemplateApplied = true; + } + + // Wrap the commands + FirstPageCommand = new RelayCommand(() => + { + if (CurrentPageNumber > 1) + { + CurrentPageNumber = 1; + } + firstPageCommandBinding?.Execute(null); + }); + + PreviousPageCommand = new RelayCommand(() => + { + if (CurrentPageNumber > 1) + { + CurrentPageNumber--; + } + previousPageCommandBinding?.Execute(null); + }); + + NextPageCommand = new RelayCommand(() => + { + if (CurrentPageNumber < TotalPages) + { + CurrentPageNumber++; + } + nextPageCommandBinding?.Execute(null); + }); + + LastPageCommand = new RelayCommand(() => + { + if (CurrentPageNumber < TotalPages) + { + CurrentPageNumber = TotalPages; + } + lastPageCommandBinding?.Execute(null); + }); + } + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + // Update the CanNavForward and CanNavBack properties + if (change.Property == CurrentPageNumberProperty && change.NewValue is int) + { + CanNavForward = (int)change.NewValue < TotalPages; + CanNavBack = (int)change.NewValue > 1; + } + else if (change.Property == TotalPagesProperty && change.NewValue is int) + { + CanNavForward = CurrentPageNumber < (int)change.NewValue; + CanNavBack = CurrentPageNumber > 1; + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/PromptCard.axaml b/StabilityMatrix.Avalonia/Controls/PromptCard.axaml new file mode 100644 index 000000000..9b026ed67 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/PromptCard.axaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/DownloadResourceDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/DownloadResourceDialog.axaml.cs new file mode 100644 index 000000000..22d345166 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/DownloadResourceDialog.axaml.cs @@ -0,0 +1,27 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Core.Processes; + +namespace StabilityMatrix.Avalonia.Views.Dialogs; + +public partial class DownloadResourceDialog : UserControl +{ + public DownloadResourceDialog() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void LicenseButton_OnTapped(object? sender, TappedEventArgs e) + { + var url = ((DownloadResourceViewModel)DataContext!).Resource.LicenseUrl; + ProcessRunner.OpenUrl(url!.ToString()); + } +} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml index d768e0ab6..2612d4db7 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml @@ -38,7 +38,9 @@ - diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml new file mode 100644 index 000000000..0f0484dec --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/ImageViewerDialog.axaml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - + + + + Margin="0,8,0,0" /> - @@ -94,15 +104,27 @@ VerticalAlignment="Center" /> - + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs b/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs new file mode 100644 index 000000000..7dd4e5ed5 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/InferencePage.axaml.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Interactivity; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Views; + +public partial class InferencePage : UserControlBase +{ + private Button? _addButton; + private Button AddButton => + _addButton ??= this.FindControl("TabView")! + .GetTemplateChildren() + .OfType + + - + - - - - - - - - + + - - - + + + + + - - - - - - - - - - - - - - + VerticalAlignment="Stretch" + IsVisible="{Binding IsProgressVisible}" /> + + + + - - - - - + + + + - + - + IsOpen="{Binding !Packages.Count}" /> +