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