// (c) Copyright Microsoft Corporation. // This source is subject to the Microsoft Public License (Ms-PL). // Please see https://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Avalonia.Reactive; using System.Threading; using System.Threading.Tasks; using Avalonia.Collections; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls { /// /// Represents the filter used by the /// control to /// determine whether an item is a possible match for the specified text. /// /// true to indicate is a possible match /// for ; otherwise false. /// The string used as the basis for filtering. /// The item that is compared with the /// parameter. /// The type used for filtering the /// . This type can /// be either a string or an object. public delegate bool AutoCompleteFilterPredicate(string? search, T item); /// /// Represents the selector used by the /// control to /// determine how the specified text should be modified with an item. /// /// /// Modified text that will be used by the /// . /// /// The string used as the basis for filtering. /// /// The selected item that should be combined with the /// parameter. /// /// /// The type used for filtering the /// . /// This type can be either a string or an object. /// public delegate string AutoCompleteSelector(string? search, T item); /// /// Represents a control that provides a text box for user input and a /// drop-down that contains possible matches based on the input in the text /// box. /// [TemplatePart(ElementPopup, typeof(Popup))] [TemplatePart(ElementSelector, typeof(SelectingItemsControl))] [TemplatePart(ElementSelectionAdapter, typeof(ISelectionAdapter))] [TemplatePart(ElementTextBox, typeof(TextBox))] [PseudoClasses(":dropdownopen")] public partial class AutoCompleteBox : TemplatedControl { /// /// Specifies the name of the selection adapter TemplatePart. /// private const string ElementSelectionAdapter = "PART_SelectionAdapter"; /// /// Specifies the name of the Selector TemplatePart. /// private const string ElementSelector = "PART_SelectingItemsControl"; /// /// Specifies the name of the Popup TemplatePart. /// private const string ElementPopup = "PART_Popup"; /// /// The name for the text box part. /// private const string ElementTextBox = "PART_TextBox"; /// /// Gets or sets a local cached copy of the items data. /// private List? _items; /// /// Gets or sets the observable collection that contains references to /// all of the items in the generated view of data that is provided to /// the selection-style control adapter. /// private AvaloniaList? _view; /// /// Gets or sets a value to ignore a number of pending change handlers. /// The value is decremented after each use. This is used to reset the /// value of properties without performing any of the actions in their /// change handlers. /// /// The int is important as a value because the TextBox /// TextChanged event does not immediately fire, and this will allow for /// nested property changes to be ignored. private int _ignoreTextPropertyChange; /// /// Gets or sets a value indicating whether to ignore calling a pending /// change handlers. /// private bool _ignorePropertyChange; /// /// Gets or sets a value indicating whether to ignore the selection /// changed event. /// private bool _ignoreTextSelectionChange; /// /// Gets or sets a value indicating whether to skip the text update /// processing when the selected item is updated. /// private bool _skipSelectedItemTextUpdate; /// /// Gets or sets the last observed text box selection start location. /// private int _textSelectionStart; /// /// Gets or sets a value indicating whether the user initiated the /// current populate call. /// private bool _userCalledPopulate; /// /// A value indicating whether the popup has been opened at least once. /// private bool _popupHasOpened; /// /// Gets or sets the DispatcherTimer used for the MinimumPopulateDelay /// condition for auto completion. /// private DispatcherTimer? _delayTimer; /// /// Gets or sets a value indicating whether a read-only dependency /// property change handler should allow the value to be set. This is /// used to ensure that read-only properties cannot be changed via /// SetValue, etc. /// private bool _allowWrite; /// /// A boolean indicating if a cancellation was requested /// private bool _cancelRequested; /// /// A boolean indicating if filtering is in action /// private bool _filterInAction; /// /// The TextBox template part. /// private TextBox? _textBox; private IDisposable? _textBoxSubscriptions; /// /// The SelectionAdapter. /// private ISelectionAdapter? _adapter; /// /// A control that can provide updated string values from a binding. /// private BindingEvaluator? _valueBindingEvaluator; /// /// A weak subscription for the collection changed event. /// private IDisposable? _collectionChangeSubscription; private CancellationTokenSource? _populationCancellationTokenSource; private bool _itemTemplateIsFromValueMemberBinding = true; private bool _settingItemTemplateFromValueMemberBinding; private bool _isFocused = false; private string? _searchText = string.Empty; private readonly EventHandler _populateDropDownHandler; /// /// /// public static readonly RoutedEvent SelectionChangedEvent = RoutedEvent.Register( nameof(SelectionChanged), RoutingStrategies.Bubble, typeof(AutoCompleteBox)); /// /// Defines the event. /// public static readonly RoutedEvent TextChangedEvent = RoutedEvent.Register( nameof(TextChanged), RoutingStrategies.Bubble); private static bool IsValidMinimumPrefixLength(int value) => value >= -1; private static bool IsValidMinimumPopulateDelay(TimeSpan value) => value.TotalMilliseconds >= 0.0; private static bool IsValidMaxDropDownHeight(double value) => value >= 0.0; private static bool IsValidFilterMode(AutoCompleteFilterMode mode) { switch (mode) { case AutoCompleteFilterMode.None: case AutoCompleteFilterMode.StartsWith: case AutoCompleteFilterMode.StartsWithCaseSensitive: case AutoCompleteFilterMode.StartsWithOrdinal: case AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive: case AutoCompleteFilterMode.Contains: case AutoCompleteFilterMode.ContainsCaseSensitive: case AutoCompleteFilterMode.ContainsOrdinal: case AutoCompleteFilterMode.ContainsOrdinalCaseSensitive: case AutoCompleteFilterMode.Equals: case AutoCompleteFilterMode.EqualsCaseSensitive: case AutoCompleteFilterMode.EqualsOrdinal: case AutoCompleteFilterMode.EqualsOrdinalCaseSensitive: case AutoCompleteFilterMode.Custom: return true; default: return false; } } /// /// Handle the change of the IsEnabled property. /// /// The event data. private void OnControlIsEnabledChanged(AvaloniaPropertyChangedEventArgs e) { bool isEnabled = (bool)e.NewValue!; if (!isEnabled) { SetCurrentValue(IsDropDownOpenProperty, false); } } /// /// MinimumPopulateDelayProperty property changed handler. Any current /// dispatcher timer will be stopped. The timer will not be restarted /// until the next TextUpdate call by the user. /// /// Event arguments. private void OnMinimumPopulateDelayChanged(AvaloniaPropertyChangedEventArgs e) { var newValue = (TimeSpan)e.NewValue!; // Stop any existing timer if (_delayTimer != null) { _delayTimer.Stop(); if (newValue == TimeSpan.Zero) { _delayTimer.Tick -= _populateDropDownHandler; _delayTimer = null; } } if (newValue > TimeSpan.Zero) { // Create or clear a dispatcher timer instance if (_delayTimer == null) { _delayTimer = new DispatcherTimer(); _delayTimer.Tick += _populateDropDownHandler; } // Set the new tick interval _delayTimer.Interval = newValue; } } /// /// IsDropDownOpenProperty property changed handler. /// /// Event arguments. private void OnIsDropDownOpenChanged(AvaloniaPropertyChangedEventArgs e) { // Ignore the change if requested if (_ignorePropertyChange) { _ignorePropertyChange = false; return; } bool oldValue = (bool)e.OldValue!; bool newValue = (bool)e.NewValue!; if (newValue) { TextUpdated(Text, true); } else { ClosingDropDown(oldValue); } UpdatePseudoClasses(); } private void OnSelectedItemPropertyChanged(AvaloniaPropertyChangedEventArgs e) { if (_ignorePropertyChange) { _ignorePropertyChange = false; return; } // Update the text display if (_skipSelectedItemTextUpdate) { _skipSelectedItemTextUpdate = false; } else { OnSelectedItemChanged(e.NewValue); } // Fire the SelectionChanged event List removed = new List(); if (e.OldValue != null) { removed.Add(e.OldValue); } List added = new List(); if (e.NewValue != null) { added.Add(e.NewValue); } OnSelectionChanged(new SelectionChangedEventArgs(SelectionChangedEvent, removed, added)); } /// /// TextProperty property changed handler. /// /// Event arguments. private void OnTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) { TextUpdated((string?)e.NewValue, false); } private void OnSearchTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) { if (_ignorePropertyChange) { _ignorePropertyChange = false; return; } // Ensure the property is only written when expected if (!_allowWrite) { // Reset the old value before it was incorrectly written _ignorePropertyChange = true; SetCurrentValue(e.Property, e.OldValue); throw new InvalidOperationException("Cannot set read-only property SearchText."); } } /// /// FilterModeProperty property changed handler. /// /// Event arguments. private void OnFilterModePropertyChanged(AvaloniaPropertyChangedEventArgs e) { AutoCompleteFilterMode mode = (AutoCompleteFilterMode)e.NewValue!; // Sets the filter predicate for the new value SetCurrentValue(TextFilterProperty, AutoCompleteSearch.GetFilter(mode)); } /// /// ItemFilterProperty property changed handler. /// /// Event arguments. private void OnItemFilterPropertyChanged(AvaloniaPropertyChangedEventArgs e) { var value = e.NewValue as AutoCompleteFilterPredicate; // If null, revert to the "None" predicate if (value == null) { SetCurrentValue(FilterModeProperty, AutoCompleteFilterMode.None); } else { SetCurrentValue(FilterModeProperty, AutoCompleteFilterMode.Custom); SetCurrentValue(TextFilterProperty, null); } } /// /// ItemsSourceProperty property changed handler. /// /// Event arguments. private void OnItemsSourcePropertyChanged(AvaloniaPropertyChangedEventArgs e) { OnItemsSourceChanged((IEnumerable?)e.NewValue); } private void OnItemTemplatePropertyChanged(AvaloniaPropertyChangedEventArgs e) { if (!_settingItemTemplateFromValueMemberBinding) _itemTemplateIsFromValueMemberBinding = false; } private void OnValueMemberBindingChanged(BindingBase? value) { if (_itemTemplateIsFromValueMemberBinding) { var template = new FuncDataTemplate( typeof(object), (o, _) => { var control = new ContentControl(); if (value is not null) control.Bind(ContentControl.ContentProperty, value); return control; }); _settingItemTemplateFromValueMemberBinding = true; SetCurrentValue(ItemTemplateProperty, template); _settingItemTemplateFromValueMemberBinding = false; } } static AutoCompleteBox() { FocusableProperty.OverrideDefaultValue(true); IsTabStopProperty.OverrideDefaultValue(false); MinimumPopulateDelayProperty.Changed.AddClassHandler((x,e) => x.OnMinimumPopulateDelayChanged(e)); IsDropDownOpenProperty.Changed.AddClassHandler((x,e) => x.OnIsDropDownOpenChanged(e)); SelectedItemProperty.Changed.AddClassHandler((x,e) => x.OnSelectedItemPropertyChanged(e)); TextProperty.Changed.AddClassHandler((x,e) => x.OnTextPropertyChanged(e)); SearchTextProperty.Changed.AddClassHandler((x,e) => x.OnSearchTextPropertyChanged(e)); FilterModeProperty.Changed.AddClassHandler((x,e) => x.OnFilterModePropertyChanged(e)); ItemFilterProperty.Changed.AddClassHandler((x,e) => x.OnItemFilterPropertyChanged(e)); ItemsSourceProperty.Changed.AddClassHandler((x,e) => x.OnItemsSourcePropertyChanged(e)); ItemTemplateProperty.Changed.AddClassHandler((x,e) => x.OnItemTemplatePropertyChanged(e)); IsEnabledProperty.Changed.AddClassHandler((x,e) => x.OnControlIsEnabledChanged(e)); } /// /// Initializes a new instance of the /// class. /// public AutoCompleteBox() { _populateDropDownHandler = PopulateDropDown; ClearView(); } /// /// Gets or sets the drop down popup control. /// private Popup? DropDownPopup { get; set; } /// /// Gets or sets the Text template part. /// private TextBox? TextBox { get => _textBox; set { _textBoxSubscriptions?.Dispose(); _textBox = value; // Attach handlers if (_textBox != null) { _textBoxSubscriptions = _textBox.GetObservable(TextBox.TextProperty) .Skip(1) .Subscribe(_ => OnTextBoxTextChanged()); if (Text != null) { UpdateTextValue(Text); } } } } private int TextBoxSelectionStart { get { if (TextBox != null) { return Math.Min(TextBox.SelectionStart, TextBox.SelectionEnd); } else { return 0; } } } private int TextBoxSelectionLength { get { if (TextBox != null) { return Math.Abs(TextBox.SelectionEnd - TextBox.SelectionStart); } else { return 0; } } } /// /// Gets or sets the selection adapter used to populate the drop-down /// with a list of selectable items. /// /// The selection adapter used to populate the drop-down with a /// list of selectable items. /// /// You can use this property when you create an automation peer to /// use with AutoCompleteBox or deriving from AutoCompleteBox to /// create a custom control. /// protected ISelectionAdapter? SelectionAdapter { get => _adapter; set { if (_adapter != null) { _adapter.SelectionChanged -= OnAdapterSelectionChanged; _adapter.Commit -= OnAdapterSelectionComplete; _adapter.Cancel -= OnAdapterSelectionCanceled; _adapter.Cancel -= OnAdapterSelectionComplete; _adapter.ItemsSource = null; } _adapter = value; if (_adapter != null) { _adapter.SelectionChanged += OnAdapterSelectionChanged; _adapter.Commit += OnAdapterSelectionComplete; _adapter.Cancel += OnAdapterSelectionCanceled; _adapter.Cancel += OnAdapterSelectionComplete; _adapter.ItemsSource = _view; } } } /// /// Returns the /// part, if /// possible. /// /// /// A object, /// if possible. Otherwise, null. /// protected virtual ISelectionAdapter? GetSelectionAdapterPart(INameScope nameScope) { ISelectionAdapter? adapter = null; SelectingItemsControl? selector = nameScope.Find(ElementSelector); if (selector != null) { // Check if it is already an IItemsSelector adapter = selector as ISelectionAdapter; if (adapter == null) { // Built in support for wrapping a Selector control adapter = new SelectingItemsControlSelectionAdapter(selector); } } if (adapter == null) { adapter = nameScope.Find(ElementSelectionAdapter); } return adapter; } /// /// Builds the visual tree for the /// control /// when a new template is applied. /// protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { if (DropDownPopup != null) { DropDownPopup.Closed -= DropDownPopup_Closed; DropDownPopup = null; } // Set the template parts. Individual part setters remove and add // any event handlers. Popup? popup = e.NameScope.Find(ElementPopup); if (popup != null) { DropDownPopup = popup; DropDownPopup.Closed += DropDownPopup_Closed; } SelectionAdapter = GetSelectionAdapterPart(e.NameScope); TextBox = e.NameScope.Find(ElementTextBox); // If the drop down property indicates that the popup is open, // flip its value to invoke the changed handler. if (IsDropDownOpen && DropDownPopup != null && !DropDownPopup.IsOpen) { OpeningDropDown(false); } base.OnApplyTemplate(e); } /// /// Provides handling for the /// event. /// /// A /// that contains the event data. protected override void OnKeyDown(KeyEventArgs e) { _ = e ?? throw new ArgumentNullException(nameof(e)); base.OnKeyDown(e); if (e.Handled || !IsEnabled) { return; } // The drop down is open, pass along the key event arguments to the // selection adapter. If it isn't handled by the adapter's logic, // then we handle some simple navigation scenarios for controlling // the drop down. if (IsDropDownOpen) { if (SelectionAdapter != null) { SelectionAdapter.HandleKeyDown(e); if (e.Handled) { return; } } if (e.Key == Key.Escape) { OnAdapterSelectionCanceled(this, new RoutedEventArgs()); e.Handled = true; } } else { // The drop down is not open, the Down key will toggle it open. // Ignore key buttons, if they are used for XY focus. if (e.Key == Key.Down && !XYFocusHelpers.IsAllowedXYNavigationMode(this, e.KeyDeviceType)) { SetCurrentValue(IsDropDownOpenProperty, true); e.Handled = true; } } // Standard drop down navigation switch (e.Key) { case Key.F4: SetCurrentValue(IsDropDownOpenProperty, !IsDropDownOpen); e.Handled = true; break; case Key.Enter: if (IsDropDownOpen) { OnAdapterSelectionComplete(this, new RoutedEventArgs()); e.Handled = true; } break; default: break; } } /// /// Provides handling for the /// event. /// /// A /// that contains the event data. protected override void OnGotFocus(GotFocusEventArgs e) { base.OnGotFocus(e); FocusChanged(HasFocus()); } /// /// Provides handling for the /// event. /// /// A /// that contains the event data. protected override void OnLostFocus(RoutedEventArgs e) { base.OnLostFocus(e); FocusChanged(HasFocus()); } /// /// Determines whether the text box or drop-down portion of the /// control has /// focus. /// /// true to indicate the /// has focus; /// otherwise, false. protected bool HasFocus() => IsKeyboardFocusWithin; /// /// Handles the FocusChanged event. /// /// A value indicating whether the control /// currently has the focus. private void FocusChanged(bool hasFocus) { // The OnGotFocus & OnLostFocus are asynchronously and cannot // reliably tell you that have the focus. All they do is let you // know that the focus changed sometime in the past. To determine // if you currently have the focus you need to do consult the // FocusManager (see HasFocus()). bool wasFocused = _isFocused; _isFocused = hasFocus; if (hasFocus) { if (!wasFocused && TextBox != null && TextBoxSelectionLength <= 0) { TextBox.Focus(); TextBox.SelectAll(); } } else { // Check if we still have focus in the parent's focus scope if (GetFocusScope() is { } scope && (FocusManager.GetFocusManager(this)?.GetFocusedElement(scope) is not { } focused || (focused != this && (focused is Visual v && !this.IsVisualAncestorOf(v))))) { SetCurrentValue(IsDropDownOpenProperty, false); } _userCalledPopulate = false; var textBoxContextMenuIsOpen = TextBox?.ContextFlyout?.IsOpen == true || TextBox?.ContextMenu?.IsOpen == true; var contextMenuIsOpen = ContextFlyout?.IsOpen == true || ContextMenu?.IsOpen == true; if (!textBoxContextMenuIsOpen && !contextMenuIsOpen && ClearSelectionOnLostFocus) { ClearTextBoxSelection(); } } _isFocused = hasFocus; IFocusScope? GetFocusScope() { IInputElement? c = this; while (c != null) { if (c is IFocusScope scope && c is Visual v && v.VisualRoot is Visual root && root.IsVisible) { return scope; } c = (c as Visual)?.GetVisualParent() ?? ((c as IHostedVisualTreeRoot)?.Host as IInputElement); } return null; } } /// /// Occurs asynchronously when the text in the portion of the /// changes. /// public event EventHandler? TextChanged { add => AddHandler(TextChangedEvent, value); remove => RemoveHandler(TextChangedEvent, value); } /// /// Occurs when the /// is /// populating the drop-down with possible matches based on the /// /// property. /// /// /// If the event is canceled, by setting the PopulatingEventArgs.Cancel /// property to true, the AutoCompleteBox will not automatically /// populate the selection adapter contained in the drop-down. /// In this case, if you want possible matches to appear, you must /// provide the logic for populating the selection adapter. /// public event EventHandler? Populating; /// /// Occurs when the /// has /// populated the drop-down with possible matches based on the /// /// property. /// public event EventHandler? Populated; /// /// Occurs when the value of the /// /// property is changing from false to true. /// public event EventHandler? DropDownOpening; /// /// Occurs when the value of the /// /// property has changed from false to true and the drop-down is open. /// public event EventHandler? DropDownOpened; /// /// Occurs when the /// /// property is changing from true to false. /// public event EventHandler? DropDownClosing; /// /// Occurs when the /// /// property was changed from true to false and the drop-down is open. /// public event EventHandler? DropDownClosed; /// /// Occurs when the selected item in the drop-down portion of the /// has /// changed. /// public event EventHandler SelectionChanged { add => AddHandler(SelectionChangedEvent, value); remove => RemoveHandler(SelectionChangedEvent, value); } /// /// Raises the /// /// event. /// /// A /// that /// contains the event data. protected virtual void OnPopulating(PopulatingEventArgs e) { Populating?.Invoke(this, e); } /// /// Raises the /// /// event. /// /// A /// /// that contains the event data. protected virtual void OnPopulated(PopulatedEventArgs e) { Populated?.Invoke(this, e); } /// /// Raises the /// /// event. /// /// A /// /// that contains the event data. protected virtual void OnSelectionChanged(SelectionChangedEventArgs e) { RaiseEvent(e); } /// /// Raises the /// /// event. /// /// A /// /// that contains the event data. protected virtual void OnDropDownOpening(CancelEventArgs e) { DropDownOpening?.Invoke(this, e); } /// /// Raises the /// /// event. /// /// A /// /// that contains the event data. protected virtual void OnDropDownOpened(EventArgs e) { DropDownOpened?.Invoke(this, e); } /// /// Raises the /// /// event. /// /// A /// /// that contains the event data. protected virtual void OnDropDownClosing(CancelEventArgs e) { DropDownClosing?.Invoke(this, e); } /// /// Raises the /// /// event. /// /// A /// /// which contains the event data. protected virtual void OnDropDownClosed(EventArgs e) { DropDownClosed?.Invoke(this, e); } /// /// Raises the event. /// /// A that contains the event data. protected virtual void OnTextChanged(TextChangedEventArgs e) { RaiseEvent(e); } /// /// Begin closing the drop-down. /// /// The original value. private void ClosingDropDown(bool oldValue) { var args = new CancelEventArgs(); OnDropDownClosing(args); if (args.Cancel) { _ignorePropertyChange = true; SetCurrentValue(IsDropDownOpenProperty, oldValue); } else { CloseDropDown(); } UpdatePseudoClasses(); } /// /// Begin opening the drop down by firing cancelable events, opening the /// drop-down or reverting, depending on the event argument values. /// /// The original value, if needed for a revert. private void OpeningDropDown(bool oldValue) { var args = new CancelEventArgs(); // Opening OnDropDownOpening(args); if (args.Cancel) { _ignorePropertyChange = true; SetCurrentValue(IsDropDownOpenProperty, oldValue); } else { OpenDropDown(); } UpdatePseudoClasses(); } /// /// Connects to the DropDownPopup Closed event. /// /// The source object. /// The event data. private void DropDownPopup_Closed(object? sender, EventArgs e) { // Force the drop down dependency property to be false. if (IsDropDownOpen) { SetCurrentValue(IsDropDownOpenProperty, false); } // Fire the DropDownClosed event if (_popupHasOpened) { OnDropDownClosed(EventArgs.Empty); } } /// /// Handles the timer tick when using a populate delay. /// /// The source object. /// The event arguments. private void PopulateDropDown(object? sender, EventArgs e) { _delayTimer?.Stop(); // Update the prefix/search text. SearchText = Text; if (TryPopulateAsync(SearchText)) { return; } // The Populated event enables advanced, custom filtering. The // client needs to directly update the ItemsSource collection or // call the Populate method on the control to continue the // display process if Cancel is set to true. PopulatingEventArgs populating = new PopulatingEventArgs(SearchText); OnPopulating(populating); if (!populating.Cancel) { PopulateComplete(); } } private bool TryPopulateAsync(string? searchText) { _populationCancellationTokenSource?.Cancel(false); _populationCancellationTokenSource?.Dispose(); _populationCancellationTokenSource = null; if (AsyncPopulator == null) { return false; } _populationCancellationTokenSource = new CancellationTokenSource(); var task = PopulateAsync(searchText, _populationCancellationTokenSource.Token); if (task.Status == TaskStatus.Created) task.Start(); return true; } private async Task PopulateAsync(string? searchText, CancellationToken cancellationToken) { try { IEnumerable result = await AsyncPopulator!.Invoke(searchText, cancellationToken); var resultList = result.ToList(); if (cancellationToken.IsCancellationRequested) { return; } await Dispatcher.UIThread.InvokeAsync(() => { if (!cancellationToken.IsCancellationRequested) { SetCurrentValue(ItemsSourceProperty, resultList); PopulateComplete(); } }); } catch (TaskCanceledException) { } finally { _populationCancellationTokenSource?.Dispose(); _populationCancellationTokenSource = null; } } /// /// Private method that directly opens the popup, checks the expander /// button, and then fires the Opened event. /// private void OpenDropDown() { if (DropDownPopup != null) { DropDownPopup.IsOpen = true; } _popupHasOpened = true; OnDropDownOpened(EventArgs.Empty); } /// /// Private method that directly closes the popup, flips the Checked /// value, and then fires the Closed event. /// private void CloseDropDown() { if (_popupHasOpened) { if (SelectionAdapter != null) { SelectionAdapter.SelectedItem = null; } if (DropDownPopup != null) { DropDownPopup.IsOpen = false; } OnDropDownClosed(EventArgs.Empty); } } /// /// Formats an Item for text comparisons based on Converter /// and ConverterCulture properties. /// /// The object to format. /// A value indicating whether to clear /// the data context after the lookup is performed. /// Formatted Value. private string? FormatValue(object? value, bool clearDataContext) { string? result = FormatValue(value); if (clearDataContext && _valueBindingEvaluator != null) { _valueBindingEvaluator.ClearDataContext(); } return result; } /// /// Converts the specified object to a string by using the /// and /// values /// of the binding object specified by the /// /// property. /// /// The object to format as a string. /// The string representation of the specified object. /// /// Override this method to provide a custom string conversion. /// protected virtual string? FormatValue(object? value) { if (_valueBindingEvaluator != null) { return _valueBindingEvaluator.GetDynamicValue(value) ?? String.Empty; } return value == null ? String.Empty : value.ToString(); } /// /// Handle the TextChanged event that is directly attached to the /// TextBox part. This ensures that only user initiated actions will /// result in an AutoCompleteBox suggestion and operation. /// private void OnTextBoxTextChanged() { //Uses Dispatcher.Post to allow the TextBox selection to update before processing Dispatcher.UIThread.Post(() => { // Call the central updated text method as a user-initiated action TextUpdated(_textBox!.Text, true); }); } /// /// Updates both the text box value and underlying text dependency /// property value if and when they change. Automatically fires the /// text changed events when there is a change. /// /// The new string value. private void UpdateTextValue(string? value) { UpdateTextValue(value, null); } /// /// Updates both the text box value and underlying text dependency /// property value if and when they change. Automatically fires the /// text changed events when there is a change. /// /// The new string value. /// A nullable bool value indicating whether /// the action was user initiated. In a user initiated mode, the /// underlying text dependency property is updated. In a non-user /// interaction, the text box value is updated. When user initiated is /// null, all values are updated. private void UpdateTextValue(string? value, bool? userInitiated) { bool callTextChanged = false; // Update the Text dependency property if ((userInitiated ?? true) && Text != value) { _ignoreTextPropertyChange++; SetCurrentValue(TextProperty, value); callTextChanged = true; } // Update the TextBox's Text dependency property if ((userInitiated == null || userInitiated == false) && TextBox != null && TextBox.Text != value) { _ignoreTextPropertyChange++; TextBox.Text = value ?? string.Empty; // Text dependency property value was set, fire event if (!callTextChanged && (Text == value || Text == null)) { callTextChanged = true; } } if (callTextChanged) { OnTextChanged(new TextChangedEventArgs(TextChangedEvent)); } } /// /// Handle the update of the text for the control from any source, /// including the TextBox part and the Text dependency property. /// /// The new text. /// A value indicating whether the update /// is a user-initiated action. This should be a True value when the /// TextUpdated method is called from a TextBox event handler. private void TextUpdated(string? newText, bool userInitiated) { // Only process this event if it is coming from someone outside // setting the Text dependency property directly. if (_ignoreTextPropertyChange > 0) { _ignoreTextPropertyChange--; return; } if (newText == null) { newText = string.Empty; } // The TextBox.TextChanged event was not firing immediately and // was causing an immediate update, even with wrapping. If there is // a selection currently, no update should happen. if (IsTextCompletionEnabled && TextBox != null && TextBoxSelectionLength > 0 && TextBoxSelectionStart != (TextBox.Text?.Length ?? 0)) { return; } // Evaluate the conditions needed for completion. // 1. Minimum prefix length // 2. If a delay timer is in use, use it bool minimumLengthReached = newText.Length >= MinimumPrefixLength && MinimumPrefixLength >= 0; _userCalledPopulate = minimumLengthReached && userInitiated; // Update the interface and values only as necessary UpdateTextValue(newText, userInitiated); if (minimumLengthReached) { _ignoreTextSelectionChange = true; if (_delayTimer != null) { _delayTimer.Start(); } else { PopulateDropDown(this, EventArgs.Empty); } } else { SearchText = string.Empty; if (SelectedItem != null) { _skipSelectedItemTextUpdate = true; } SetCurrentValue(SelectedItemProperty, null); if (IsDropDownOpen) { SetCurrentValue(IsDropDownOpenProperty, false); } } } /// /// A simple helper method to clear the view and ensure that a view /// object is always present and not null. /// private void ClearView() { if (_view == null) { _view = new AvaloniaList(); } else { _view.Clear(); } } /// /// Walks through the items enumeration. Performance is not going to be /// perfect with the current implementation. /// private void RefreshView() { // If we have a running filter, trigger a request first if (_filterInAction) { _cancelRequested = true; } // Indicate that filtering is ongoing _filterInAction = true; try { if (_items == null) { ClearView(); return; } // Cache the current text value string text = Text ?? string.Empty; // Determine if any filtering mode is on bool stringFiltering = TextFilter != null; bool objectFiltering = FilterMode == AutoCompleteFilterMode.Custom && TextFilter == null; List items = _items; // cache properties var textFilter = TextFilter; var itemFilter = ItemFilter; var _newViewItems = new Collection(); // if the mode is objectFiltering and itemFilter is null, we throw an exception if (objectFiltering && itemFilter is null) { throw new Exception( "ItemFilter property can not be null when FilterMode has value AutoCompleteFilterMode.Custom"); } foreach (object item in items) { // Exit the fitter when requested if cancellation is requested if (_cancelRequested) { return; } bool inResults = !(stringFiltering || objectFiltering); if (!inResults) { if (stringFiltering) { inResults = textFilter!(text, FormatValue(item)); } else if (objectFiltering) { inResults = itemFilter!(text, item); } } if (inResults) { _newViewItems.Add(item); } } _view?.Clear(); _view?.AddRange(_newViewItems); // Clear the evaluator to discard a reference to the last item _valueBindingEvaluator?.ClearDataContext(); } finally { // indicate that filtering is not ongoing anymore _filterInAction = false; _cancelRequested = false; } } /// /// Handle any change to the ItemsSource dependency property, update /// the underlying ObservableCollection view, and set the selection /// adapter's ItemsSource to the view if appropriate. /// /// The new enumerable reference. private void OnItemsSourceChanged(IEnumerable? newValue) { // Remove handler for oldValue.CollectionChanged (if present) _collectionChangeSubscription?.Dispose(); _collectionChangeSubscription = null; // Add handler for newValue.CollectionChanged (if possible) if (newValue is INotifyCollectionChanged newValueINotifyCollectionChanged) { _collectionChangeSubscription = newValueINotifyCollectionChanged.WeakSubscribe(ItemsCollectionChanged); } // Store a local cached copy of the data _items = newValue == null ? null : new List(newValue.Cast()); // Clear and set the view on the selection adapter ClearView(); if (SelectionAdapter != null && SelectionAdapter.ItemsSource != _view) { SelectionAdapter.ItemsSource = _view; } if (IsDropDownOpen) { RefreshView(); } } /// /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. /// /// The object that raised the event. /// The event data. private void ItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Update the cache if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) { for (int index = 0; index < e.OldItems.Count; index++) { _items!.RemoveAt(e.OldStartingIndex); } } if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && _items!.Count >= e.NewStartingIndex) { for (int index = 0; index < e.NewItems.Count; index++) { _items.Insert(e.NewStartingIndex + index, e.NewItems[index]!); } } if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) { for (int index = 0; index < e.NewItems.Count; index++) { _items![e.NewStartingIndex] = e.NewItems[index]!; } } // Update the view if ((e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) && e.OldItems != null) { for (int index = 0; index < e.OldItems.Count; index++) { _view!.Remove(e.OldItems[index]!); } } if (e.Action == NotifyCollectionChangedAction.Reset) { // Significant changes to the underlying data. ClearView(); if (ItemsSource != null) { _items = new List(ItemsSource.Cast()); } } // Refresh the observable collection used in the selection adapter. RefreshView(); } /// /// Notifies the /// that the /// /// property has been set and the data can be filtered to provide /// possible matches in the drop-down. /// /// /// Call this method when you are providing custom population of /// the drop-down portion of the AutoCompleteBox, to signal the control /// that you are done with the population process. /// Typically, you use PopulateComplete when the population process /// is a long-running process and you want to cancel built-in filtering /// of the ItemsSource items. In this case, you can handle the /// Populated event and set PopulatingEventArgs.Cancel to true. /// When the long-running process has completed you call /// PopulateComplete to indicate the drop-down is populated. /// public void PopulateComplete() { // Apply the search filter RefreshView(); // Fire the Populated event containing the read-only view data. PopulatedEventArgs populated = new PopulatedEventArgs(new ReadOnlyCollection(_view!)); OnPopulated(populated); if (SelectionAdapter != null && SelectionAdapter.ItemsSource != _view) { SelectionAdapter.ItemsSource = _view; } bool isDropDownOpen = _userCalledPopulate && (_view!.Count > 0); if (isDropDownOpen != IsDropDownOpen) { _ignorePropertyChange = true; SetCurrentValue(IsDropDownOpenProperty, isDropDownOpen); } if (IsDropDownOpen) { OpeningDropDown(false); } else { ClosingDropDown(true); } UpdateTextCompletion(_userCalledPopulate); } /// /// Performs text completion, if enabled, and a lookup on the underlying /// item values for an exact match. Will update the SelectedItem value. /// /// A value indicating whether the operation /// was user initiated. Text completion will not be performed when not /// directly initiated by the user. private void UpdateTextCompletion(bool userInitiated) { // By default this method will clear the selected value object? newSelectedItem = null; string? text = Text; // Text search is StartsWith explicit and only when enabled, in // line with WPF's ComboBox lookup. When in use it will associate // a Value with the Text if it is found in ItemsSource. This is // only valid when there is data and the user initiated the action. if (_view!.Count > 0) { if (IsTextCompletionEnabled && TextBox != null && userInitiated) { int currentLength = TextBox.Text?.Length ?? 0; int selectionStart = TextBoxSelectionStart; if (selectionStart == text?.Length && selectionStart > _textSelectionStart) { // When the FilterMode dependency property is set to // either StartsWith or StartsWithCaseSensitive, the // first item in the view is used. This will improve // performance on the lookup. It assumes that the // FilterMode the user has selected is an acceptable // case sensitive matching function for their scenario. object? top = FilterMode == AutoCompleteFilterMode.StartsWith || FilterMode == AutoCompleteFilterMode.StartsWithCaseSensitive ? _view[0] : TryGetMatch(text, _view, AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); // If the search was successful, update SelectedItem if (top != null) { newSelectedItem = top; string? topString = FormatValue(top, true); // Only replace partially when the two words being the same int minLength = Math.Min(topString?.Length ?? 0, Text?.Length ?? 0); if (AutoCompleteSearch.Equals(Text?.Substring(0, minLength), topString?.Substring(0, minLength))) { // Update the text UpdateTextValue(topString); // Select the text past the user's caret TextBox.SelectionStart = currentLength; TextBox.SelectionEnd = topString?.Length ?? 0; } } } } else { // Perform an exact string lookup for the text. This is a // design change from the original Toolkit release when the // IsTextCompletionEnabled property behaved just like the // WPF ComboBox's IsTextSearchEnabled property. // // This change provides the behavior that most people expect // to find: a lookup for the value is always performed. newSelectedItem = TryGetMatch(text, _view, AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)); } } // Update the selected item property if (SelectedItem != newSelectedItem) { _skipSelectedItemTextUpdate = true; } SetCurrentValue(SelectedItemProperty, newSelectedItem); // Restore updates for TextSelection if (_ignoreTextSelectionChange) { _ignoreTextSelectionChange = false; if (TextBox != null) { _textSelectionStart = TextBoxSelectionStart; } } } /// /// Attempts to look through the view and locate the specific exact /// text match. /// /// The search text. /// The view reference. /// The predicate to use for the partial or /// exact match. /// Returns the object or null. private object? TryGetMatch(string? searchText, AvaloniaList? view, AutoCompleteFilterPredicate? predicate) { if (predicate is null) return null; if (view != null && view.Count > 0) { foreach (object o in view) { if (predicate(searchText, FormatValue(o))) { return o; } } } return null; } private void UpdatePseudoClasses() { PseudoClasses.Set(":dropdownopen", IsDropDownOpen); } private void ClearTextBoxSelection() { if (TextBox != null) { int length = TextBox.Text?.Length ?? 0; TextBox.SelectionStart = length; TextBox.SelectionEnd = length; } } /// /// Called when the selected item is changed, updates the text value /// that is displayed in the text box part. /// /// The new item. private void OnSelectedItemChanged(object? newItem) { string? text; if (newItem == null) { text = SearchText; } else if (TextSelector != null) { text = TextSelector(SearchText, FormatValue(newItem, true)); } else if (ItemSelector != null) { text = ItemSelector(SearchText, newItem); } else { text = FormatValue(newItem, true); } // Update the Text property and the TextBox values UpdateTextValue(text); // Move the caret to the end of the text box ClearTextBoxSelection(); } /// /// Handles the SelectionChanged event of the selection adapter. /// /// The source object. /// The selection changed event data. private void OnAdapterSelectionChanged(object? sender, SelectionChangedEventArgs e) { SetCurrentValue(SelectedItemProperty, _adapter!.SelectedItem); } //TODO Check UpdateTextCompletion /// /// Handles the Commit event on the selection adapter. /// /// The source object. /// The event data. private void OnAdapterSelectionComplete(object? sender, RoutedEventArgs e) { SetCurrentValue(IsDropDownOpenProperty, false); // Completion will update the selected value //UpdateTextCompletion(false); // Text should not be selected ClearTextBoxSelection(); TextBox!.Focus(); } /// /// Handles the Cancel event on the selection adapter. /// /// The source object. /// The event data. private void OnAdapterSelectionCanceled(object? sender, RoutedEventArgs e) { UpdateTextValue(SearchText); // Completion will update the selected value UpdateTextCompletion(false); } /// /// A predefined set of filter functions for the known, built-in /// AutoCompleteFilterMode enumeration values. /// private static class AutoCompleteSearch { /// /// Index function that retrieves the filter for the provided /// AutoCompleteFilterMode. /// /// The built-in search mode. /// Returns the string-based comparison function. public static AutoCompleteFilterPredicate? GetFilter(AutoCompleteFilterMode FilterMode) { switch (FilterMode) { case AutoCompleteFilterMode.Contains: return Contains; case AutoCompleteFilterMode.ContainsCaseSensitive: return ContainsCaseSensitive; case AutoCompleteFilterMode.ContainsOrdinal: return ContainsOrdinal; case AutoCompleteFilterMode.ContainsOrdinalCaseSensitive: return ContainsOrdinalCaseSensitive; case AutoCompleteFilterMode.Equals: return Equals; case AutoCompleteFilterMode.EqualsCaseSensitive: return EqualsCaseSensitive; case AutoCompleteFilterMode.EqualsOrdinal: return EqualsOrdinal; case AutoCompleteFilterMode.EqualsOrdinalCaseSensitive: return EqualsOrdinalCaseSensitive; case AutoCompleteFilterMode.StartsWith: return StartsWith; case AutoCompleteFilterMode.StartsWithCaseSensitive: return StartsWithCaseSensitive; case AutoCompleteFilterMode.StartsWithOrdinal: return StartsWithOrdinal; case AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive: return StartsWithOrdinalCaseSensitive; case AutoCompleteFilterMode.None: case AutoCompleteFilterMode.Custom: default: return null; } } /// /// An implementation of the Contains member of string that takes in a /// string comparison. The traditional .NET string Contains member uses /// StringComparison.Ordinal. /// /// The string. /// The string value to search for. /// The string comparison type. /// Returns true when the substring is found. private static bool Contains(string? s, string? value, StringComparison comparison) { if (s is not null && value is not null) return s.IndexOf(value, comparison) >= 0; return false; } /// /// Check if the string value begins with the text. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool StartsWith(string? text, string? value) { if (value is not null && text is not null) return value.StartsWith(text, StringComparison.CurrentCultureIgnoreCase); return false; } /// /// Check if the string value begins with the text. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool StartsWithCaseSensitive(string? text, string? value) { if (value is not null && text is not null) return value.StartsWith(text, StringComparison.CurrentCulture); return false; } /// /// Check if the string value begins with the text. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool StartsWithOrdinal(string? text, string? value) { if (value is not null && text is not null) return value.StartsWith(text, StringComparison.OrdinalIgnoreCase); return false; } /// /// Check if the string value begins with the text. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool StartsWithOrdinalCaseSensitive(string? text, string? value) { if (value is not null && text is not null) return value.StartsWith(text, StringComparison.Ordinal); return false; } /// /// Check if the prefix is contained in the string value. The current /// culture's case insensitive string comparison operator is used. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool Contains(string? text, string? value) { return Contains(value, text, StringComparison.CurrentCultureIgnoreCase); } /// /// Check if the prefix is contained in the string value. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool ContainsCaseSensitive(string? text, string? value) { return Contains(value, text, StringComparison.CurrentCulture); } /// /// Check if the prefix is contained in the string value. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool ContainsOrdinal(string? text, string? value) { return Contains(value, text, StringComparison.OrdinalIgnoreCase); } /// /// Check if the prefix is contained in the string value. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool ContainsOrdinalCaseSensitive(string? text, string? value) { return Contains(value, text, StringComparison.Ordinal); } /// /// Check if the string values are equal. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool Equals(string? text, string? value) { return string.Equals(value, text, StringComparison.CurrentCultureIgnoreCase); } /// /// Check if the string values are equal. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool EqualsCaseSensitive(string? text, string? value) { return string.Equals(value, text, StringComparison.CurrentCulture); } /// /// Check if the string values are equal. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool EqualsOrdinal(string? text, string? value) { return string.Equals(value, text, StringComparison.OrdinalIgnoreCase); } /// /// Check if the string values are equal. /// /// The AutoCompleteBox prefix text. /// The item's string value. /// Returns true if the condition is met. public static bool EqualsOrdinalCaseSensitive(string? text, string? value) { return string.Equals(value, text, StringComparison.Ordinal); } } // TODO12: Remove, this shouldn't be part of the public API. Use our internal BindingEvaluator instead. /// /// A framework element that permits a binding to be evaluated in a new data /// context leaf node. /// /// The type of dynamic binding to return. public class BindingEvaluator : Control { /// /// Gets or sets the string value binding used by the control. /// private BindingBase? _binding; /// /// Identifies the Value dependency property. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1002:AvaloniaProperty objects should not be owned by a generic type", Justification = "This property is not supposed to be used from XAML.")] public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register, T>(nameof(Value)); /// /// Gets or sets the data item value. /// public T Value { get => GetValue(ValueProperty); set => SetValue(ValueProperty, value); } /// /// Gets or sets the value binding. /// public BindingBase? ValueBinding { get => _binding; set { _binding = value; if (value is not null) Bind(ValueProperty, value); } } /// /// Initializes a new instance of the BindingEvaluator class. /// public BindingEvaluator() { } /// /// Initializes a new instance of the BindingEvaluator class, /// setting the initial binding to the provided parameter. /// /// The initial string value binding. public BindingEvaluator(BindingBase? binding) : this() { ValueBinding = binding; } /// /// Clears the data context so that the control does not keep a /// reference to the last-looked up item. /// public void ClearDataContext() { DataContext = null; } /// /// Updates the data context of the framework element and returns the /// updated binding value. /// /// The object to use as the data context. /// If set to true, this parameter will /// clear the data context immediately after retrieving the value. /// Returns the evaluated T value of the bound dependency /// property. public T GetDynamicValue(object o, bool clearDataContext) { DataContext = o; T value = Value; if (clearDataContext) { DataContext = null; } return value; } /// /// Updates the data context of the framework element and returns the /// updated binding value. /// /// The object to use as the data context. /// Returns the evaluated T value of the bound dependency /// property. public T GetDynamicValue(object? o) { DataContext = o; return Value; } } } }