using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.ComponentModel; using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Data; namespace Kingo.Plugin.DLTB_IDG { /// /// AutoCompleteComboBox.xaml /// public partial class AutoCompleteComboBox : ComboBox { readonly SerialDisposable disposable = new SerialDisposable(); TextBox editableTextBoxCache; Predicate defaultItemsFilter; public TextBox EditableTextBox { get { if (editableTextBoxCache == null) { const string name = "PART_EditableTextBox"; editableTextBoxCache = (TextBox)VisualTreeModule.FindChild(this, name); } return editableTextBoxCache; } } /// /// Gets text to match with the query from an item. /// Never null. /// /// string TextFromItem(object item) { if (item == null) return string.Empty; var d = new DependencyVariable(); d.SetBinding(item, TextSearch.GetTextPath(this)); return d.Value ?? string.Empty; } protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue) { base.OnItemsSourceChanged(oldValue, newValue); defaultItemsFilter = newValue is ICollectionView cv ? cv.Filter : null; } #region Setting static readonly DependencyProperty settingProperty = DependencyProperty.Register( "Setting", typeof(AutoCompleteComboBoxSetting), typeof(AutoCompleteComboBox) ); public static DependencyProperty SettingProperty { get { return settingProperty; } } public AutoCompleteComboBoxSetting Setting { get { return (AutoCompleteComboBoxSetting)GetValue(SettingProperty); } set { SetValue(SettingProperty, value); } } AutoCompleteComboBoxSetting SettingOrDefault { get { return Setting ?? AutoCompleteComboBoxSetting.Default; } } #endregion #region OnTextChanged long revisionId; string previousText; struct TextBoxStatePreserver : IDisposable { readonly TextBox textBox; readonly int selectionStart; readonly int selectionLength; readonly string text; public void Dispose() { textBox.Text = text; textBox.Select(selectionStart, selectionLength); } public TextBoxStatePreserver(TextBox textBox) { this.textBox = textBox; selectionStart = textBox.SelectionStart; selectionLength = textBox.SelectionLength; text = textBox.Text; } } static int CountWithMax(IEnumerable xs, Predicate predicate, int maxCount) { var count = 0; foreach (var x in xs) { if (predicate(x)) { count++; if (count > maxCount) return count; } } return count; } void Unselect() { var textBox = EditableTextBox; textBox.Select(textBox.SelectionStart + textBox.SelectionLength, 0); } void UpdateFilter(Predicate filter) { using (new TextBoxStatePreserver(EditableTextBox)) using (Items.DeferRefresh()) { // Can empty the text box. I don't why. Items.Filter = filter; } } void OpenDropDown(Predicate filter) { UpdateFilter(filter); IsDropDownOpen = true; Unselect(); } void OpenDropDown() { var filter = GetFilter(); OpenDropDown(filter); } void UpdateSuggestionList() { var text = Text; if (text == previousText) return; previousText = text; if (string.IsNullOrEmpty(text)) { IsDropDownOpen = false; SelectedItem = null; using (Items.DeferRefresh()) { Items.Filter = defaultItemsFilter; } } else if (SelectedItem != null && TextFromItem(SelectedItem) == text) { // It seems the user selected an item. // Do nothing. } else { using (new TextBoxStatePreserver(EditableTextBox)) { SelectedItem = null; } var filter = GetFilter(); var maxCount = SettingOrDefault.MaxSuggestionCount; var count = CountWithMax(ItemsSource?.Cast() ?? Enumerable.Empty(), filter, maxCount); if (count > maxCount) return; OpenDropDown(filter); } } void OnTextChanged(object sender, TextChangedEventArgs e) { var id = unchecked(++revisionId); var setting = SettingOrDefault; if (setting.Delay <= TimeSpan.Zero) { UpdateSuggestionList(); return; } disposable.Content = new Timer( state => { Dispatcher.InvokeAsync(() => { if (revisionId != id) return; UpdateSuggestionList(); }); }, null, setting.Delay, Timeout.InfiniteTimeSpan ); } #endregion void ComboBox_PreviewKeyDown(object sender, KeyEventArgs e) { if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && e.Key == Key.Space) { OpenDropDown(); e.Handled = true; } } Predicate GetFilter() { var filter = SettingOrDefault.GetFilter(Text, TextFromItem); return defaultItemsFilter != null ? i => defaultItemsFilter(i) && filter(i) : filter; } public AutoCompleteComboBox() { InitializeComponent(); AddHandler(TextBoxBase.TextChangedEvent, new TextChangedEventHandler(OnTextChanged)); } } sealed class SerialDisposable : IDisposable { IDisposable content; public IDisposable Content { get { return content; } set { if (content != null) { content.Dispose(); } content = value; } } public void Dispose() { Content = null; } } static class VisualTreeModule { public static FrameworkElement FindChild(DependencyObject obj, string childName) { if (obj == null) return null; var queue = new Queue(); queue.Enqueue(obj); while (queue.Count > 0) { obj = queue.Dequeue(); var childCount = VisualTreeHelper.GetChildrenCount(obj); for (var i = 0; i < childCount; i++) { var child = VisualTreeHelper.GetChild(obj, i); var fe = child as FrameworkElement; if (fe != null && fe.Name == childName) { return fe; } queue.Enqueue(child); } } return null; } } sealed class DependencyVariable : DependencyObject { static readonly DependencyProperty valueProperty = DependencyProperty.Register( "Value", typeof(T), typeof(DependencyVariable) ); public static DependencyProperty ValueProperty { get { return valueProperty; } } public T Value { get { return (T)GetValue(ValueProperty); } set { SetValue(ValueProperty, value); } } public void SetBinding(Binding binding) { BindingOperations.SetBinding(this, ValueProperty, binding); } public void SetBinding(object dataContext, string propertyPath) { SetBinding(new Binding(propertyPath) { Source = dataContext }); } } public class AutoCompleteComboBoxSetting { /// /// Gets a filter function which determines whether items should be suggested or not /// for the specified query. /// Default: Gets the filter which maps an item to true /// if its text contains the query (case insensitive). /// /// /// The string input by user. /// /// /// The function to get a string which identifies the specified item. /// /// public virtual Predicate GetFilter(string query, Func stringFromItem) { return item => stringFromItem(item).IndexOf(query, StringComparison.InvariantCultureIgnoreCase) >= 0; } /// /// Gets an integer. /// The combobox opens the drop down /// if the number of suggested items is less than the value. /// Note that the value is larger, it's heavier to open the drop down. /// Default: 100. /// public virtual int MaxSuggestionCount { get { return 100; } } /// /// Gets the duration to delay updating the suggestion list. /// Returns Zero if no delay. /// Default: 300ms. /// public virtual TimeSpan Delay { get { return TimeSpan.FromMilliseconds(300.0); } } static AutoCompleteComboBoxSetting @default = new AutoCompleteComboBoxSetting(); /// /// Gets the default setting. /// public static AutoCompleteComboBoxSetting Default { get { return @default; } set { if (value == null) throw new ArgumentNullException(nameof(value)); @default = value; } } } }