diff --git a/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs b/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs new file mode 100644 index 00000000..23a0dee3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Behaviors/TextEditorCompletionBehavior.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Input; +using Avalonia.Xaml.Interactivity; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using NLog; +using StabilityMatrix.Avalonia.Controls.CodeCompletion; +using StabilityMatrix.Avalonia.Models; +using CompletionWindow = StabilityMatrix.Avalonia.Controls.CodeCompletion.CompletionWindow; + +namespace StabilityMatrix.Avalonia.Behaviors; + +public class TextEditorCompletionBehavior : Behavior +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private TextEditor textEditor = null!; + + private CompletionWindow? completionWindow; + + // ReSharper disable once MemberCanBePrivate.Global + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + + if (AssociatedObject is not { } editor) + { + throw new NullReferenceException("AssociatedObject is null"); + } + + textEditor = editor; + textEditor.TextArea.TextEntered += TextArea_TextEntered; + textEditor.TextArea.TextEntering += TextArea_TextEntering; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + + textEditor.TextArea.TextEntered -= TextArea_TextEntered; + textEditor.TextArea.TextEntering -= TextArea_TextEntering; + } + + private CompletionWindow CreateCompletionWindow(TextArea textArea) + { + var window = new CompletionWindow(textArea) + { + WindowManagerAddShadowHint = false, + CloseWhenCaretAtBeginning = true, + CloseAutomatically = true, + IsLightDismissEnabled = true, + CompletionList = + { + IsFiltering = true + } + }; + + var completionList = window.CompletionList; + + completionList.CompletionData.Add(new CompletionData("item1")); + completionList.CompletionData.Add(new CompletionData("item2")); + completionList.CompletionData.Add(new CompletionData("item3")); + + return window; + } + + private void TextArea_TextEntered(object? sender, TextInputEventArgs e) + { + if (e.Text is not { } triggerText) return; + + if (triggerText.All(char.IsLetterOrDigit)) + { + // Create completion window if its not already created + if (completionWindow == null) + { + // Get the segment of the token the caret is currently in + if (GetCaretToken(textEditor) is not { } tokenSegment) + { + Logger.Trace("Token segment not found"); + return; + } + + var token = textEditor.Document.GetText(tokenSegment); + Logger.Trace("Using token {Token} ({@Segment})", token, tokenSegment); + + completionWindow = CreateCompletionWindow(textEditor.TextArea); + completionWindow.StartOffset = tokenSegment.Offset; + completionWindow.EndOffset = tokenSegment.EndOffset; + + completionWindow.CompletionList.SelectItem(token); + + completionWindow.Closed += delegate + { + completionWindow = null; + }; + + completionWindow.Show(); + } + } + } + + private void TextArea_TextEntering(object? sender, TextInputEventArgs e) + { + if (completionWindow is null) return; + + // When completion window is open, parse and update token offsets + if (GetCaretToken(textEditor) is not { } tokenSegment) + { + Logger.Trace("Token segment not found"); + return; + } + + completionWindow.StartOffset = tokenSegment.Offset; + completionWindow.EndOffset = tokenSegment.EndOffset; + + /*if (e.Text?.Length > 0) { + if (!char.IsLetterOrDigit(e.Text[0])) { + // Whenever a non-letter is typed while the completion window is open, + // insert the currently selected element. + completionWindow?.CompletionList.RequestInsertion(e); + } + }*/ + // Do not set e.Handled=true. + // We still want to insert the character that was typed. + } + + /// + /// Gets a segment of the token the caret is currently in. + /// + private static ISegment? GetCaretToken(TextEditor textEditor) + { + var caret = textEditor.CaretOffset; + + // Search for the start and end of a token + // A token is defined as either alphanumeric chars or a space + var start = caret; + while (start > 0 && char.IsLetterOrDigit(textEditor.Document.GetCharAt(start - 1))) + { + start--; + } + + var end = caret; + while (end < textEditor.Document.TextLength && char.IsLetterOrDigit(textEditor.Document.GetCharAt(end))) + { + end++; + } + + return start < end ? new TextSegment { StartOffset = start, EndOffset = end } : null; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs new file mode 100644 index 00000000..f0cb3474 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionData.cs @@ -0,0 +1,128 @@ +using System; +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Styles; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Provides entries in AvaloniaEdit completion window. +/// +public class CompletionData : ICompletionData +{ + /// + public string Text { get; } + + /// + public string? Description { get; init; } + + /// + public IImage? Image { get; set; } + + /// + public IconData? Icon { get; init; } + + /// + /// Cached instance. + /// + private object? _content; + + /// + public object Content => _content ??= new TextBlock + { + Inlines = CreateInlines() + }; + + /// + /// Get the current inlines + /// + public InlineCollection TextInlines => ((TextBlock) Content).Inlines!; + + /// + public double Priority { get; } + + public CompletionData(string text) + { + Text = text; + } + + /// + /// Create text block inline runs from text. + /// + private InlineCollection CreateInlines() + { + // Create a span for each character in the text. + var chars = Text.ToCharArray(); + var inlines = new InlineCollection(); + + foreach (var c in chars) + { + var run = new Run(c.ToString()); + inlines.Add(run); + } + + return inlines; + } + + /// + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + textArea.Document.Replace(completionSegment, Text); + } + + /// + public void UpdateCharHighlighting(string searchText) + { + if (TextInlines is null) + { + throw new NullReferenceException("TextContent is null"); + } + + Debug.WriteLine($"Updating char highlighting for {Text} with search text {searchText}"); + + var defaultColor = ThemeColors.CompletionForegroundBrush; + var highlightColor = ThemeColors.CompletionSelectionForegroundBrush; + + // Match characters in the text with the search text from the start + foreach (var (i, currentChar) in Text.Enumerate()) + { + var inline = TextInlines[i]; + + // If longer than text, set to default color + if (i >= searchText.Length) + { + inline.Foreground = defaultColor; + continue; + } + + // If char matches, highlight it + if (currentChar == searchText[i]) + { + inline.Foreground = highlightColor; + } + // For mismatch, set to default color + else + { + inline.Foreground = defaultColor; + } + } + } + + /// + public void ResetCharHighlighting() + { + // TODO: handle light theme foreground variant + var defaultColor = ThemeColors.CompletionForegroundBrush; + + foreach (var inline in TextInlines) + { + inline.Foreground = defaultColor; + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs new file mode 100644 index 00000000..8446b929 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionIcons.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Projektanker.Icons.Avalonia; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Avalonia.Styles; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public static class CompletionIcons +{ + public static readonly IconData General = new() + { + FAIcon = "fa-solid fa-star-of-life", + Foreground = ThemeColors.LightSteelBlue, + }; + + public static readonly IconData Artist = new() + { + FAIcon = "fa-solid fa-palette", + Foreground = ThemeColors.AmericanYellow, + }; + + public static readonly IconData Character = new() + { + FAIcon = "fa-solid fa-user", + Foreground = ThemeColors.LuminousGreen, + }; + + public static readonly IconData Copyright = new() + { + FAIcon = "fa-solid fa-copyright", + Foreground = ThemeColors.DeepMagenta, + }; + + public static readonly IconData Species = new() + { + FAIcon = "fa-solid fa-dragon", + FontSize = 14, + Foreground = ThemeColors.HalloweenOrange, + }; + + public static readonly IconData Invalid = new() + { + FAIcon = "fa-solid fa-question", + Foreground = ThemeColors.CompletionForegroundBrush, + }; + + public static readonly IconData Keyword = new() + { + FAIcon = "fa-solid fa-key", + Foreground = ThemeColors.CompletionForegroundBrush, + }; + + public static IconData? GetIconForTagType(TagType tagType) + { + return tagType switch + { + TagType.General => General, + TagType.Artist => Artist, + TagType.Character => Character, + TagType.Species => Species, + TagType.Invalid => Invalid, + TagType.Copyright => Copyright, + _ => null + }; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs new file mode 100644 index 00000000..785ec041 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionList.cs @@ -0,0 +1,480 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml.Templates; +using AvaloniaEdit.Utils; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The listbox used inside the CompletionWindow, contains CompletionListBox. +/// +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +public class CompletionList : TemplatedControl +{ + public CompletionList() + { + DoubleTapped += OnDoubleTapped; + + CompletionAcceptKeys = new[] { Key.Enter, Key.Tab, }; + } + + /// + /// If true, the CompletionList is filtered to show only matching items. Also enables search by substring. + /// If false, enables the old behavior: no filtering, search by string.StartsWith. + /// + public bool IsFiltering { get; set; } = true; + + /// + /// Dependency property for . + /// + public static readonly StyledProperty EmptyTemplateProperty = + AvaloniaProperty.Register(nameof(EmptyTemplate)); + + /// + /// Content of EmptyTemplate will be shown when CompletionList contains no items. + /// If EmptyTemplate is null, nothing will be shown. + /// + public ControlTemplate EmptyTemplate + { + get => GetValue(EmptyTemplateProperty); + set => SetValue(EmptyTemplateProperty, value); + } + + /// + /// Dependency property for . + /// + public static readonly StyledProperty FooterTextProperty = AvaloniaProperty.Register( + "FooterText", "Press Enter to insert, Tab to replace"); + + /// + /// Gets/Sets the text displayed in the footer of the completion list. + /// + public string? FooterText + { + get => GetValue(FooterTextProperty); + set => SetValue(FooterTextProperty, value); + } + + /// + /// Is raised when the completion list indicates that the user has chosen + /// an entry to be completed. + /// + public event EventHandler? InsertionRequested; + + /// + /// Raises the InsertionRequested event. + /// + public void RequestInsertion(EventArgs e) + { + InsertionRequested?.Invoke(this, e); + } + + private CompletionListBox? _listBox; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + _listBox = e.NameScope.Find("PART_ListBox") as CompletionListBox; + if (_listBox is not null) + { + _listBox.ItemsSource = _completionData; + } + } + + /// + /// Gets the list box. + /// + public CompletionListBox? ListBox + { + get + { + if (_listBox == null) + ApplyTemplate(); + return _listBox; + } + } + + /// + /// Gets or sets the array of keys that are supposed to request insertation of the completion + /// + public Key[] CompletionAcceptKeys { get; set; } + + /// + /// Gets the scroll viewer used in this list box. + /// + public ScrollViewer? ScrollViewer => _listBox?.ScrollViewer; + + private readonly ObservableCollection _completionData = new(); + + /// + /// Gets the list to which completion data can be added. + /// + public IList CompletionData => _completionData; + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + HandleKey(e); + } + } + + /// + /// Handles a key press. Used to let the completion list handle key presses while the + /// focus is still on the text editor. + /// + public void HandleKey(KeyEventArgs e) + { + if (_listBox == null) + return; + + // We have to do some key handling manually, because the default doesn't work with + // our simulated events. + // Also, the default PageUp/PageDown implementation changes the focus, so we avoid it. + switch (e.Key) + { + case Key.Down: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex + 1); + break; + case Key.Up: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex - 1); + break; + case Key.PageDown: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex + _listBox.VisibleItemCount); + break; + case Key.PageUp: + e.Handled = true; + _listBox.SelectIndex(_listBox.SelectedIndex - _listBox.VisibleItemCount); + break; + case Key.Home: + e.Handled = true; + _listBox.SelectIndex(0); + break; + case Key.End: + e.Handled = true; + _listBox.SelectIndex(_listBox.ItemCount - 1); + break; + default: + if (CompletionAcceptKeys.Contains(e.Key) && CurrentList.Count > 0) + { + e.Handled = true; + RequestInsertion(e); + } + + break; + } + } + + protected void OnDoubleTapped(object? sender, RoutedEventArgs e) + { + //TODO TEST + if ( + ((AvaloniaObject?) e.Source) + .VisualAncestorsAndSelf() + .TakeWhile(obj => obj != this) + .Any(obj => obj is ListBoxItem) + ) + { + e.Handled = true; + RequestInsertion(e); + } + } + + /// + /// Gets/Sets the selected item. + /// + /// + /// The setter of this property does not scroll to the selected item. + /// You might want to also call . + /// + public ICompletionData? SelectedItem + { + get => _listBox?.SelectedItem as ICompletionData; + set + { + if (_listBox == null && value != null) + ApplyTemplate(); + if (_listBox != null) // may still be null if ApplyTemplate fails, or if listBox and value both are null + _listBox.SelectedItem = value; + } + } + + /// + /// Scrolls the specified item into view. + /// + public void ScrollIntoView(ICompletionData item) + { + if (_listBox == null) + ApplyTemplate(); + _listBox?.ScrollIntoView(item); + } + + /// + /// Occurs when the SelectedItem property changes. + /// + public event EventHandler SelectionChanged + { + add => AddHandler(SelectingItemsControl.SelectionChangedEvent, value); + remove => RemoveHandler(SelectingItemsControl.SelectionChangedEvent, value); + } + + // SelectItem gets called twice for every typed character (once from FormatLine), this helps execute SelectItem only once + private string _currentText; + + private ObservableCollection? _currentList; + + public List? CurrentList => ListBox?.Items.Cast().ToList(); + + /// + /// Selects the best match, and filter the items if turned on using . + /// + public void SelectItem(string text) + { + if (text == _currentText) + return; + if (_listBox == null) + ApplyTemplate(); + + if (IsFiltering) + { + SelectItemFiltering(text); + } + else + { + SelectItemWithStart(text); + } + _currentText = text; + } + + /// + /// Filters CompletionList items to show only those matching given query, and selects the best match. + /// + private void SelectItemFiltering(string query) + { + // if the user just typed one more character, don't filter all data but just filter what we are already displaying + var listToFilter = + _currentList != null + && !string.IsNullOrEmpty(_currentText) + && !string.IsNullOrEmpty(query) + && query.StartsWith(_currentText, StringComparison.Ordinal) + ? _currentList + : _completionData; + + var matchingItems = + from item in listToFilter + let quality = GetMatchQuality(item.Text, query) + where quality > 0 + select new { Item = item, Quality = quality }; + + // e.g. "DateTimeKind k = (*cc here suggests DateTimeKind*)" + var suggestedItem = + _listBox.SelectedIndex != -1 ? (ICompletionData)_listBox.SelectedItem : null; + + var listBoxItems = new ObservableCollection(); + var bestIndex = -1; + var bestQuality = -1; + double bestPriority = 0; + var i = 0; + foreach (var matchingItem in matchingItems) + { + var priority = + matchingItem.Item == suggestedItem + ? double.PositiveInfinity + : matchingItem.Item.Priority; + var quality = matchingItem.Quality; + if (quality > bestQuality || quality == bestQuality && priority > bestPriority) + { + bestIndex = i; + bestPriority = priority; + bestQuality = quality; + } + // Add to listbox + listBoxItems.Add(matchingItem.Item); + // Update the character highlighting + matchingItem.Item.UpdateCharHighlighting(query); + i++; + } + + _currentList = listBoxItems; + //_listBox.Items = null; Makes no sense? Tooltip disappeared because of this + _listBox.ItemsSource = listBoxItems; + SelectIndexCentered(bestIndex); + } + + /// + /// Selects the item that starts with the specified query. + /// + private void SelectItemWithStart(string query) + { + if (string.IsNullOrEmpty(query)) + return; + + var suggestedIndex = _listBox.SelectedIndex; + + var bestIndex = -1; + var bestQuality = -1; + double bestPriority = 0; + for (var i = 0; i < _completionData.Count; ++i) + { + var quality = GetMatchQuality(_completionData[i].Text, query); + if (quality < 0) + continue; + + var priority = _completionData[i].Priority; + bool useThisItem; + if (bestQuality < quality) + { + useThisItem = true; + } + else + { + if (bestIndex == suggestedIndex) + { + useThisItem = false; + } + else if (i == suggestedIndex) + { + // prefer recommendedItem, regardless of its priority + useThisItem = bestQuality == quality; + } + else + { + useThisItem = bestQuality == quality && bestPriority < priority; + } + } + if (useThisItem) + { + bestIndex = i; + bestPriority = priority; + bestQuality = quality; + } + } + SelectIndexCentered(bestIndex); + } + + private void SelectIndexCentered(int bestIndex) + { + if (bestIndex < 0) + { + _listBox.ClearSelection(); + } + else + { + var firstItem = _listBox.FirstVisibleItem; + if (bestIndex < firstItem || firstItem + _listBox.VisibleItemCount <= bestIndex) + { + // CenterViewOn does nothing as CompletionListBox.ScrollViewer is null + _listBox.CenterViewOn(bestIndex); + _listBox.SelectIndex(bestIndex); + } + else + { + _listBox.SelectIndex(bestIndex); + } + } + } + + private int GetMatchQuality(string itemText, string query) + { + if (itemText == null) + throw new ArgumentNullException(nameof(itemText), "ICompletionData.Text returned null"); + + // Qualities: + // 8 = full match case sensitive + // 7 = full match + // 6 = match start case sensitive + // 5 = match start + // 4 = match CamelCase when length of query is 1 or 2 characters + // 3 = match substring case sensitive + // 2 = match substring + // 1 = match CamelCase + // -1 = no match + if (query == itemText) + return 8; + if (string.Equals(itemText, query, StringComparison.CurrentCultureIgnoreCase)) + return 7; + + if (itemText.StartsWith(query, StringComparison.CurrentCulture)) + return 6; + if (itemText.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) + return 5; + + bool? camelCaseMatch = null; + if (query.Length <= 2) + { + camelCaseMatch = CamelCaseMatch(itemText, query); + if (camelCaseMatch == true) + return 4; + } + + // search by substring, if filtering (i.e. new behavior) turned on + if (IsFiltering) + { + if (itemText.IndexOf(query, StringComparison.CurrentCulture) >= 0) + return 3; + if (itemText.IndexOf(query, StringComparison.CurrentCultureIgnoreCase) >= 0) + return 2; + } + + if (!camelCaseMatch.HasValue) + camelCaseMatch = CamelCaseMatch(itemText, query); + if (camelCaseMatch == true) + return 1; + + return -1; + } + + private static bool CamelCaseMatch(string text, string query) + { + // We take the first letter of the text regardless of whether or not it's upper case so we match + // against camelCase text as well as PascalCase text ("cct" matches "camelCaseText") + var theFirstLetterOfEachWord = text.AsEnumerable() + .Take(1) + .Concat(text.AsEnumerable().Skip(1).Where(char.IsUpper)); + + var i = 0; + foreach (var letter in theFirstLetterOfEachWord) + { + if (i > query.Length - 1) + return true; // return true here for CamelCase partial match ("CQ" matches "CodeQualityAnalysis") + if (char.ToUpperInvariant(query[i]) != char.ToUpperInvariant(letter)) + return false; + i++; + } + if (i >= query.Length) + return true; + return false; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs new file mode 100644 index 00000000..39d68d59 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListBox.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using AvaloniaEdit.Utils; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The list box used inside the CompletionList. +/// +public class CompletionListBox : ListBox +{ + internal ScrollViewer ScrollViewer; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + ScrollViewer = e.NameScope.Find("PART_ScrollViewer") as ScrollViewer; + } + + /// + /// Gets the number of the first visible item. + /// + public int FirstVisibleItem + { + get + { + if (ScrollViewer == null || ScrollViewer.Extent.Height == 0) + { + return 0; + } + + return (int)(ItemCount * ScrollViewer.Offset.Y / ScrollViewer.Extent.Height); + } + set + { + value = value.CoerceValue(0, ItemCount - VisibleItemCount); + if (ScrollViewer != null) + { + ScrollViewer.Offset = ScrollViewer.Offset.WithY((double)value / ItemCount * ScrollViewer.Extent.Height); + } + } + } + + /// + /// Gets the number of visible items. + /// + public int VisibleItemCount + { + get + { + if (ScrollViewer == null || ScrollViewer.Extent.Height == 0) + { + return 10; + } + return Math.Max( + 3, + (int)Math.Ceiling(ItemCount * ScrollViewer.Viewport.Height + / ScrollViewer.Extent.Height)); + } + } + + /// + /// Removes the selection. + /// + public void ClearSelection() + { + SelectedIndex = -1; + } + + /// + /// Selects the item with the specified index and scrolls it into view. + /// + public void SelectIndex(int index) + { + if (index >= ItemCount) + index = ItemCount - 1; + if (index < 0) + index = 0; + SelectedIndex = index; + ScrollIntoView(SelectedItem); + } + + /// + /// Centers the view on the item with the specified index. + /// + public void CenterViewOn(int index) + { + FirstVisibleItem = index - VisibleItemCount / 2; + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml new file mode 100644 index 00000000..bf12be87 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionListThemes.axaml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml new file mode 100644 index 00000000..32b22eb7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs new file mode 100644 index 00000000..e8a315f7 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindow.axaml.cs @@ -0,0 +1,258 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// The code completion window. +/// +public class CompletionWindow : CompletionWindowBase +{ + private PopupWithCustomPosition _toolTip; + private ContentControl _toolTipContent; + + + /// + /// Gets the completion list used in this completion window. + /// + public CompletionList CompletionList { get; } + + /// + /// Creates a new code completion window. + /// + public CompletionWindow(TextArea textArea) : base(textArea) + { + CompletionList = new CompletionList(); + // keep height automatic + CloseAutomatically = true; + MaxHeight = 225; + // Width = 175; + Width = 350; + Child = CompletionList; + // prevent user from resizing window to 0x0 + MinHeight = 15; + MinWidth = 30; + + _toolTipContent = new ContentControl(); + _toolTipContent.Classes.Add("ToolTip"); + + _toolTip = new PopupWithCustomPosition + { + IsLightDismissEnabled = true, + PlacementTarget = this, + // Placement = PlacementMode.RightEdgeAlignedTop, + Placement = PlacementMode.LeftEdgeAlignedBottom, + Child = _toolTipContent, + }; + + LogicalChildren.Add(_toolTip); + + //_toolTip.Closed += (o, e) => ((Popup)o).Child = null; + + AttachEvents(); + } + + protected override void OnClosed() + { + base.OnClosed(); + + if (_toolTip != null) + { + _toolTip.IsOpen = false; + _toolTip = null; + _toolTipContent = null; + } + } + + #region ToolTip handling + + private void CompletionList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_toolTipContent == null) return; + + var item = CompletionList.SelectedItem; + var description = item?.Description; + if (description != null) + { + if (description is string descriptionText) + { + _toolTipContent.Content = new TextBlock + { + Text = descriptionText, + TextWrapping = TextWrapping.Wrap + }; + } + else + { + _toolTipContent.Content = description; + } + + _toolTip.IsOpen = false; //Popup needs to be closed to change position + + // Calculate offset for tooltip + var popupRoot = Host as PopupRoot; + if (CompletionList.CurrentList != null) + { + double yOffset = 0; + var itemContainer = CompletionList.ListBox.ContainerFromItem(item); + if (popupRoot != null && itemContainer != null) + { + var position = itemContainer.TranslatePoint(new Point(0, 0), popupRoot); + if (position.HasValue) + yOffset = position.Value.Y; + } + + _toolTip.Offset = new Point(2, yOffset); + } + + _toolTip.PlacementTarget = popupRoot; + _toolTip.IsOpen = true; + } + else + { + _toolTip.IsOpen = false; + } + } + + #endregion + + private void CompletionList_InsertionRequested(object sender, EventArgs e) + { + Hide(); + // The window must close before Complete() is called. + // If the Complete callback pushes stacked input handlers, we don't want to pop those when the CC window closes. + var item = CompletionList.SelectedItem; + item?.Complete(TextArea, new AnchorSegment(TextArea.Document, StartOffset, EndOffset - StartOffset), e); + } + + private void AttachEvents() + { + CompletionList.InsertionRequested += CompletionList_InsertionRequested; + CompletionList.SelectionChanged += CompletionList_SelectionChanged; + TextArea.Caret.PositionChanged += CaretPositionChanged; + TextArea.PointerWheelChanged += TextArea_MouseWheel; + TextArea.TextInput += TextArea_PreviewTextInput; + } + + /// + protected override void DetachEvents() + { + CompletionList.InsertionRequested -= CompletionList_InsertionRequested; + CompletionList.SelectionChanged -= CompletionList_SelectionChanged; + TextArea.Caret.PositionChanged -= CaretPositionChanged; + TextArea.PointerWheelChanged -= TextArea_MouseWheel; + TextArea.TextInput -= TextArea_PreviewTextInput; + base.DetachEvents(); + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled) + { + CompletionList.HandleKey(e); + } + } + + private void TextArea_PreviewTextInput(object sender, TextInputEventArgs e) + { + e.Handled = RaiseEventPair(this, null, TextInputEvent, + new TextInputEventArgs { Text = e.Text }); + } + + private void TextArea_MouseWheel(object sender, PointerWheelEventArgs e) + { + e.Handled = RaiseEventPair(GetScrollEventTarget(), + null, PointerWheelChangedEvent, e); + } + + private Control GetScrollEventTarget() + { + if (CompletionList == null) + return this; + return CompletionList.ScrollViewer ?? CompletionList.ListBox ?? (Control)CompletionList; + } + + /// + /// Gets/Sets whether the completion window should close automatically. + /// The default value is true. + /// + public bool CloseAutomatically { get; set; } + + /// + protected override bool CloseOnFocusLost => CloseAutomatically; + + /// + /// When this flag is set, code completion closes if the caret moves to the + /// beginning of the allowed range. This is useful in Ctrl+Space and "complete when typing", + /// but not in dot-completion. + /// Has no effect if CloseAutomatically is false. + /// + public bool CloseWhenCaretAtBeginning { get; set; } + + private void CaretPositionChanged(object sender, EventArgs e) + { + Debug.WriteLine($"Caret Position changed: {e}"); + var offset = TextArea.Caret.Offset; + if (offset == StartOffset) + { + if (CloseAutomatically && CloseWhenCaretAtBeginning) + { + Hide(); + } + else + { + CompletionList.SelectItem(string.Empty); + + if (CompletionList.ListBox.ItemCount == 0) IsVisible = false; + else IsVisible = true; + } + return; + } + if (offset < StartOffset || offset > EndOffset) + { + if (CloseAutomatically) + { + Hide(); + } + } + else + { + var document = TextArea.Document; + if (document != null) + { + var newText = document.GetText(StartOffset, offset - StartOffset); + Debug.WriteLine("CaretPositionChanged newText: " + newText); + CompletionList.SelectItem(newText); + + IsVisible = CompletionList.ListBox.ItemCount != 0; + } + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs new file mode 100644 index 00000000..47ef3efe --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/CompletionWindowBase.cs @@ -0,0 +1,421 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Base class for completion windows. Handles positioning the window at the caret. +/// +public class CompletionWindowBase : Popup +{ + static CompletionWindowBase() + { + //BackgroundProperty.OverrideDefaultValue(typeof(CompletionWindowBase), Brushes.White); + } + + protected override Type StyleKeyOverride => typeof(PopupRoot); + + /// + /// Gets the parent TextArea. + /// + public TextArea TextArea { get; } + + private readonly Window _parentWindow; + private TextDocument _document; + + /// + /// Gets/Sets the start of the text range in which the completion window stays open. + /// This text portion is used to determine the text used to select an entry in the completion list by typing. + /// + public int StartOffset { get; set; } + + /// + /// Gets/Sets the end of the text range in which the completion window stays open. + /// This text portion is used to determine the text used to select an entry in the completion list by typing. + /// + public int EndOffset { get; set; } + + /// + /// Gets whether the window was opened above the current line. + /// + protected bool IsUp { get; private set; } + + /// + /// Creates a new CompletionWindowBase. + /// + public CompletionWindowBase(TextArea textArea) : base() + { + TextArea = textArea ?? throw new ArgumentNullException(nameof(textArea)); + _parentWindow = textArea.GetVisualRoot() as Window; + + + AddHandler(PointerReleasedEvent, OnMouseUp, handledEventsToo: true); + + StartOffset = EndOffset = TextArea.Caret.Offset; + + PlacementTarget = TextArea.TextView; + Placement = PlacementMode.AnchorAndGravity; + PlacementAnchor = global::Avalonia.Controls.Primitives.PopupPositioning.PopupAnchor.TopLeft; + PlacementGravity = global::Avalonia.Controls.Primitives.PopupPositioning.PopupGravity.BottomRight; + + //Deactivated += OnDeactivated; //Not needed? + + Closed += (sender, args) => DetachEvents(); + + AttachEvents(); + + Initialize(); + } + + protected virtual void OnClosed() + { + DetachEvents(); + } + + private void Initialize() + { + if (_document != null && StartOffset != TextArea.Caret.Offset) + { + SetPosition(new TextViewPosition(_document.GetLocation(StartOffset))); + } + else + { + SetPosition(TextArea.Caret.Position); + } + } + + public void Show() + { + UpdatePosition(); + + Open(); + Height = double.NaN; + MinHeight = 0; + } + + public void Hide() + { + Close(); + OnClosed(); + } + + #region Event Handlers + + private void AttachEvents() + { + ((ISetLogicalParent)this).SetParent(TextArea.GetVisualRoot() as ILogical); + + _document = TextArea.Document; + if (_document != null) + { + _document.Changing += TextArea_Document_Changing; + } + + // LostKeyboardFocus seems to be more reliable than PreviewLostKeyboardFocus - see SD-1729 + TextArea.LostFocus += TextAreaLostFocus; + TextArea.TextView.ScrollOffsetChanged += TextViewScrollOffsetChanged; + TextArea.DocumentChanged += TextAreaDocumentChanged; + if (_parentWindow != null) + { + _parentWindow.PositionChanged += ParentWindow_LocationChanged; + _parentWindow.Deactivated += ParentWindow_Deactivated; + } + + // close previous completion windows of same type + foreach (var x in TextArea.StackedInputHandlers.OfType()) + { + if (x.Window.GetType() == GetType()) + TextArea.PopStackedInputHandler(x); + } + + _myInputHandler = new InputHandler(this); + TextArea.PushStackedInputHandler(_myInputHandler); + } + + /// + /// Detaches events from the text area. + /// + protected virtual void DetachEvents() + { + ((ISetLogicalParent)this).SetParent(null); + + if (_document != null) + { + _document.Changing -= TextArea_Document_Changing; + } + TextArea.LostFocus -= TextAreaLostFocus; + TextArea.TextView.ScrollOffsetChanged -= TextViewScrollOffsetChanged; + TextArea.DocumentChanged -= TextAreaDocumentChanged; + if (_parentWindow != null) + { + _parentWindow.PositionChanged -= ParentWindow_LocationChanged; + _parentWindow.Deactivated -= ParentWindow_Deactivated; + } + TextArea.PopStackedInputHandler(_myInputHandler); + } + + #region InputHandler + + private InputHandler _myInputHandler; + + /// + /// A dummy input handler (that justs invokes the default input handler). + /// This is used to ensure the completion window closes when any other input handler + /// becomes active. + /// + private sealed class InputHandler : TextAreaStackedInputHandler + { + internal readonly CompletionWindowBase Window; + + public InputHandler(CompletionWindowBase window) + : base(window.TextArea) + { + Debug.Assert(window != null); + Window = window; + } + + public override void Detach() + { + base.Detach(); + Window.Hide(); + } + + public override void OnPreviewKeyDown(KeyEventArgs e) + { + // prevents crash when typing deadchar while CC window is open + if (e.Key == Key.DeadCharProcessed) + return; + e.Handled = RaiseEventPair(Window, null, KeyDownEvent, + new KeyEventArgs { Key = e.Key }); + } + + public override void OnPreviewKeyUp(KeyEventArgs e) + { + if (e.Key == Key.DeadCharProcessed) + return; + e.Handled = RaiseEventPair(Window, null, KeyUpEvent, + new KeyEventArgs { Key = e.Key }); + } + } + #endregion + + private void TextViewScrollOffsetChanged(object sender, EventArgs e) + { + ILogicalScrollable textView = TextArea; + var visibleRect = new Rect(textView.Offset.X, textView.Offset.Y, textView.Viewport.Width, textView.Viewport.Height); + //close completion window when the user scrolls so far that the anchor position is leaving the visible area + if (visibleRect.Contains(_visualLocation) || visibleRect.Contains(_visualLocationTop)) + { + UpdatePosition(); + } + else + { + Hide(); + } + } + + private void TextAreaDocumentChanged(object sender, EventArgs e) + { + Hide(); + } + + private void TextAreaLostFocus(object sender, RoutedEventArgs e) + { + Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); + } + + private void ParentWindow_Deactivated(object sender, EventArgs e) + { + Hide(); + } + + private void ParentWindow_LocationChanged(object sender, EventArgs e) + { + UpdatePosition(); + } + + /// + private void OnDeactivated(object sender, EventArgs e) + { + Dispatcher.UIThread.Post(CloseIfFocusLost, DispatcherPriority.Background); + } + + #endregion + + /// + /// Raises a tunnel/bubble event pair for a control. + /// + /// The control for which the event should be raised. + /// The tunneling event. + /// The bubbling event. + /// The event args to use. + /// The value of the event args. + [SuppressMessage("Microsoft.Design", "CA1030:UseEventsWhereAppropriate")] + protected static bool RaiseEventPair(Control target, RoutedEvent previewEvent, RoutedEvent @event, RoutedEventArgs args) + { + if (target == null) + throw new ArgumentNullException(nameof(target)); + if (args == null) + throw new ArgumentNullException(nameof(args)); + if (previewEvent != null) + { + args.RoutedEvent = previewEvent; + target.RaiseEvent(args); + } + args.RoutedEvent = @event ?? throw new ArgumentNullException(nameof(@event)); + target.RaiseEvent(args); + return args.Handled; + } + + // Special handler: handledEventsToo + private void OnMouseUp(object sender, PointerReleasedEventArgs e) + { + ActivateParentWindow(); + } + + /// + /// Activates the parent window. + /// + protected virtual void ActivateParentWindow() + { + _parentWindow?.Activate(); + } + + private void CloseIfFocusLost() + { + if (CloseOnFocusLost) + { + Debug.WriteLine("CloseIfFocusLost: this.IsFocues=" + IsFocused + " IsTextAreaFocused=" + IsTextAreaFocused); + if (!IsFocused && !IsTextAreaFocused) + { + Hide(); + } + } + } + + /// + /// Gets whether the completion window should automatically close when the text editor looses focus. + /// + protected virtual bool CloseOnFocusLost => true; + + private bool IsTextAreaFocused + { + get + { + if (_parentWindow != null && !_parentWindow.IsActive) + return false; + return TextArea.IsFocused; + } + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.Handled && e.Key == Key.Escape) + { + e.Handled = true; + Hide(); + } + } + + private Point _visualLocation; + private Point _visualLocationTop; + + /// + /// Positions the completion window at the specified position. + /// + protected void SetPosition(TextViewPosition position) + { + var textView = TextArea.TextView; + + _visualLocation = textView.GetVisualPosition(position, VisualYPosition.LineBottom); + _visualLocationTop = textView.GetVisualPosition(position, VisualYPosition.LineTop); + + UpdatePosition(); + } + + /// + /// Updates the position of the CompletionWindow based on the parent TextView position and the screen working area. + /// It ensures that the CompletionWindow is completely visible on the screen. + /// + protected void UpdatePosition() + { + var textView = TextArea.TextView; + + var position = _visualLocation - textView.ScrollOffset; + + this.HorizontalOffset = position.X; + this.VerticalOffset = position.Y; + } + + // TODO: check if needed + ///// + //protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + //{ + // base.OnRenderSizeChanged(sizeInfo); + // if (sizeInfo.HeightChanged && IsUp) + // { + // this.Top += sizeInfo.PreviousSize.Height - sizeInfo.NewSize.Height; + // } + //} + + /// + /// Gets/sets whether the completion window should expect text insertion at the start offset, + /// which not go into the completion region, but before it. + /// + /// This property allows only a single insertion, it is reset to false + /// when that insertion has occurred. + public bool ExpectInsertionBeforeStart { get; set; } + + protected virtual void TextArea_Document_Changing(object? sender, DocumentChangeEventArgs e) + { + if (e.Offset + e.RemovalLength == StartOffset && e.RemovalLength > 0) + { + Hide(); // removal immediately in front of completion segment: close the window + // this is necessary when pressing backspace after dot-completion + } + if (e.Offset == StartOffset && e.RemovalLength == 0 && ExpectInsertionBeforeStart) + { + StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.AfterInsertion); + ExpectInsertionBeforeStart = false; + } + else + { + StartOffset = e.GetNewOffset(StartOffset, AnchorMovementType.BeforeInsertion); + } + EndOffset = e.GetNewOffset(EndOffset, AnchorMovementType.AfterInsertion); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs new file mode 100644 index 00000000..04cd7f95 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/ICompletionData.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using AvaloniaEdit.Document; +using AvaloniaEdit.Editing; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.Models; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +/// +/// Describes an entry in the . +/// +/// +/// Note that the CompletionList uses data binding against the properties in this interface. +/// Thus, your implementation of the interface must use public properties; not explicit interface implementation. +/// +public interface ICompletionData +{ + /// + /// Gets the text. This property is used to filter the list of visible elements. + /// + string Text { get; } + + /// + /// Gets the description. + /// + public string? Description { get; } + + /// + /// Gets the image. + /// + IImage? Image { get; } + + /// + /// Gets the icon shown on the left. + /// + IconData? Icon { get; } + + /// + /// The displayed content. This can be the same as 'Text', or a control if + /// you want to display rich content. + /// + object Content { get; } + + /// + /// Gets inline text fragments. + /// + InlineCollection TextInlines { get; } + + /// + /// Gets the priority. This property is used in the selection logic. You can use it to prefer selecting those items + /// which the user is accessing most frequently. + /// + double Priority { get; } + + /// + /// Perform the completion. + /// + /// The text area on which completion is performed. + /// The text segment that was used by the completion window if + /// the user types (segment between CompletionWindow.StartOffset and CompletionWindow.EndOffset). + /// The EventArgs used for the insertion request. + /// These can be TextCompositionEventArgs, KeyEventArgs, MouseEventArgs, depending on how + /// the insertion was triggered. + void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs); + + /// + /// Update the text character highlighting + /// + void UpdateCharHighlighting(string searchText); + + /// + /// Reset the text character highlighting + /// + void ResetCharHighlighting(); +} diff --git a/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs b/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs new file mode 100644 index 00000000..e26b50db --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/CodeCompletion/PopupWithCustomPosition.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls.Primitives; + +namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; + +internal class PopupWithCustomPosition : Popup +{ + public Point Offset + { + get => new(HorizontalOffset, VerticalOffset); + set + { + HorizontalOffset = value.X; + VerticalOffset = value.Y; + + // this.Revalidate(VerticalOffsetProperty); + } + } +} diff --git a/StabilityMatrix.Avalonia/Controls/PromptCard.axaml b/StabilityMatrix.Avalonia/Controls/PromptCard.axaml index 2cde1206..423e2f2c 100644 --- a/StabilityMatrix.Avalonia/Controls/PromptCard.axaml +++ b/StabilityMatrix.Avalonia/Controls/PromptCard.axaml @@ -4,6 +4,8 @@ xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit" xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" + xmlns:i="clr-namespace:Avalonia.Xaml.Interactivity;assembly=Avalonia.Xaml.Interactivity" + xmlns:behaviors="clr-namespace:StabilityMatrix.Avalonia.Behaviors" xmlns:icons="clr-namespace:Projektanker.Icons.Avalonia;assembly=Projektanker.Icons.Avalonia" x:DataType="vmInference:PromptCardViewModel"> @@ -64,8 +66,12 @@ - + FontFamily="Cascadia Code,Consolas,Menlo,Monospace"> + + + + + @@ -93,8 +99,12 @@ - + FontFamily="Cascadia Code,Consolas,Menlo,Monospace"> + + + + + diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index fffa651d..11a35ca4 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -5,8 +5,11 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net.Http; +using AvaloniaEdit.Utils; using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Controls.CodeCompletion; using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Dialogs; @@ -430,6 +433,26 @@ public static class DesignData public static BatchSizeCardViewModel BatchSizeCardViewModel => DialogFactory.Get(); + public static IList SampleCompletionData => new List + { + new TagCompletionData("test1", TagType.General), + new TagCompletionData("test2", TagType.Artist), + new TagCompletionData("test3", TagType.Character), + new TagCompletionData("test4", TagType.Copyright), + new TagCompletionData("test5", TagType.Species), + new TagCompletionData("test_unknown", TagType.Invalid), + }; + + public static CompletionList SampleCompletionList + { + get + { + var list = new CompletionList(); + list.CompletionData.AddRange(SampleCompletionData); + return list; + } + } + public static Indexer Types => new(); public class Indexer diff --git a/StabilityMatrix.Avalonia/Models/IconData.cs b/StabilityMatrix.Avalonia/Models/IconData.cs new file mode 100644 index 00000000..94fc2df4 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/IconData.cs @@ -0,0 +1,12 @@ +using Avalonia.Media; + +namespace StabilityMatrix.Avalonia.Models; + +public record IconData +{ + public string? FAIcon { get; init; } + + public int? FontSize { get; init; } + + public SolidColorBrush? Foreground { get; init; } +} diff --git a/StabilityMatrix.Avalonia/Models/TagCompletion/TagCompletionData.cs b/StabilityMatrix.Avalonia/Models/TagCompletion/TagCompletionData.cs new file mode 100644 index 00000000..1c19c91e --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/TagCompletion/TagCompletionData.cs @@ -0,0 +1,17 @@ +using StabilityMatrix.Avalonia.Controls.CodeCompletion; +using StabilityMatrix.Core.Extensions; + +namespace StabilityMatrix.Avalonia.Models.TagCompletion; + +public class TagCompletionData : CompletionData +{ + protected TagType TagType { get; } + + /// + public TagCompletionData(string text, TagType tagType) : base(text) + { + TagType = tagType; + Icon = CompletionIcons.GetIconForTagType(tagType) ?? CompletionIcons.Invalid; + Description = tagType.GetStringValue(); + } +} diff --git a/StabilityMatrix.Avalonia/Models/TagCompletion/TagType.cs b/StabilityMatrix.Avalonia/Models/TagCompletion/TagType.cs new file mode 100644 index 00000000..f6f28951 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/TagCompletion/TagType.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using StabilityMatrix.Core.Converters.Json; + +namespace StabilityMatrix.Avalonia.Models.TagCompletion; + +[JsonConverter(typeof(DefaultUnknownEnumConverter))] +public enum TagType +{ + Unknown, + Invalid, + General, + Artist, + Copyright, + Character, + Species, + Meta, + Lore +} + +public static class TagTypeExtensions +{ + public static TagType FromE621(int tag) + { + return tag switch + { + -1 => TagType.Invalid, + 0 => TagType.General, + 1 => TagType.Artist, + 3 => TagType.Copyright, + 4 => TagType.Character, + 5 => TagType.Species, + 6 => TagType.Invalid, + 7 => TagType.Meta, + 8 => TagType.Lore, + _ => TagType.Unknown + }; + } +} diff --git a/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml b/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml index ae50d341..87ba8372 100644 --- a/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml +++ b/StabilityMatrix.Avalonia/Styles/ThemeColors.axaml @@ -1,6 +1,7 @@  - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:sty="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia"> + #333333 #952923 #C2362E @@ -30,4 +31,38 @@ #795548 #9E9E9E #607D8B + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Styles/ThemeColors.cs b/StabilityMatrix.Avalonia/Styles/ThemeColors.cs index f77232d7..511db0e9 100644 --- a/StabilityMatrix.Avalonia/Styles/ThemeColors.cs +++ b/StabilityMatrix.Avalonia/Styles/ThemeColors.cs @@ -1,4 +1,5 @@ -using Avalonia.Media; +using Avalonia; +using Avalonia.Media; namespace StabilityMatrix.Avalonia.Styles; @@ -7,4 +8,17 @@ public static class ThemeColors public static readonly SolidColorBrush ThemeGreen = SolidColorBrush.Parse("#4caf50"); public static readonly SolidColorBrush ThemeRed = SolidColorBrush.Parse("#f44336"); public static readonly SolidColorBrush ThemeYellow = SolidColorBrush.Parse("#ffeb3b"); + + public static readonly SolidColorBrush AmericanYellow = SolidColorBrush.Parse("#f2ac08"); + public static readonly SolidColorBrush HalloweenOrange = SolidColorBrush.Parse("#ed5D1f"); + public static readonly SolidColorBrush LightSteelBlue = SolidColorBrush.Parse("#b4c7d9"); + public static readonly SolidColorBrush DeepMagenta = SolidColorBrush.Parse("#dd00dd"); + public static readonly SolidColorBrush LuminousGreen = SolidColorBrush.Parse("#00aa00"); + + public static readonly SolidColorBrush CompletionSelectionBackgroundBrush = + SolidColorBrush.Parse("#2E436E"); + public static readonly SolidColorBrush CompletionSelectionForegroundBrush = + SolidColorBrush.Parse("#5389F4"); + public static readonly SolidColorBrush CompletionForegroundBrush = + SolidColorBrush.Parse("#B4B8BF"); } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs index 09b1c59a..2b773811 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceTextToImageViewModel.cs @@ -286,7 +286,7 @@ public partial class InferenceTextToImageViewModel : InferenceTabViewModelBase // Connect progress handler // client.ProgressUpdateReceived += OnProgressUpdateReceived; client.PreviewImageReceived += OnPreviewImageReceived; - + ComfyTask? promptTask = null; try { @@ -356,12 +356,11 @@ public partial class InferenceTextToImageViewModel : InferenceTabViewModelBase await using var fileStream = gridPath.Info.OpenWrite(); await fileStream.WriteAsync(grid.Encode().ToArray(), cancellationToken); - // Insert to start of gallery + // Insert to start of images ImageGalleryCardViewModel.ImageSources.Add(new ImageSource(gridPath)); - // var bitmaps = (await outputImages.SelectAsync(async i => await i.GetBitmapAsync())).ToImmutableArray(); } - // Insert rest of images + // Add rest of images ImageGalleryCardViewModel.ImageSources.AddRange(outputImages); } finally