using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Collections; using Avalonia; using Avalonia.Collections; using Avalonia.Controls; 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; using Irihi.Avalonia.Shared.Contracts; using Irihi.Avalonia.Shared.Helpers; using Ursa.Common; namespace Ursa.Controls; public partial class MultiAutoCompleteBox : TemplatedControl, IInnerContentControl { /// /// 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"; /// /// public static readonly RoutedEvent SelectionChangedEvent = RoutedEvent.Register( nameof(SelectionChanged), RoutingStrategies.Bubble); /// /// Defines the event. /// public static readonly RoutedEvent TextChangedEvent = RoutedEvent.Register( nameof(TextChanged), RoutingStrategies.Bubble); private readonly EventHandler _populateDropDownHandler; /// /// The SelectionAdapter. /// private ISelectionAdapter? _adapter; /// /// 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 weak subscription for the collection changed event. /// private IDisposable? _collectionChangeSubscription; /// /// Gets or sets the DispatcherTimer used for the MinimumPopulateDelay /// condition for auto completion. /// private DispatcherTimer? _delayTimer; /// /// A boolean indicating if filtering is in action /// private bool _filterInAction; /// /// Gets or sets a value indicating whether to ignore calling a pending /// change handlers. /// private bool _ignorePropertyChange; /// /// 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 the selection /// changed event. /// private bool _ignoreTextSelectionChange; private bool _isFocused; /// /// Gets or sets a local cached copy of the items data. /// private List? _items; private bool _itemTemplateIsFromValueMemberBinding = true; private CancellationTokenSource? _populationCancellationTokenSource; /// /// A value indicating whether the popup has been opened at least once. /// private bool _popupHasOpened; private string? _searchText = string.Empty; private bool _settingItemTemplateFromValueMemberBinding; /// /// Gets or sets a value indicating whether to skip the text update /// processing when the selected item is updated. /// private bool _skipSelectedItemTextUpdate; /// /// The TextBox template part. /// private TextBox? _textBox; private IDisposable? _textBoxSubscriptions; /// /// 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 control that can provide updated string values from a binding. /// private BindingEvaluator? _valueBindingEvaluator; /// /// 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; static MultiAutoCompleteBox() { 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 MultiAutoCompleteBox() { _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); return 0; } } private int TextBoxSelectionLength { get { if (TextBox != null) return Math.Abs(TextBox.SelectionEnd - TextBox.SelectionStart); 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; } } } private static bool IsValidMinimumPrefixLength(int value) { return value >= -1; } private static bool IsValidMinimumPopulateDelay(TimeSpan value) { return value.TotalMilliseconds >= 0.0; } private static bool IsValidMaxDropDownHeight(double value) { return 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) { var 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; } var oldValue = (bool)e.OldValue!; var 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 var removed = new List(); if (e.OldValue != null) removed.Add(e.OldValue); var 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(IBinding? 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; } } /// /// 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 // Built in support for wrapping a Selector control adapter = new MultiAutoCompleteSelectionAdapter(selector); } return adapter ?? nameScope.Find(ElementSelectionAdapter); } /// /// 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); _selectedItemsControl = e.NameScope.Find(PART_SelectedItemsControl); } public const string PART_SelectedItemsControl = "PART_SelectedItemsControl"; private ItemsControl? _selectedItemsControl; protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); TextBox = (_selectedItemsControl?.ItemsPanelRoot as WrapPanelWithTrailingItem)?.TrailingItem as TextBox; } /// /// Called to update the validation state for properties for which data validation is /// enabled. /// /// The property. /// The current data binding state. /// The current data binding error, if any. protected override void UpdateDataValidation( AvaloniaProperty property, BindingValueType state, Exception? error) { if (property == TextProperty) { DataValidationErrors.SetError(this, error); } } /// /// 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 && !this.IsAllowedXYNavigationMode(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; } } /// /// 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() { return 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()). var 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 && (TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() is not { } focused || (focused != this && focused is not ListBoxItem && focused is Visual v && !this.IsVisualAncestorOf(v)))) SetCurrentValue(IsDropDownOpenProperty, false); _userCalledPopulate = false; if (ContextMenu is not { IsOpen: true }) ClearTextBoxSelection(); } _isFocused = hasFocus; IFocusScope? GetFocusScope() { IInputElement? c = this; while (c != null) { if (c is IFocusScope scope && c is Visual v && v.GetVisualRoot() 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(System.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(System.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, System.EventArgs e) { // Force the drop down dependency property to be false. if (IsDropDownOpen) SetCurrentValue(IsDropDownOpenProperty, false); // Fire the DropDownClosed event if (_popupHasOpened) OnDropDownClosed(System.EventArgs.Empty); } /// /// Handles the timer tick when using a populate delay. /// /// The source object. /// The event arguments. private void PopulateDropDown(object? sender, System.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 { var 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(System.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(System.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) { var 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) { var 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 var 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, System.EventArgs.Empty); } else { // TODO implement selection. /* 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 var text = Text ?? string.Empty; // Determine if any filtering mode is on var stringFiltering = TextFilter != null; var objectFiltering = FilterMode == AutoCompleteFilterMode.Custom && TextFilter == null; var 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 (var item in items) { // Exit the fitter when requested if cancellation is requested if (_cancelRequested) return; var 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 (var index = 0; index < e.OldItems.Count; index++) _items!.RemoveAt(e.OldStartingIndex); if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && _items!.Count >= e.NewStartingIndex) for (var 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 (var 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 (var 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; var 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; var 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) { var currentLength = TextBox.Text?.Length ?? 0; var 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. var 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; var topString = FormatValue(top, true); // Only replace partially when the two words being the same var 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 // TODO set selection. /* 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) { var 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) { // TODO set selection. // SetCurrentValue(SelectedItemProperty, _adapter!.SelectedItem); SelectedItems?.Add(_adapter?.SelectedItem); // UpdateTextValue(null); } //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); } } public void Remove(object? o) { if (o is not StyledElement s) return; if (SelectedItems?.Contains(s.DataContext) == true) { SelectedItems.Remove(s.DataContext); } } // 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 { /// /// Identifies the Value dependency property. /// [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 string value binding used by the control. /// private IBinding? _binding; /// /// 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(IBinding? binding) : this() { ValueBinding = binding; } /// /// Gets or sets the data item value. /// public T Value { get => GetValue(ValueProperty); set => SetValue(ValueProperty, value); } /// /// Gets or sets the value binding. /// public IBinding? ValueBinding { get => _binding; set { _binding = value; if (value is not null) Bind(ValueProperty, value); } } /// /// 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; var 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; } } }