From d87b214029ea78dddb2544685b59fd0763fa818c Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Thu, 18 Jan 2024 20:37:53 +0100 Subject: [PATCH 1/6] :rocket: First push of addons enhancement & file type support --- SkEditor/API/AddonLoader.cs | 10 +- SkEditor/API/ApiVault.cs | 9 +- SkEditor/API/IAddon.cs | 17 +- SkEditor/API/ISkEditorAPI.cs | 2 +- SkEditor/App.axaml | 1 + SkEditor/Controls/BottomBarControl.axaml.cs | 2 + SkEditor/Controls/MainMenuControl.axaml | 3 + SkEditor/Controls/MainMenuControl.axaml.cs | 26 + SkEditor/Languages/English.xaml | 4 + SkEditor/SkEditor.csproj | 1 + SkEditor/Utilities/Files/FileBuilder.cs | 44 +- SkEditor/Utilities/Files/FileHandler.cs | 20 +- SkEditor/Utilities/Files/FileTypes.cs | 90 + .../Views/FileTypes/AdvancedImageBox.axaml | 60 + SkEditor/Views/FileTypes/ImageViewer.axaml | 27 + SkEditor/Views/FileTypes/ImageViewer.axaml.cs | 36 + .../FileTypes/Images/AdvancedImageBox.cs | 2701 +++++++++++++++++ SkEditor/Views/MainWindow.axaml.cs | 8 +- 18 files changed, 3044 insertions(+), 17 deletions(-) create mode 100644 SkEditor/Utilities/Files/FileTypes.cs create mode 100644 SkEditor/Views/FileTypes/AdvancedImageBox.axaml create mode 100644 SkEditor/Views/FileTypes/ImageViewer.axaml create mode 100644 SkEditor/Views/FileTypes/ImageViewer.axaml.cs create mode 100644 SkEditor/Views/FileTypes/Images/AdvancedImageBox.cs diff --git a/SkEditor/API/AddonLoader.cs b/SkEditor/API/AddonLoader.cs index bfdcabf7..690832c4 100644 --- a/SkEditor/API/AddonLoader.cs +++ b/SkEditor/API/AddonLoader.cs @@ -1,12 +1,12 @@ -using Avalonia.Controls; -using Serilog; -using SkEditor.Utilities; -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; +using Serilog; +using SkEditor.Utilities; +using SkEditor.Views; namespace SkEditor.API; public class AddonLoader @@ -69,6 +69,8 @@ public static void Load() { Log.Error(ex, "Failed to load addons"); } + + MainWindow.Instance.MainMenu.LoadAddonsMenus(); } public static List LoadAddonsFromFolder(string folder) diff --git a/SkEditor/API/ApiVault.cs b/SkEditor/API/ApiVault.cs index 77c3bc00..50f2d875 100644 --- a/SkEditor/API/ApiVault.cs +++ b/SkEditor/API/ApiVault.cs @@ -1,4 +1,6 @@ -namespace SkEditor.API; +using SkEditor.Utilities.Files; + +namespace SkEditor.API; public static class ApiVault { @@ -13,4 +15,9 @@ public static ISkEditorAPI Get() { return instance; } + + public static void RegisterFileAssociation(FileTypes.FileAssociation association) + { + FileTypes.RegisterExternalAssociation(association); + } } \ No newline at end of file diff --git a/SkEditor/API/IAddon.cs b/SkEditor/API/IAddon.cs index d78952ea..467ca550 100644 --- a/SkEditor/API/IAddon.cs +++ b/SkEditor/API/IAddon.cs @@ -1,8 +1,23 @@ -namespace SkEditor.API; +using System.Collections.Generic; +using Avalonia.Controls; +using FluentAvalonia.UI.Controls; +using SkEditor.Utilities.Files; + +namespace SkEditor.API; public interface IAddon { public string Name { get; } public string Version { get; } + public virtual List GetMenuItems() + { + return []; + } + + public virtual Symbol GetMenuIcon() + { + return Symbol.Document; + } + public void OnEnable(); } \ No newline at end of file diff --git a/SkEditor/API/ISkEditorAPI.cs b/SkEditor/API/ISkEditorAPI.cs index 2f6dfe0e..c22b0a2f 100644 --- a/SkEditor/API/ISkEditorAPI.cs +++ b/SkEditor/API/ISkEditorAPI.cs @@ -20,7 +20,7 @@ public interface ISkEditorAPI public bool IsFile(TabViewItem tabItem); - public TextEditor GetTextEditor(); + public TextEditor? GetTextEditor(); public TabView GetTabView(); diff --git a/SkEditor/App.axaml b/SkEditor/App.axaml index 0b001268..53ccd117 100644 --- a/SkEditor/App.axaml +++ b/SkEditor/App.axaml @@ -19,6 +19,7 @@ + diff --git a/SkEditor/Controls/BottomBarControl.axaml.cs b/SkEditor/Controls/BottomBarControl.axaml.cs index f6ad9cea..d5a8010f 100644 --- a/SkEditor/Controls/BottomBarControl.axaml.cs +++ b/SkEditor/Controls/BottomBarControl.axaml.cs @@ -5,6 +5,7 @@ using AvaloniaEdit.Document; using SkEditor.API; using SkEditor.Utilities; +using SkEditor.Utilities.Files; namespace SkEditor.Controls; public partial class BottomBarControl : UserControl @@ -17,6 +18,7 @@ public BottomBarControl() { Application.Current.ResourcesChanged += (sender, e) => UpdatePosition(); ApiVault.Get().GetTabView().SelectionChanged += (sender, e) => UpdatePosition(); + ApiVault.Get().GetTabView().SelectionChanged += (sender, e) => FileHandler.TabSwitchAction(); }; } diff --git a/SkEditor/Controls/MainMenuControl.axaml b/SkEditor/Controls/MainMenuControl.axaml index aefb3539..68c0876e 100644 --- a/SkEditor/Controls/MainMenuControl.axaml +++ b/SkEditor/Controls/MainMenuControl.axaml @@ -117,6 +117,9 @@ + + + diff --git a/SkEditor/Controls/MainMenuControl.axaml.cs b/SkEditor/Controls/MainMenuControl.axaml.cs index 427e8cf1..699199be 100644 --- a/SkEditor/Controls/MainMenuControl.axaml.cs +++ b/SkEditor/Controls/MainMenuControl.axaml.cs @@ -8,6 +8,7 @@ using SkEditor.Views.Generators; using SkEditor.Views.Generators.Gui; using System; +using FluentAvalonia.UI.Controls; namespace SkEditor.Controls; public partial class MainMenuControl : UserControl @@ -55,4 +56,29 @@ private void AssignCommands() MenuItemRefactor.Command = new RelayCommand(() => new RefactorWindow().ShowDialog(ApiVault.Get().GetMainWindow())); MenuItemMarketplace.Command = new RelayCommand(() => new MarketplaceWindow().ShowDialog(ApiVault.Get().GetMainWindow())); } + + public void LoadAddonsMenus() + { + bool hasAnyMenu = false; + foreach (IAddon addon in AddonLoader.Addons) + { + var items = addon.GetMenuItems(); + if (items.Count <= 0) + continue; + + hasAnyMenu = true; + var menuItem = new MenuItem() + { + Header = addon.Name, + Icon = new SymbolIcon() { Symbol = addon.GetMenuIcon() } + }; + + foreach (MenuItem sub in items) + menuItem.Items.Add(sub); + + AddonsMenuItem.Items.Add(menuItem); + } + + AddonsMenuItem.IsVisible = hasAnyMenu; + } } diff --git a/SkEditor/Languages/English.xaml b/SkEditor/Languages/English.xaml index 96b333fb..14466db3 100644 --- a/SkEditor/Languages/English.xaml +++ b/SkEditor/Languages/English.xaml @@ -19,6 +19,7 @@ Edit Tools Other + Addons New @@ -152,6 +153,9 @@ Addons + + + Antialiasing Editing: {0} diff --git a/SkEditor/SkEditor.csproj b/SkEditor/SkEditor.csproj index 7ac7fa11..fb42ceec 100644 --- a/SkEditor/SkEditor.csproj +++ b/SkEditor/SkEditor.csproj @@ -11,6 +11,7 @@ 2.1.0 2.1.0 2.1.0 + true CS8600;CS8604;CS8622;CS8603;CS8602;CA2211;CS8619;CS8629;CS8601;CS8618;CS8620 diff --git a/SkEditor/Utilities/Files/FileBuilder.cs b/SkEditor/Utilities/Files/FileBuilder.cs index 740e6db3..6756a93c 100644 --- a/SkEditor/Utilities/Files/FileBuilder.cs +++ b/SkEditor/Utilities/Files/FileBuilder.cs @@ -12,24 +12,30 @@ using SkEditor.Utilities.Editor; using SkEditor.Utilities.Syntax; using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using SkEditor.Views; namespace SkEditor.Utilities.Files; public class FileBuilder { + public static readonly Dictionary OpenedFiles = new(); + public static TabViewItem Build(string header, string path = "") { - TextEditor editor = GetDefaultEditor(path); + var fileType = GetFileDisplay(path); TabViewItem tabViewItem = new() { Header = header, IsSelected = true, - Content = editor, + Content = fileType.Display, Tag = string.Empty }; + MainWindow.Instance.BottomBar.IsVisible = fileType.NeedsBottomBar; + if (!string.IsNullOrWhiteSpace(path)) { tabViewItem.Tag = Uri.UnescapeDataString(path); @@ -45,12 +51,40 @@ public static TabViewItem Build(string header, string path = "") Icon.SetIcon(tabViewItem); } - ApiVault.Get().OnFileCreated(editor); + if (fileType.IsEditor) + ApiVault.Get().OnFileCreated(fileType.Display as TextEditor); + OpenedFiles.Add(header, fileType); return tabViewItem; } - private static TextEditor GetDefaultEditor(string path) + private static FileTypes.FileType GetFileDisplay(string path) + { + FileTypes.FileType? fileType = null; + if (FileTypes.RegisteredFileTypes.ContainsKey(Path.GetExtension(path))) + { + var handlers = FileTypes.RegisteredFileTypes[Path.GetExtension(path)]; + if (handlers.Count == 1) + { + fileType = handlers[0].Handle(path); + } + else + { + foreach (var handler in handlers) + { + fileType = handler.Handle(path); + if (fileType != null) + { + break; + } + } + } + } + + return fileType ?? GetDefaultEditor(path); + } + + private static FileTypes.FileType GetDefaultEditor(string path) { AppConfig config = ApiVault.Get().GetAppConfig(); @@ -90,7 +124,7 @@ private static TextEditor GetDefaultEditor(string path) editor = AddEventHandlers(editor); editor = SetOptions(editor); - return editor; + return new FileTypes.FileType(editor, path, true); } private static TextEditor AddEventHandlers(TextEditor editor) diff --git a/SkEditor/Utilities/Files/FileHandler.cs b/SkEditor/Utilities/Files/FileHandler.cs index 1f7754b5..b5c03a59 100644 --- a/SkEditor/Utilities/Files/FileHandler.cs +++ b/SkEditor/Utilities/Files/FileHandler.cs @@ -16,6 +16,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using SkEditor.Views; using Path = System.IO.Path; namespace SkEditor.Utilities.Files; @@ -34,6 +35,15 @@ public class FileHandler } catch { } }; + + public static readonly Action TabSwitchAction = () => + { + var item = ApiVault.Get().GetTabView().SelectedItem as TabViewItem; + if (item is null) + return; + var fileType = FileBuilder.OpenedFiles.GetValueOrDefault(item.Header.ToString()); + MainWindow.Instance.BottomBar.IsVisible = fileType.NeedsBottomBar; + }; private static int GetUntitledNumber() => (ApiVault.Get().GetTabView().TabItems as IList).Cast().Count(tab => RegexPattern.IsMatch(tab.Header.ToString())) + 1; @@ -47,10 +57,11 @@ public static void NewFile() public async static void OpenFile() { bool untitledFileOpen = ApiVault.Get().GetTabView().TabItems.Count() == 1 && - ApiVault.Get().GetTextEditor().Text.Length == 0 && - ApiVault.Get().GetTabView().SelectedItem is TabViewItem item && - item.Header.ToString().Contains(Translation.Get("NewFileNameFormat").Replace("{0}", "")) && - !item.Header.ToString().EndsWith('*'); + ApiVault.Get().GetTextEditor() != null && + ApiVault.Get().GetTextEditor().Text.Length == 0 && + ApiVault.Get().GetTabView().SelectedItem is TabViewItem item && + item.Header.ToString().Contains(Translation.Get("NewFileNameFormat").Replace("{0}", "")) && + !item.Header.ToString().EndsWith('*'); var topLevel = TopLevel.GetTopLevel(ApiVault.Get().GetMainWindow()); @@ -185,6 +196,7 @@ public static async Task CloseFile(TabViewItem item) tabItems?.Remove(item); if (tabItems.Count == 0) NewFile(); + FileBuilder.OpenedFiles.Remove(header); } private static void DisposeEditorData(TabViewItem item) diff --git a/SkEditor/Utilities/Files/FileTypes.cs b/SkEditor/Utilities/Files/FileTypes.cs new file mode 100644 index 00000000..8fd64070 --- /dev/null +++ b/SkEditor/Utilities/Files/FileTypes.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Media.Imaging; +using AvaloniaEdit; +using SkEditor.API; +using SkEditor.Views.FileTypes; + +namespace SkEditor.Utilities.Files; + +/// +/// Class handling opening of non-editor files (images, audio, etc.) +/// +public static class FileTypes +{ + + public static readonly Dictionary> RegisteredFileTypes = new(); + + public static void RegisterDefaultAssociations() + { + RegisterAssociation(new ImageAssociation()); + } + + public static void RegisterExternalAssociation(FileAssociation association) + { + association.IsFromAddon = true; + RegisterAssociation(association); + } + + private static void RegisterAssociation(FileAssociation association) + { + foreach (var extension in association.SupportedExtensions) + { + if (!RegisteredFileTypes.ContainsKey(extension)) + RegisteredFileTypes.Add(extension, new List()); + + RegisteredFileTypes[extension].Add(association); + } + } + + #region Classes + + public class FileType(object display, string path, bool needsBottomBar = false) + { + public object Display { get; set; } = display; + public string Path { get; set; } = path; + public bool NeedsBottomBar { get; set; } = needsBottomBar; + public bool IsEditor => Display is TextEditor; + } + + public abstract class FileAssociation + { + + public List SupportedExtensions { get; set; } + public bool IsFromAddon { get; set; } = false; + + public abstract FileType? Handle(string path); + } + + #endregion + + #region Default File Associations + + public class ImageAssociation : FileAssociation + { + public ImageAssociation() + { + SupportedExtensions = [".png", ".jpg", ".ico", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"]; + } + + public override FileType? Handle(string path) + { + try + { + var fileStream = File.OpenRead(path); + var bitmap = new Bitmap(fileStream); + fileStream.Close(); + + return new FileType(new ImageViewer(bitmap, path), path); + } + catch (Exception e) + { + ApiVault.Get().ShowError($"Unable to load the specified image:\n\n{e.Message}"); + return null; + } + } + } + + #endregion +} \ No newline at end of file diff --git a/SkEditor/Views/FileTypes/AdvancedImageBox.axaml b/SkEditor/Views/FileTypes/AdvancedImageBox.axaml new file mode 100644 index 00000000..c38334ad --- /dev/null +++ b/SkEditor/Views/FileTypes/AdvancedImageBox.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + \ No newline at end of file diff --git a/SkEditor/Views/FileTypes/ImageViewer.axaml b/SkEditor/Views/FileTypes/ImageViewer.axaml new file mode 100644 index 00000000..44729672 --- /dev/null +++ b/SkEditor/Views/FileTypes/ImageViewer.axaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/SkEditor/Views/FileTypes/ImageViewer.axaml.cs b/SkEditor/Views/FileTypes/ImageViewer.axaml.cs new file mode 100644 index 00000000..99f330ff --- /dev/null +++ b/SkEditor/Views/FileTypes/ImageViewer.axaml.cs @@ -0,0 +1,36 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; + +namespace SkEditor.Views.FileTypes; + +public partial class ImageViewer : UserControl +{ + + public ImageViewer(Bitmap image, string path) + { + InitializeComponent(); + + Image.Source = path; + Image.Image = image; + InformationText.Text = $"{Path.GetExtension(path).ToUpper()} Image ({image.Size.Width}x{image.Size.Height} pixels)"; + + AssignCommands(); + } + + private void AssignCommands() + { + AntialiasingModeToggle.IsCheckedChanged += AntialiasingModeToggleOnChecked; + } + + private void AntialiasingModeToggleOnChecked(object? sender, RoutedEventArgs e) + { + RenderOptions.SetBitmapInterpolationMode(Image, AntialiasingModeToggle.IsChecked == true + ? BitmapInterpolationMode.HighQuality + : BitmapInterpolationMode.None); + Image.InvalidateVisual(); + } + +} \ No newline at end of file diff --git a/SkEditor/Views/FileTypes/Images/AdvancedImageBox.cs b/SkEditor/Views/FileTypes/Images/AdvancedImageBox.cs new file mode 100644 index 00000000..9e68e199 --- /dev/null +++ b/SkEditor/Views/FileTypes/Images/AdvancedImageBox.cs @@ -0,0 +1,2701 @@ +namespace SkEditor.Views.FileTypes.Images; + +/* + * 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 AsyncImageLoader; +using Avalonia; +using Avalonia.Controls; +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; + +[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< + AdvancedImageBox, + bool + >(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< + AdvancedImageBox, + ISolidColorBrush + >(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< + AdvancedImageBox, + ISolidColorBrush + >(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); + }) + .ConfigureAwait(false); + } + } + } + + public static readonly StyledProperty ImageProperty = AvaloniaProperty.Register( + 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< + AdvancedImageBox, + bool + >(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< + AdvancedImageBox, + bool + >("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( + 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< + AdvancedImageBox, + bool + >(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< + AdvancedImageBox, + bool + >(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( + 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< + AdvancedImageBox, + MouseButtons + >(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< + AdvancedImageBox, + MouseButtons + >(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( + 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( + nameof(AllowZoom), + true + ); + + /// + /// Gets or sets if zoom is allowed + /// + public bool AllowZoom + { + get => GetValue(AllowZoomProperty); + set => SetValue(AllowZoomProperty, value); + } + + public static readonly DirectProperty 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( + 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( + 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< + AdvancedImageBox, + bool + >(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< + AdvancedImageBox, + int + >(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( + 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< + AdvancedImageBox, + ISolidColorBrush + >(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< + AdvancedImageBox, + int + >(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< + AdvancedImageBox, + SelectionModes + >(nameof(SelectionMode)); + + public static readonly StyledProperty IsPixelGridEnabledProperty = AvaloniaProperty.Register< + AdvancedImageBox, + bool + >("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< + AdvancedImageBox, + ISolidColorBrush + >(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(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + 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) + { + 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 +} \ No newline at end of file diff --git a/SkEditor/Views/MainWindow.axaml.cs b/SkEditor/Views/MainWindow.axaml.cs index b039a80e..6148cef6 100644 --- a/SkEditor/Views/MainWindow.axaml.cs +++ b/SkEditor/Views/MainWindow.axaml.cs @@ -18,6 +18,9 @@ namespace SkEditor.Views; public partial class MainWindow : AppWindow { + + public static MainWindow Instance { get; private set; } + public BottomBarControl GetBottomBar() => this.FindControl("BottomBar"); public MainWindow() @@ -30,6 +33,8 @@ public MainWindow() Translation.LoadDefaultLanguage(); Translation.ChangeLanguage(ApiVault.Get().GetAppConfig().Language); + + Instance = this; } private void AddEvents() @@ -74,7 +79,8 @@ private async void OnClosing(object sender, WindowClosingEventArgs e) private async void OnWindowLoaded(object sender, RoutedEventArgs e) { - AddonLoader.Load(); + AddonLoader.Load(); + Utilities.Files.FileTypes.RegisterDefaultAssociations(); ThemeEditor.SetTheme(ThemeEditor.CurrentTheme); From 579d1cbb65125ef5a3613925893d48812383e022 Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Thu, 18 Jan 2024 20:54:31 +0100 Subject: [PATCH 2/6] :zap: Refactored image-viewer related files --- SkEditor/App.axaml | 2 +- SkEditor/SkEditor.csproj | 4 ++++ SkEditor/Views/FileTypes/{ => Images}/AdvancedImageBox.axaml | 0 SkEditor/Views/FileTypes/{ => Images}/ImageViewer.axaml | 0 SkEditor/Views/FileTypes/{ => Images}/ImageViewer.axaml.cs | 0 5 files changed, 5 insertions(+), 1 deletion(-) rename SkEditor/Views/FileTypes/{ => Images}/AdvancedImageBox.axaml (100%) rename SkEditor/Views/FileTypes/{ => Images}/ImageViewer.axaml (100%) rename SkEditor/Views/FileTypes/{ => Images}/ImageViewer.axaml.cs (100%) diff --git a/SkEditor/App.axaml b/SkEditor/App.axaml index 53ccd117..e39ba116 100644 --- a/SkEditor/App.axaml +++ b/SkEditor/App.axaml @@ -19,7 +19,7 @@ - + diff --git a/SkEditor/SkEditor.csproj b/SkEditor/SkEditor.csproj index fb42ceec..73dd3793 100644 --- a/SkEditor/SkEditor.csproj +++ b/SkEditor/SkEditor.csproj @@ -129,6 +129,10 @@ ThemePage.axaml + + ImageViewer.axaml + Code + diff --git a/SkEditor/Views/FileTypes/AdvancedImageBox.axaml b/SkEditor/Views/FileTypes/Images/AdvancedImageBox.axaml similarity index 100% rename from SkEditor/Views/FileTypes/AdvancedImageBox.axaml rename to SkEditor/Views/FileTypes/Images/AdvancedImageBox.axaml diff --git a/SkEditor/Views/FileTypes/ImageViewer.axaml b/SkEditor/Views/FileTypes/Images/ImageViewer.axaml similarity index 100% rename from SkEditor/Views/FileTypes/ImageViewer.axaml rename to SkEditor/Views/FileTypes/Images/ImageViewer.axaml diff --git a/SkEditor/Views/FileTypes/ImageViewer.axaml.cs b/SkEditor/Views/FileTypes/Images/ImageViewer.axaml.cs similarity index 100% rename from SkEditor/Views/FileTypes/ImageViewer.axaml.cs rename to SkEditor/Views/FileTypes/Images/ImageViewer.axaml.cs From ca581a9579e7c38190ca6acaf8a38c7aee758692 Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Sat, 20 Jan 2024 19:27:27 +0100 Subject: [PATCH 3/6] :sparkles: Added association selection window & 'remember my choice' option --- SkEditor/Utilities/AppConfig.cs | 1 + SkEditor/Utilities/CrashChecker.cs | 4 +- SkEditor/Utilities/Files/FileBuilder.cs | 56 ++++++++++++++++--- SkEditor/Utilities/Files/FileHandler.cs | 16 +++--- .../Views/FileTypes/AssociationItemView.axaml | 20 +++++++ .../FileTypes/AssociationItemView.axaml.cs | 37 ++++++++++++ .../AssociationSelectionWindow.axaml | 34 +++++++++++ .../AssociationSelectionWindow.axaml.cs | 48 ++++++++++++++++ 8 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 SkEditor/Views/FileTypes/AssociationItemView.axaml create mode 100644 SkEditor/Views/FileTypes/AssociationItemView.axaml.cs create mode 100644 SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml create mode 100644 SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs diff --git a/SkEditor/Utilities/AppConfig.cs b/SkEditor/Utilities/AppConfig.cs index 02dd1b46..ac769a62 100644 --- a/SkEditor/Utilities/AppConfig.cs +++ b/SkEditor/Utilities/AppConfig.cs @@ -33,6 +33,7 @@ public class AppConfig public HashSet AddonsToUpdate { get; set; } = []; public Dictionary CustomOptions { get; set; } = []; + public Dictionary PreferredFileAssociations { get; set; } = []; public bool EnableAutoCompletionExperiment { get; set; } = false; diff --git a/SkEditor/Utilities/CrashChecker.cs b/SkEditor/Utilities/CrashChecker.cs index baffdd40..abbe83da 100644 --- a/SkEditor/Utilities/CrashChecker.cs +++ b/SkEditor/Utilities/CrashChecker.cs @@ -19,9 +19,9 @@ public static void CheckForCrash() string tempPath = Path.Combine(Path.GetTempPath(), "SkEditor"); if (!Directory.Exists(tempPath)) return; - Directory.GetFiles(tempPath).ToList().ForEach(file => + Directory.GetFiles(tempPath).ToList().ForEach(async file => { - TabViewItem tabItem = FileBuilder.Build(Path.GetFileName(file), file); + TabViewItem tabItem = await FileBuilder.Build(Path.GetFileName(file), file); tabItem.Tag = null; (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); }); diff --git a/SkEditor/Utilities/Files/FileBuilder.cs b/SkEditor/Utilities/Files/FileBuilder.cs index 6756a93c..be89b7e9 100644 --- a/SkEditor/Utilities/Files/FileBuilder.cs +++ b/SkEditor/Utilities/Files/FileBuilder.cs @@ -15,7 +15,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using SkEditor.Views; +using SkEditor.Views.FileTypes; namespace SkEditor.Utilities.Files; @@ -23,9 +25,9 @@ public class FileBuilder { public static readonly Dictionary OpenedFiles = new(); - public static TabViewItem Build(string header, string path = "") + public static async Task Build(string header, string path = "") { - var fileType = GetFileDisplay(path); + var fileType = await GetFileDisplay(path); TabViewItem tabViewItem = new() { Header = header, @@ -54,11 +56,14 @@ public static TabViewItem Build(string header, string path = "") if (fileType.IsEditor) ApiVault.Get().OnFileCreated(fileType.Display as TextEditor); + if (OpenedFiles.ContainsKey(header)) + OpenedFiles.Remove(header); + OpenedFiles.Add(header, fileType); return tabViewItem; } - private static FileTypes.FileType GetFileDisplay(string path) + private static async Task GetFileDisplay(string path) { FileTypes.FileType? fileType = null; if (FileTypes.RegisteredFileTypes.ContainsKey(Path.GetExtension(path))) @@ -70,17 +75,54 @@ private static FileTypes.FileType GetFileDisplay(string path) } else { - foreach (var handler in handlers) + var ext = Path.GetExtension(path); + if (ApiVault.Get().GetAppConfig().PreferredFileAssociations.ContainsKey(ext)) + { + var pref = ApiVault.Get().GetAppConfig().PreferredFileAssociations[ext]; + if (pref == "SkEditor") + { + fileType = handlers.Find(association => !association.IsFromAddon).Handle(path); + } + else + { + var preferred = handlers.Find(association => association.IsFromAddon && + association.Addon.Name == ApiVault.Get().GetAppConfig().PreferredFileAssociations[ext]); + if (preferred != null) + { + fileType = preferred.Handle(path); + } + else + { + ApiVault.Get().GetAppConfig().PreferredFileAssociations.Remove(ext); + } + } + } + + if (fileType == null) { - fileType = handler.Handle(path); - if (fileType != null) + var window = new AssociationSelectionWindow(path, handlers); + await window.ShowDialog(MainWindow.Instance); + var selected = window.SelectedAssociation; + ApiVault.Get().Log("Selected: " + (selected == null ? "null" : ( + selected.IsFromAddon ? selected.Addon.Name : "SkEditor"))); + if (selected != null) { - break; + fileType = selected.Handle(path); + if (window.RememberCheck.IsChecked == true) + { + ApiVault.Get().GetAppConfig().PreferredFileAssociations[ext] = selected.IsFromAddon ? selected.Addon.Name : "SkEditor"; + } + else + { + ApiVault.Get().GetAppConfig().PreferredFileAssociations.Remove(ext); + } + ApiVault.Get().GetAppConfig().Save(); } } } } + ApiVault.Get().Log($"FileBuilder.GetFileDisplay: {path} => {fileType?.Display.GetType().Name ?? "null"}"); return fileType ?? GetDefaultEditor(path); } diff --git a/SkEditor/Utilities/Files/FileHandler.cs b/SkEditor/Utilities/Files/FileHandler.cs index b5c03a59..3b3e0cbc 100644 --- a/SkEditor/Utilities/Files/FileHandler.cs +++ b/SkEditor/Utilities/Files/FileHandler.cs @@ -36,21 +36,22 @@ public class FileHandler catch { } }; - public static readonly Action TabSwitchAction = () => + public static void TabSwitchAction() { var item = ApiVault.Get().GetTabView().SelectedItem as TabViewItem; if (item is null) return; var fileType = FileBuilder.OpenedFiles.GetValueOrDefault(item.Header.ToString()); - MainWindow.Instance.BottomBar.IsVisible = fileType.NeedsBottomBar; - }; + if (fileType != null) + MainWindow.Instance.BottomBar.IsVisible = fileType.NeedsBottomBar; + } private static int GetUntitledNumber() => (ApiVault.Get().GetTabView().TabItems as IList).Cast().Count(tab => RegexPattern.IsMatch(tab.Header.ToString())) + 1; - public static void NewFile() + public static async void NewFile() { string header = Translation.Get("NewFileNameFormat").Replace("{0}", GetUntitledNumber().ToString()); - TabViewItem tabItem = FileBuilder.Build(header); + TabViewItem tabItem = await FileBuilder.Build(header); (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); } @@ -75,7 +76,7 @@ public async static void OpenFile() if (untitledFileOpen) await CloseFile((ApiVault.Get().GetTabView().TabItems as IList)[0] as TabViewItem); } - public static void OpenFile(string path) + public static async void OpenFile(string path) { if ((ApiVault.Get().GetTabView().TabItems as IList).Cast().Any(tab => tab.Tag.ToString().Equals(path))) { @@ -84,7 +85,7 @@ public static void OpenFile(string path) } string fileName = Uri.UnescapeDataString(Path.GetFileName(path)); - TabViewItem tabItem = FileBuilder.Build(fileName, path); + TabViewItem tabItem = await FileBuilder.Build(fileName, path); (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); } @@ -197,6 +198,7 @@ public static async Task CloseFile(TabViewItem item) if (tabItems.Count == 0) NewFile(); FileBuilder.OpenedFiles.Remove(header); + TabSwitchAction(); } private static void DisposeEditorData(TabViewItem item) diff --git a/SkEditor/Views/FileTypes/AssociationItemView.axaml b/SkEditor/Views/FileTypes/AssociationItemView.axaml new file mode 100644 index 00000000..5a539a6e --- /dev/null +++ b/SkEditor/Views/FileTypes/AssociationItemView.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/SkEditor/Views/FileTypes/AssociationItemView.axaml.cs b/SkEditor/Views/FileTypes/AssociationItemView.axaml.cs new file mode 100644 index 00000000..27b5c3ee --- /dev/null +++ b/SkEditor/Views/FileTypes/AssociationItemView.axaml.cs @@ -0,0 +1,37 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using FluentAvalonia.UI.Controls; + +namespace SkEditor.Views.FileTypes; + +public partial class AssociationItemView : UserControl +{ + + public static readonly AvaloniaProperty AssociationSource = AvaloniaProperty.Register(nameof(AssociationSource)); + public static readonly AvaloniaProperty AssociationDescription = AvaloniaProperty.Register(nameof(AssociationDescription)); + + public string Source + { + get => GetValue(AssociationSource)?.ToString(); + set => SetValue(AssociationSource, value); + } + + public string Description + { + get => GetValue(AssociationDescription)?.ToString(); + set => SetValue(AssociationDescription, value); + } + + public AssociationItemView() + { + InitializeComponent(); + + DataContext = this; + } + + public void UpdateIcon(Symbol symbol) + { + Icon.Symbol = symbol; + } +} \ No newline at end of file diff --git a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml new file mode 100644 index 00000000..2b081b9c --- /dev/null +++ b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + Remember my choice + + + + + diff --git a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs new file mode 100644 index 00000000..3124ec5b --- /dev/null +++ b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Windowing; +using SkEditor.Utilities.Styling; + +namespace SkEditor.Views.FileTypes; + +public partial class AssociationSelectionWindow : AppWindow +{ + + public Utilities.Files.FileTypes.FileAssociation? SelectedAssociation { get; set; } + + public AssociationSelectionWindow(string path, + List fileTypes) + { + InitializeComponent(); + WindowStyler.Style(this); + + fileTypes.Sort((a, b) => a.IsFromAddon.CompareTo(b.IsFromAddon)); + SelectedAssociation = fileTypes.Find(association => !association.IsFromAddon); + + foreach (var association in fileTypes) + { + var item = new AssociationItemView + { + Source = association.IsFromAddon ? association.Addon.Name : "SkEditor", + Description = association.IsFromAddon ? "This is an unofficial file type from an addon." : "This is an official file type from SkEditor.", + Tag = association.IsFromAddon ? association.Addon.Name : "SkEditor" + }; + item.UpdateIcon(association.IsFromAddon ? Symbol.Edit : Symbol.Checkmark); + Associations.Items.Add(item); + + if (!association.IsFromAddon) + Associations.SelectedItem = item; + } + + Associations.SelectionChanged += (_, _) => + { + if (Associations.SelectedItem is not AssociationItemView item) + return; + + SelectedAssociation = fileTypes.Find(association => association.IsFromAddon && association.Addon.Name == item.Tag.ToString()); + }; + + ConfirmButton.Click += (_, _) => Close(); + } + +} \ No newline at end of file From cb88d878753718d157548ef2bb5b2332e5b1148d Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Sat, 20 Jan 2024 19:27:58 +0100 Subject: [PATCH 4/6] :bug: Fixed special characters crashing SkEditor when opening images --- SkEditor/Utilities/Files/FileTypes.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/SkEditor/Utilities/Files/FileTypes.cs b/SkEditor/Utilities/Files/FileTypes.cs index 8fd64070..afafb440 100644 --- a/SkEditor/Utilities/Files/FileTypes.cs +++ b/SkEditor/Utilities/Files/FileTypes.cs @@ -24,6 +24,12 @@ public static void RegisterDefaultAssociations() public static void RegisterExternalAssociation(FileAssociation association) { association.IsFromAddon = true; + if (association.Addon == null) + { + ApiVault.Get().ShowError($"Unable to register file association for {association.GetType().Name}:\n\nAddon is null"); + return; + } + RegisterAssociation(association); } @@ -53,6 +59,8 @@ public abstract class FileAssociation public List SupportedExtensions { get; set; } public bool IsFromAddon { get; set; } = false; + + public IAddon? Addon { get; set; } = null; public abstract FileType? Handle(string path); } @@ -72,7 +80,7 @@ public ImageAssociation() { try { - var fileStream = File.OpenRead(path); + var fileStream = File.OpenRead(Uri.UnescapeDataString(path)); var bitmap = new Bitmap(fileStream); fileStream.Close(); From f2224a162801db4afb4bf8b995ca78142f6793c5 Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Sat, 20 Jan 2024 20:06:51 +0100 Subject: [PATCH 5/6] :sparkles: Fixed conflicts and added localization for file association window --- SkEditor/Languages/English.xaml | 8 ++++++++ SkEditor/Utilities/Files/FileBuilder.cs | 14 +++++++------- SkEditor/Utilities/Files/FileHandler.cs | 5 ++--- SkEditor/Utilities/Syntax/SyntaxLoader.cs | 2 ++ .../FileTypes/AssociationSelectionWindow.axaml | 8 ++++---- .../FileTypes/AssociationSelectionWindow.axaml.cs | 5 ++++- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/SkEditor/Languages/English.xaml b/SkEditor/Languages/English.xaml index 14466db3..7233fa1e 100644 --- a/SkEditor/Languages/English.xaml +++ b/SkEditor/Languages/English.xaml @@ -94,6 +94,14 @@ Remove comments Convert tabs to spaces Convert spaces to tabs + + + Select file association + There's multiple provider for this file type. Please select one. + This is an official file type from SkEditor. + This is an unofficial file type from an addon, {0}. + Remember my choice + Confirm Enabled diff --git a/SkEditor/Utilities/Files/FileBuilder.cs b/SkEditor/Utilities/Files/FileBuilder.cs index fc572b0d..71db00f9 100644 --- a/SkEditor/Utilities/Files/FileBuilder.cs +++ b/SkEditor/Utilities/Files/FileBuilder.cs @@ -53,8 +53,11 @@ public static async Task Build(string header, string path = "") Icon.SetIcon(tabViewItem); } - if (fileType.IsEditor) { - ApiVault.Get().OnFileCreated(fileType.Display as TextEditor); + if (fileType.IsEditor) + { + var editor = fileType.Display as TextEditor; + + ApiVault.Get().OnFileCreated(editor); Dispatcher.UIThread.Post(() => TextEditorEventHandler.CheckForHex(editor)); } @@ -105,8 +108,6 @@ public static async Task Build(string header, string path = "") var window = new AssociationSelectionWindow(path, handlers); await window.ShowDialog(MainWindow.Instance); var selected = window.SelectedAssociation; - ApiVault.Get().Log("Selected: " + (selected == null ? "null" : ( - selected.IsFromAddon ? selected.Addon.Name : "SkEditor"))); if (selected != null) { fileType = selected.Handle(path); @@ -124,11 +125,10 @@ public static async Task Build(string header, string path = "") } } - ApiVault.Get().Log($"FileBuilder.GetFileDisplay: {path} => {fileType?.Display.GetType().Name ?? "null"}"); - return fileType ?? GetDefaultEditor(path); + return fileType ?? await GetDefaultEditor(path); } - private static FileTypes.FileType GetDefaultEditor(string path) + private static async Task GetDefaultEditor(string path) { AppConfig config = ApiVault.Get().GetAppConfig(); diff --git a/SkEditor/Utilities/Files/FileHandler.cs b/SkEditor/Utilities/Files/FileHandler.cs index 89e70a06..68e168c7 100644 --- a/SkEditor/Utilities/Files/FileHandler.cs +++ b/SkEditor/Utilities/Files/FileHandler.cs @@ -39,11 +39,10 @@ public class FileHandler public static void TabSwitchAction() { var item = ApiVault.Get().GetTabView().SelectedItem as TabViewItem; - if (item is null) + if (item is null) return; var fileType = FileBuilder.OpenedFiles.GetValueOrDefault(item.Header.ToString()); - if (fileType != null) - MainWindow.Instance.BottomBar.IsVisible = fileType.NeedsBottomBar; + MainWindow.Instance.BottomBar.IsVisible = fileType?.NeedsBottomBar ?? true; } private static int GetUntitledNumber() => (ApiVault.Get().GetTabView().TabItems as IList).Cast().Count(tab => RegexPattern.IsMatch(tab.Header.ToString())) + 1; diff --git a/SkEditor/Utilities/Syntax/SyntaxLoader.cs b/SkEditor/Utilities/Syntax/SyntaxLoader.cs index 2f4170f1..be93ad18 100644 --- a/SkEditor/Utilities/Syntax/SyntaxLoader.cs +++ b/SkEditor/Utilities/Syntax/SyntaxLoader.cs @@ -179,6 +179,8 @@ public static async Task RefreshSyntaxAsync(string? extension = null) { var defaultSyntax = await GetDefaultSyntax(); var editor = ApiVault.Get().GetTextEditor(); + if (editor == null) + return; if (extension == null) { diff --git a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml index 2b081b9c..1c3dac36 100644 --- a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml +++ b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml @@ -6,7 +6,7 @@ mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" Width="600" Height="400" x:Class="SkEditor.Views.FileTypes.AssociationSelectionWindow" Icon="/Assets/SkEditor.ico" - Title="{DynamicResource WindowTitleMarketplace}" CanResize="False" WindowStartupLocation="CenterOwner"> + Title="{DynamicResource FileAssociationSelectionWindowTitle}" CanResize="False" WindowStartupLocation="CenterOwner"> @@ -18,7 +18,7 @@ @@ -26,8 +26,8 @@ - Remember my choice - + + diff --git a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs index 3124ec5b..fbbca9da 100644 --- a/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs +++ b/SkEditor/Views/FileTypes/AssociationSelectionWindow.axaml.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Windowing; +using SkEditor.Utilities; using SkEditor.Utilities.Styling; namespace SkEditor.Views.FileTypes; @@ -24,7 +25,9 @@ public AssociationSelectionWindow(string path, var item = new AssociationItemView { Source = association.IsFromAddon ? association.Addon.Name : "SkEditor", - Description = association.IsFromAddon ? "This is an unofficial file type from an addon." : "This is an official file type from SkEditor.", + Description = association.IsFromAddon + ? Translation.Get("FileAssociationSelectionWindowAddonItem", association.Addon.Name) + : Translation.Get("FileAssociationSelectionWindowOfficialItem"), Tag = association.IsFromAddon ? association.Addon.Name : "SkEditor" }; item.UpdateIcon(association.IsFromAddon ? Symbol.Edit : Symbol.Checkmark); From f166440ae84eab71364eb4d3f7eef1965be43357 Mon Sep 17 00:00:00 2001 From: Nicolas RACOT Date: Wed, 24 Jan 2024 11:17:42 +0100 Subject: [PATCH 6/6] :sparkles: Added binary file chekcer & warning --- SkEditor/Languages/English.xaml | 2 ++ SkEditor/Utilities/CrashChecker.cs | 2 ++ SkEditor/Utilities/Files/FileBuilder.cs | 28 +++++++++++++++++++++---- SkEditor/Utilities/Files/FileHandler.cs | 6 ++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/SkEditor/Languages/English.xaml b/SkEditor/Languages/English.xaml index 7233fa1e..f81e6b00 100644 --- a/SkEditor/Languages/English.xaml +++ b/SkEditor/Languages/English.xaml @@ -81,6 +81,8 @@ Update available An update is available.\nWould you like to download it now? Failed to download the update. Please try again later. + It seems you're trying to open a binary file (or a non text-based file).\n\nDo you want to open it anyway? SkEditor may not handle it correctly. + Binary File Found diff --git a/SkEditor/Utilities/CrashChecker.cs b/SkEditor/Utilities/CrashChecker.cs index 1257cf59..463284d2 100644 --- a/SkEditor/Utilities/CrashChecker.cs +++ b/SkEditor/Utilities/CrashChecker.cs @@ -23,6 +23,8 @@ public async static Task CheckForCrash() Directory.GetFiles(tempPath).ToList().ForEach(async file => { TabViewItem tabItem = await FileBuilder.Build(Path.GetFileName(file), file); + if (tabItem == null) + return; tabItem.Tag = null; (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); }); diff --git a/SkEditor/Utilities/Files/FileBuilder.cs b/SkEditor/Utilities/Files/FileBuilder.cs index 71db00f9..aaaa2cbf 100644 --- a/SkEditor/Utilities/Files/FileBuilder.cs +++ b/SkEditor/Utilities/Files/FileBuilder.cs @@ -25,9 +25,12 @@ public class FileBuilder { public static readonly Dictionary OpenedFiles = new(); - public static async Task Build(string header, string path = "") + public static async Task Build(string header, string path = "") { var fileType = await GetFileDisplay(path); + if (fileType == null) + return null; + TabViewItem tabViewItem = new() { Header = header, @@ -68,7 +71,7 @@ public static async Task Build(string header, string path = "") return tabViewItem; } - private static async Task GetFileDisplay(string path) + private static async Task GetFileDisplay(string path) { FileTypes.FileType? fileType = null; if (FileTypes.RegisteredFileTypes.ContainsKey(Path.GetExtension(path))) @@ -128,10 +131,27 @@ public static async Task Build(string header, string path = "") return fileType ?? await GetDefaultEditor(path); } - private static async Task GetDefaultEditor(string path) + private static async Task GetDefaultEditor(string path) { AppConfig config = ApiVault.Get().GetAppConfig(); + string fileContent = null; + if (!string.IsNullOrWhiteSpace(path)) + { + path = Uri.UnescapeDataString(path); + if (File.Exists(path)) + fileContent = await File.ReadAllTextAsync(path); + } + + if (fileContent != null && fileContent.Any(c => char.IsControl(c) && c != '\n' && c != '\r' && c != '\t')) + { + var response = await ApiVault.Get().ShowMessageWithIcon( + Translation.Get("BinaryFileTitle"), Translation.Get("BinaryFileFound"), + new SymbolIconSource() { Symbol = Symbol.Alert }); + if (response != ContentDialogResult.Primary) + return null; + } + TextEditor editor = new() { ShowLineNumbers = true, @@ -159,7 +179,7 @@ public static async Task Build(string header, string path = "") path = Uri.UnescapeDataString(path); if (File.Exists(path)) { - editor.Text = await File.ReadAllTextAsync(path); + editor.Text = fileContent; } } diff --git a/SkEditor/Utilities/Files/FileHandler.cs b/SkEditor/Utilities/Files/FileHandler.cs index 68e168c7..42172e9a 100644 --- a/SkEditor/Utilities/Files/FileHandler.cs +++ b/SkEditor/Utilities/Files/FileHandler.cs @@ -51,6 +51,9 @@ public static async void NewFile() { string header = Translation.Get("NewFileNameFormat").Replace("{0}", GetUntitledNumber().ToString()); TabViewItem tabItem = await FileBuilder.Build(header); + if (tabItem == null) + return; + (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); } @@ -85,6 +88,9 @@ public static async void OpenFile(string path) string fileName = Uri.UnescapeDataString(Path.GetFileName(path)); TabViewItem tabItem = await FileBuilder.Build(fileName, path); + if (tabItem == null) + return; + (ApiVault.Get().GetTabView().TabItems as IList)?.Add(tabItem); await SyntaxLoader.RefreshSyntaxAsync(Path.GetExtension(path));