// Copyright (c) 2014 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software // without restriction, including without limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons // to whom the Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR // PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml.Templates; using AvaloniaEdit.Utils; using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; namespace StabilityMatrix.Avalonia.Controls.CodeCompletion; /// /// The listbox used inside the CompletionWindow, contains CompletionListBox. /// [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public class CompletionList : TemplatedControl { private CompletionListBox? _listBox; public CompletionList() { AddHandler(DoubleTappedEvent, OnDoubleTapped); } /// /// If true, the CompletionList is filtered to show only matching items. Also enables search by substring. /// If false, enables the old behavior: no filtering, search by string.StartsWith. /// public bool IsFiltering { get; set; } = true; /// /// Dependency property for . /// public static readonly StyledProperty EmptyTemplateProperty = AvaloniaProperty.Register(nameof(EmptyTemplate)); /// /// Content of EmptyTemplate will be shown when CompletionList contains no items. /// If EmptyTemplate is null, nothing will be shown. /// public ControlTemplate EmptyTemplate { get => GetValue(EmptyTemplateProperty); set => SetValue(EmptyTemplateProperty, value); } /// /// Dependency property for . /// public static readonly StyledProperty FooterTextProperty = AvaloniaProperty.Register< CompletionList, string? >("FooterText", "Press Enter to insert, Tab to replace"); /// /// Gets/Sets the text displayed in the footer of the completion list. /// public string? FooterText { get => GetValue(FooterTextProperty); set => SetValue(FooterTextProperty, value); } /// /// Is raised when the completion list indicates that the user has chosen /// an entry to be completed. /// public event EventHandler? InsertionRequested; /// /// Raised when the completion list indicates that it should be closed. /// public event EventHandler? CloseRequested; /// /// Raises the InsertionRequested event. /// public void RequestInsertion( ICompletionData item, RoutedEventArgs triggeringEvent, string? appendText = null ) { InsertionRequested?.Invoke( this, new InsertionRequestEventArgs { Item = item, TriggeringEvent = triggeringEvent, AppendText = appendText } ); } /// /// Raises the CloseRequested event. /// public void RequestClose() { CloseRequested?.Invoke(this, EventArgs.Empty); } protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); _listBox = e.NameScope.Find("PART_ListBox") as CompletionListBox; if (_listBox is not null) { // _listBox.ItemsSource = _completionData; _listBox.ItemsSource = FilteredCompletionData; } } /// /// Gets the list box. /// public CompletionListBox? ListBox { get { if (_listBox == null) ApplyTemplate(); return _listBox; } } /// /// Dictionary of keys that request insertion of the completion /// mapped to strings that will be appended to the completion when selected. /// The string may be empty. /// public Dictionary CompletionAcceptKeys { get; init; } = new() { [Key.Enter] = "", [Key.Tab] = "" }; /// /// Gets the scroll viewer used in this list box. /// public ScrollViewer? ScrollViewer => _listBox?.ScrollViewer; private readonly ObservableCollection _completionData = new(); /// /// Gets the list to which completion data can be added. /// public IList CompletionData => _completionData; public ObservableCollection FilteredCompletionData { get; } = new(); /// protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (!e.Handled) { HandleKey(e); } } /// /// Handles a key press. Used to let the completion list handle key presses while the /// focus is still on the text editor. /// [SuppressMessage("ReSharper", "SwitchStatementHandlesSomeKnownEnumValuesWithDefault")] public void HandleKey(KeyEventArgs e) { if (_listBox == null) return; // We have to do some key handling manually, because the default doesn't work with // our simulated events. // Also, the default PageUp/PageDown implementation changes the focus, so we avoid it. switch (e.Key) { case Key.Down: e.Handled = true; _listBox.SelectNextIndexWithLoop(); break; case Key.Up: e.Handled = true; _listBox.SelectPreviousIndexWithLoop(); break; case Key.PageDown: e.Handled = true; _listBox.SelectIndex(_listBox.SelectedIndex + _listBox.VisibleItemCount); break; case Key.PageUp: e.Handled = true; _listBox.SelectIndex(_listBox.SelectedIndex - _listBox.VisibleItemCount); break; case Key.Home: e.Handled = true; _listBox.SelectIndex(0); break; case Key.End: e.Handled = true; _listBox.SelectIndex(_listBox.ItemCount - 1); break; default: // Check insertion keys if ( CompletionAcceptKeys.TryGetValue(e.Key, out var appendText) && CurrentList?.Count > 0 ) { e.Handled = true; if (SelectedItem is { } item) { RequestInsertion(item, e, appendText); } else { RequestClose(); } } break; } } protected void OnDoubleTapped(object? sender, RoutedEventArgs e) { //TODO TEST if ( ((AvaloniaObject?)e.Source) .VisualAncestorsAndSelf() .TakeWhile(obj => obj != this) .Any(obj => obj is ListBoxItem) ) { e.Handled = true; if (SelectedItem is { } item) { RequestInsertion(item, e); } else { RequestClose(); } } } /// /// Gets/Sets the selected item. /// /// /// The setter of this property does not scroll to the selected item. /// You might want to also call . /// public ICompletionData? SelectedItem { get => _listBox?.SelectedItem as ICompletionData; set { if (_listBox == null && value != null) ApplyTemplate(); if (_listBox != null) // may still be null if ApplyTemplate fails, or if listBox and value both are null _listBox.SelectedItem = value; } } /// /// Scrolls the specified item into view. /// public void ScrollIntoView(ICompletionData item) { if (_listBox == null) ApplyTemplate(); _listBox?.ScrollIntoView(item); } /// /// Occurs when the SelectedItem property changes. /// public event EventHandler SelectionChanged { add => AddHandler(SelectingItemsControl.SelectionChangedEvent, value); remove => RemoveHandler(SelectingItemsControl.SelectionChangedEvent, value); } // SelectItem gets called twice for every typed character (once from FormatLine), this helps execute SelectItem only once private string? _currentText; private ObservableCollection? _currentList; public List? CurrentList => ListBox?.Items.Cast().ToList(); /// /// Selects the best match, and filter the items if turned on using . /// public void SelectItem(string text, bool fullUpdate = false) { if (text == _currentText) { return; } using var _ = CodeTimer.StartDebug(); if (_listBox == null) { ApplyTemplate(); } if (IsFiltering) { SelectItemFilteringLive(text, fullUpdate); } else { SelectItemWithStart(text); } _currentText = text; } private IReadOnlyList FilterItems( IEnumerable items, string query ) { using var _ = new CodeTimer(); // Order first by quality, then by priority var matchingItems = items .Select(item => new { Item = item, Quality = GetMatchQuality(item.Text, query) }) .Where(x => x.Quality > 0) .OrderByDescending(x => x.Quality) .ThenByDescending(x => x.Item.Priority) .Select(x => x.Item) .ToList(); return matchingItems; } /// /// Filters CompletionList items to show only those matching given query, and selects the best match. /// private void SelectItemFilteringLive(string query, bool fullUpdate = false) { var listToFilter = _completionData; // if the user just typed one more character, don't filter all data but just filter what we are already displaying if ( !fullUpdate && FilteredCompletionData.Count > 0 && !string.IsNullOrEmpty(_currentText) && !string.IsNullOrEmpty(query) && query.StartsWith(_currentText, StringComparison.Ordinal) ) { listToFilter = FilteredCompletionData; } var matchingItems = FilterItems(listToFilter, query); // Close if no items match if (matchingItems.Count == 0) { RequestClose(); return; } // Fast path if both only 1 item, and item is the same if ( FilteredCompletionData.Count == 1 && matchingItems.Count == 1 && FilteredCompletionData[0] == matchingItems[0] ) { // Just update the character highlighting matchingItems[0].UpdateCharHighlighting(query); } else { // Clear current items and set new ones FilteredCompletionData.Clear(); foreach (var item in matchingItems) { item.UpdateCharHighlighting(query); FilteredCompletionData.Add(item); } // Set index to 0 if not already if (_listBox != null && _listBox.SelectedIndex != 0) { _listBox.SelectedIndex = 0; } } } /// /// Filters CompletionList items to show only those matching given query, and selects the best match. /// private void SelectItemFiltering(string query, bool fullUpdate = false) { if (_listBox is null) throw new NullReferenceException("ListBox not set"); var listToFilter = _completionData; // if the user just typed one more character, don't filter all data but just filter what we are already displaying if ( !fullUpdate && _currentList != null && !string.IsNullOrEmpty(_currentText) && !string.IsNullOrEmpty(query) && query.StartsWith(_currentText, StringComparison.Ordinal) ) { listToFilter = _currentList; } // Order first by quality, then by priority var matchingItems = listToFilter .Select(item => new { Item = item, Quality = GetMatchQuality(item.Text, query) }) .Where(x => x.Quality > 0) .OrderByDescending(x => x.Quality) .ThenByDescending(x => x.Item.Priority) .ToImmutableArray(); /*var matchingItems = from item in listToFilter let quality = GetMatchQuality(item.Text, query) where quality > 0 orderby quality select new { Item = item, Quality = quality };*/ var suggestedItem = _listBox.SelectedIndex != -1 ? (ICompletionData)_listBox.SelectedItem! : null; var listBoxItems = new ObservableCollection(); var bestIndex = -1; var bestQuality = -1; double bestPriority = 0; var i = 0; foreach (var matchingItem in matchingItems) { var priority = matchingItem.Item == suggestedItem ? double.PositiveInfinity : matchingItem.Item.Priority; var quality = matchingItem.Quality; if (quality > bestQuality || quality == bestQuality && priority > bestPriority) { bestIndex = i; bestPriority = priority; bestQuality = quality; } // Add to listbox listBoxItems.Add(matchingItem.Item); // Update the character highlighting matchingItem.Item.UpdateCharHighlighting(query); i++; } _currentList = listBoxItems; //_listBox.Items = null; Makes no sense? Tooltip disappeared because of this _listBox.ItemsSource = listBoxItems; SelectIndex(bestIndex); } /// /// Selects the item that starts with the specified query. /// private void SelectItemWithStart(string query) { if (string.IsNullOrEmpty(query)) return; var suggestedIndex = _listBox?.SelectedIndex ?? -1; if (suggestedIndex == -1) { return; } var bestIndex = -1; var bestQuality = -1; double bestPriority = 0; for (var i = 0; i < _completionData.Count; ++i) { var quality = GetMatchQuality(_completionData[i].Text, query); if (quality < 0) continue; var priority = _completionData[i].Priority; bool useThisItem; if (bestQuality < quality) { useThisItem = true; } else { if (bestIndex == suggestedIndex) { useThisItem = false; } else if (i == suggestedIndex) { // prefer recommendedItem, regardless of its priority useThisItem = bestQuality == quality; } else { useThisItem = bestQuality == quality && bestPriority < priority; } } if (useThisItem) { bestIndex = i; bestPriority = priority; bestQuality = quality; } } SelectIndexCentered(bestIndex); } private void SelectIndexCentered(int index) { if (_listBox is null) { throw new NullReferenceException("ListBox not set"); } if (index < 0) { _listBox.ClearSelection(); } else { var firstItem = _listBox.FirstVisibleItem; if (index < firstItem || firstItem + _listBox.VisibleItemCount <= index) { // CenterViewOn does nothing as CompletionListBox.ScrollViewer is null _listBox.CenterViewOn(index); _listBox.SelectIndex(index); } else { _listBox.SelectIndex(index); } } } private void SelectIndex(int index) { if (_listBox is null) { throw new NullReferenceException("ListBox not set"); } if (index == _listBox.SelectedIndex) return; if (index < 0) { _listBox.ClearSelection(); } else { _listBox.SelectedIndex = index; } } private int GetMatchQuality(string itemText, string query) { if (itemText == null) throw new ArgumentNullException(nameof(itemText), "ICompletionData.Text returned null"); // Qualities: // 8 = full match case sensitive // 7 = full match // 6 = match start case sensitive // 5 = match start // 4 = match CamelCase when length of query is 1 or 2 characters // 3 = match substring case sensitive // 2 = match substring // 1 = match CamelCase // -1 = no match if (query == itemText) return 8; if (string.Equals(itemText, query, StringComparison.CurrentCultureIgnoreCase)) return 7; if (itemText.StartsWith(query, StringComparison.CurrentCulture)) return 6; if (itemText.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) return 5; bool? camelCaseMatch = null; if (query.Length <= 2) { camelCaseMatch = CamelCaseMatch(itemText, query); if (camelCaseMatch == true) return 4; } // search by substring, if filtering (i.e. new behavior) turned on if (IsFiltering) { if (itemText.Contains(query, StringComparison.CurrentCulture)) return 3; if (itemText.Contains(query, StringComparison.CurrentCultureIgnoreCase)) return 2; } if (!camelCaseMatch.HasValue) camelCaseMatch = CamelCaseMatch(itemText, query); if (camelCaseMatch == true) return 1; return -1; } private static bool CamelCaseMatch(string text, string query) { // We take the first letter of the text regardless of whether or not it's upper case so we match // against camelCase text as well as PascalCase text ("cct" matches "camelCaseText") var theFirstLetterOfEachWord = text.AsEnumerable() .Take(1) .Concat(text.AsEnumerable().Skip(1).Where(char.IsUpper)); var i = 0; foreach (var letter in theFirstLetterOfEachWord) { if (i > query.Length - 1) return true; // return true here for CamelCase partial match ("CQ" matches "CodeQualityAnalysis") if (char.ToUpperInvariant(query[i]) != char.ToUpperInvariant(letter)) return false; i++; } if (i >= query.Length) return true; return false; } }