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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+