// This Source Code Form is subject to the terms of the MIT License. // If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. // Copyright (C) Leszek Pomianowski and WPF UI Contributors. // All Rights Reserved. using WPFluent.Extensions; using WPFluent.Input; using WPFluent.Interop; using System.Collections; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; // ReSharper disable once CheckNamespace namespace WPFluent.Controls; /// /// Represents a text control that makes suggestions to users as they enter text using a keyboard. The app is notified when text has been changed by the user and is responsible for providing relevant suggestions for this control to display. /// /// /// /// <ui:AutoSuggestBox x:Name="AutoSuggestBox" PlaceholderText="Search"> /// <ui:AutoSuggestBox.Icon> /// <ui:IconSourceElement> /// <ui:SymbolIconSource Symbol="Search24" /> /// </ui:IconSourceElement> /// </ui:AutoSuggestBox.Icon> /// </ui:AutoSuggestBox> /// /// [TemplatePart(Name = ElementTextBox, Type = typeof(TextBox))] [TemplatePart(Name = ElementSuggestionsPopup, Type = typeof(Popup))] [TemplatePart(Name = ElementSuggestionsList, Type = typeof(ListView))] public class AutoSuggestBox : ItemsControl, IIconControl { protected const string ElementTextBox = "PART_TextBox"; protected const string ElementSuggestionsPopup = "PART_SuggestionsPopup"; protected const string ElementSuggestionsList = "PART_SuggestionsList"; /// Identifies the dependency property. public static readonly DependencyProperty OriginalItemsSourceProperty = DependencyProperty.Register( nameof(OriginalItemsSource), typeof(IList), typeof(AutoSuggestBox), new PropertyMetadata(Array.Empty()) ); /// Identifies the dependency property. public static readonly DependencyProperty IsSuggestionListOpenProperty = DependencyProperty.Register( nameof(IsSuggestionListOpen), typeof(bool), typeof(AutoSuggestBox), new PropertyMetadata(false) ); /// Identifies the dependency property. public static readonly DependencyProperty TextProperty = DependencyProperty.Register( nameof(Text), typeof(string), typeof(AutoSuggestBox), new PropertyMetadata(string.Empty, OnTextChanged) ); /// Identifies the dependency property. public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register( nameof(PlaceholderText), typeof(string), typeof(AutoSuggestBox), new PropertyMetadata(string.Empty) ); /// Identifies the dependency property. public static readonly DependencyProperty UpdateTextOnSelectProperty = DependencyProperty.Register( nameof(UpdateTextOnSelect), typeof(bool), typeof(AutoSuggestBox), new PropertyMetadata(true) ); /// Identifies the dependency property. public static readonly DependencyProperty MaxSuggestionListHeightProperty = DependencyProperty.Register( nameof(MaxSuggestionListHeight), typeof(double), typeof(AutoSuggestBox), new PropertyMetadata(0d) ); /// Identifies the dependency property. public static readonly DependencyProperty IconProperty = DependencyProperty.Register( nameof(Icon), typeof(IconElement), typeof(AutoSuggestBox), new PropertyMetadata(null) ); /// Identifies the dependency property. public static readonly DependencyProperty FocusCommandProperty = DependencyProperty.Register( nameof(FocusCommand), typeof(ICommand), typeof(AutoSuggestBox), new PropertyMetadata(null) ); /// /// Gets or sets your items here if you want to use the default filtering /// public IList OriginalItemsSource { get => (IList)GetValue(OriginalItemsSourceProperty); set => SetValue(OriginalItemsSourceProperty, value); } /// /// Gets or sets a value indicating whether the drop-down portion of the is open. /// public bool IsSuggestionListOpen { get => (bool)GetValue(IsSuggestionListOpenProperty); set => SetValue(IsSuggestionListOpenProperty, value); } /// /// Gets or sets the text that is shown in the control. /// /// /// This property is not typically set in XAML. /// public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } /// /// Gets or sets the placeholder text to be displayed in the control. /// /// /// The placeholder text to be displayed in the control. The default is an empty string. /// public string PlaceholderText { get => (string)GetValue(PlaceholderTextProperty); set => SetValue(PlaceholderTextProperty, value); } /// /// Gets or sets the maximum height for the drop-down portion of the control. /// public double MaxSuggestionListHeight { get => (double)GetValue(MaxSuggestionListHeightProperty); set => SetValue(MaxSuggestionListHeightProperty, value); } /// /// Gets or sets a value indicating whether items in the view will trigger an update of the editable text part of the when clicked. /// public bool UpdateTextOnSelect { get => (bool)GetValue(UpdateTextOnSelectProperty); set => SetValue(UpdateTextOnSelectProperty, value); } /// /// Gets or sets displayed . /// public IconElement? Icon { get => (IconElement?)GetValue(IconProperty); set => SetValue(IconProperty, value); } /// /// Gets command used for focusing control. /// public ICommand FocusCommand => (ICommand)GetValue(FocusCommandProperty); /// Identifies the routed event. public static readonly RoutedEvent QuerySubmittedEvent = EventManager.RegisterRoutedEvent( nameof(QuerySubmitted), RoutingStrategy.Bubble, typeof(TypedEventHandler), typeof(AutoSuggestBox) ); /// Identifies the routed event. public static readonly RoutedEvent SuggestionChosenEvent = EventManager.RegisterRoutedEvent( nameof(SuggestionChosen), RoutingStrategy.Bubble, typeof(TypedEventHandler), typeof(AutoSuggestBox) ); /// Identifies the routed event. public static readonly RoutedEvent TextChangedEvent = EventManager.RegisterRoutedEvent( nameof(TextChanged), RoutingStrategy.Bubble, typeof(TypedEventHandler), typeof(AutoSuggestBox) ); /// /// Occurs when the user submits a search query. /// public event TypedEventHandler QuerySubmitted { add => AddHandler(QuerySubmittedEvent, value); remove => RemoveHandler(QuerySubmittedEvent, value); } /// /// Event occurs when the user selects an item from the recommended ones. /// public event TypedEventHandler SuggestionChosen { add => AddHandler(SuggestionChosenEvent, value); remove => RemoveHandler(SuggestionChosenEvent, value); } /// /// Raised after the text content of the editable control component is updated. /// public event TypedEventHandler TextChanged { add => AddHandler(TextChangedEvent, value); remove => RemoveHandler(TextChangedEvent, value); } protected TextBox? TextBox { get; set; } = null; protected Popup SuggestionsPopup { get; set; } = null!; protected ListView? SuggestionsList { get; set; } = null!; private bool _changingTextAfterSuggestionChosen; private bool _isChangedTextOutSideOfTextBox; private object? _selectedItem; private bool? _isHwndHookSubscribed; public AutoSuggestBox() { Loaded += static (sender, _) => { var self = (AutoSuggestBox)sender; self.AcquireTemplateResources(); }; Unloaded += static (sender, _) => { var self = (AutoSuggestBox)sender; self.ReleaseTemplateResources(); }; SetValue(FocusCommandProperty, new RelayCommand(_ => Focus())); } public override void OnApplyTemplate() { base.OnApplyTemplate(); TextBox = GetTemplateChild(ElementTextBox); SuggestionsPopup = GetTemplateChild(ElementSuggestionsPopup); SuggestionsList = GetTemplateChild(ElementSuggestionsList); _isHwndHookSubscribed = false; AcquireTemplateResources(); } /// public new bool Focus() { if (TextBox is null) { return false; } return TextBox.Focus(); } protected T GetTemplateChild(string name) where T : DependencyObject { if (GetTemplateChild(name) is not T dependencyObject) { throw new ArgumentNullException(name); } return dependencyObject; } protected virtual void AcquireTemplateResources() { // Unsubscribe each handler before subscription, to prevent memory leak from double subscriptions. // Unsubscription is safe, even if event has never been subscribed to. if (TextBox != null) { TextBox.PreviewKeyDown -= TextBoxOnPreviewKeyDown; TextBox.PreviewKeyDown += TextBoxOnPreviewKeyDown; TextBox.TextChanged -= TextBoxOnTextChanged; TextBox.TextChanged += TextBoxOnTextChanged; TextBox.LostKeyboardFocus -= TextBoxOnLostKeyboardFocus; TextBox.LostKeyboardFocus += TextBoxOnLostKeyboardFocus; } if (SuggestionsList != null) { SuggestionsList.SelectionChanged -= SuggestionsListOnSelectionChanged; SuggestionsList.SelectionChanged += SuggestionsListOnSelectionChanged; SuggestionsList.PreviewKeyDown -= SuggestionsListOnPreviewKeyDown; SuggestionsList.PreviewKeyDown += SuggestionsListOnPreviewKeyDown; SuggestionsList.LostKeyboardFocus -= SuggestionsListOnLostKeyboardFocus; SuggestionsList.LostKeyboardFocus += SuggestionsListOnLostKeyboardFocus; SuggestionsList.PreviewMouseLeftButtonUp -= SuggestionsListOnPreviewMouseLeftButtonUp; SuggestionsList.PreviewMouseLeftButtonUp += SuggestionsListOnPreviewMouseLeftButtonUp; } if (_isHwndHookSubscribed.HasValue && !_isHwndHookSubscribed.Value) { var hwnd = (HwndSource)PresentationSource.FromVisual(this)!; hwnd.AddHook(Hook); _isHwndHookSubscribed = true; } } protected virtual void ReleaseTemplateResources() { if (TextBox != null) { TextBox.PreviewKeyDown -= TextBoxOnPreviewKeyDown; TextBox.TextChanged -= TextBoxOnTextChanged; TextBox.LostKeyboardFocus -= TextBoxOnLostKeyboardFocus; } if (SuggestionsList != null) { SuggestionsList.SelectionChanged -= SuggestionsListOnSelectionChanged; SuggestionsList.PreviewKeyDown -= SuggestionsListOnPreviewKeyDown; SuggestionsList.LostKeyboardFocus -= SuggestionsListOnLostKeyboardFocus; SuggestionsList.PreviewMouseLeftButtonUp -= SuggestionsListOnPreviewMouseLeftButtonUp; } if ( _isHwndHookSubscribed.HasValue && _isHwndHookSubscribed.Value && PresentationSource.FromVisual(this) is HwndSource source ) { source.RemoveHook(Hook); _isHwndHookSubscribed = false; } } /// /// Method for . /// /// Currently submitted query text. protected virtual void OnQuerySubmitted(string queryText) { var args = new AutoSuggestBoxQuerySubmittedEventArgs(QuerySubmittedEvent, this) { QueryText = queryText, }; RaiseEvent(args); } /// /// Method for . /// /// Currently selected item. protected virtual void OnSuggestionChosen(object selectedItem) { var args = new AutoSuggestBoxSuggestionChosenEventArgs(SuggestionChosenEvent, this) { SelectedItem = selectedItem, }; RaiseEvent(args); if (UpdateTextOnSelect && !args.Handled) { UpdateTexBoxTextAfterSelection(selectedItem); } } /// /// Method for . /// /// Data for the text changed event. /// Changed text. protected virtual void OnTextChanged(AutoSuggestionBoxTextChangeReason reason, string text) { var args = new AutoSuggestBoxTextChangedEventArgs(TextChangedEvent, this) { Reason = reason, Text = text, }; RaiseEvent(args); if (args is { Handled: false, Reason: AutoSuggestionBoxTextChangeReason.UserInput }) { DefaultFiltering(text); } } private void TextBoxOnPreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key is Key.Escape) { SetCurrentValue(IsSuggestionListOpenProperty, false); return; } if (e.Key is Key.Enter) { SetCurrentValue(IsSuggestionListOpenProperty, false); OnQuerySubmitted(TextBox!.Text); return; } if (e.Key is not Key.Down || !IsSuggestionListOpen) { return; } _ = SuggestionsList?.Focus(); } private void TextBoxOnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) { if (e.NewFocus is ListView) { return; } SetCurrentValue(IsSuggestionListOpenProperty, false); } private void TextBoxOnTextChanged(object sender, TextChangedEventArgs e) { var changeReason = AutoSuggestionBoxTextChangeReason.UserInput; if (_changingTextAfterSuggestionChosen) { changeReason = AutoSuggestionBoxTextChangeReason.SuggestionChosen; } if (_isChangedTextOutSideOfTextBox) { changeReason = AutoSuggestionBoxTextChangeReason.ProgrammaticChange; } OnTextChanged(changeReason, TextBox!.Text); SuggestionsList!.SetCurrentValue(Selector.SelectedItemProperty, null); if (changeReason is not AutoSuggestionBoxTextChangeReason.UserInput) { return; } SetCurrentValue(IsSuggestionListOpenProperty, true); } private void SuggestionsListOnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) { if (e.NewFocus is ListViewItem) { return; } SetCurrentValue(IsSuggestionListOpenProperty, false); } private void SuggestionsListOnPreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key is not Key.Enter) { return; } SetCurrentValue(IsSuggestionListOpenProperty, false); OnSelectedChanged(SuggestionsList!.SelectedItem); } private void SuggestionsListOnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (SuggestionsList!.SelectedItem is not null) { return; } SetCurrentValue(IsSuggestionListOpenProperty, false); if (_selectedItem is not null) { OnSuggestionChosen(_selectedItem); } } private void SuggestionsListOnSelectionChanged(object sender, SelectionChangedEventArgs e) { if (SuggestionsList!.SelectedItem is null) { return; } OnSelectedChanged(SuggestionsList.SelectedItem); } private IntPtr Hook(IntPtr hwnd, int msg, IntPtr wparam, IntPtr lparam, ref bool handled) { if (!IsSuggestionListOpen) { return IntPtr.Zero; } var message = (User32.WM)msg; if (message is User32.WM.NCACTIVATE or User32.WM.WINDOWPOSCHANGED) { SetCurrentValue(IsSuggestionListOpenProperty, false); } return IntPtr.Zero; } private void OnSelectedChanged(object selectedObj) { OnSuggestionChosen(selectedObj); _selectedItem = selectedObj; } private void UpdateTexBoxTextAfterSelection(object selectedObj) { _changingTextAfterSuggestionChosen = true; TextBox!.SetCurrentValue(System.Windows.Controls.TextBox.TextProperty, GetStringFromObj(selectedObj)); _changingTextAfterSuggestionChosen = false; } private void DefaultFiltering(string text) { if (string.IsNullOrEmpty(text)) { SetCurrentValue(ItemsSourceProperty, OriginalItemsSource); return; } var splitText = text.Split(' '); var suitableItems = OriginalItemsSource .Cast() .Where(item => { var itemText = GetStringFromObj(item); return splitText.All(key => itemText?.Contains(key, StringComparison.OrdinalIgnoreCase) ?? false ); }) .ToList(); SetCurrentValue(ItemsSourceProperty, suitableItems); } private string? GetStringFromObj(object obj) { // uses reflection. maybe it needs some optimization? var displayMemberPathText = !string.IsNullOrEmpty(DisplayMemberPath) && obj.GetType().GetProperty(DisplayMemberPath)?.GetValue(obj) is string value ? value : null; return displayMemberPathText ?? obj as string ?? obj.ToString(); } private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var self = (AutoSuggestBox)d; var newText = (string)e.NewValue; if (self.TextBox is null) { return; } if (self.TextBox.Text == newText) { return; } self._isChangedTextOutSideOfTextBox = true; self.TextBox.SetCurrentValue(System.Windows.Controls.TextBox.TextProperty, newText); self._isChangedTextOutSideOfTextBox = false; } }