From 9cee8d9341485f7b6a6588f7fd6b39670d8be49e Mon Sep 17 00:00:00 2001 From: Dong Bin Date: Wed, 3 Sep 2025 22:50:32 +0800 Subject: [PATCH] feat: bring code from Avalonia to Ursa. --- src/Ursa/Common/ObservableHelper.cs | 38 + src/Ursa/Common/XYFocusHelpers.cs | 23 + .../MultiAutoCompleteBox.Properties.cs | 513 +++++ .../AutoCompleteBox/MultiAutoCompleteBox.cs | 1970 +++++++++++++++++ 4 files changed, 2544 insertions(+) create mode 100644 src/Ursa/Common/ObservableHelper.cs create mode 100644 src/Ursa/Common/XYFocusHelpers.cs create mode 100644 src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.Properties.cs create mode 100644 src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.cs diff --git a/src/Ursa/Common/ObservableHelper.cs b/src/Ursa/Common/ObservableHelper.cs new file mode 100644 index 0000000..cf77f97 --- /dev/null +++ b/src/Ursa/Common/ObservableHelper.cs @@ -0,0 +1,38 @@ +using Avalonia.Reactive; + +namespace Ursa.Common; + +internal static class ObservableHelper +{ + public static IObservable Skip(this IObservable source, int skipCount) + { + if (skipCount <= 0) throw new ArgumentException("Skip count must be bigger than zero", nameof(skipCount)); + + return Create(obs => + { + var remaining = skipCount; + return source.Subscribe(new AnonymousObserver( + input => + { + if (remaining <= 0) + obs.OnNext(input); + else + remaining--; + }, obs.OnError, obs.OnCompleted)); + }); + } + + public static IObservable Create(Func, IDisposable> subscribe) + { + return new CreateWithDisposableObservable(subscribe); + } + + private sealed class CreateWithDisposableObservable(Func, IDisposable> subscribe) + : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + return subscribe(observer); + } + } +} \ No newline at end of file diff --git a/src/Ursa/Common/XYFocusHelpers.cs b/src/Ursa/Common/XYFocusHelpers.cs new file mode 100644 index 0000000..27bd055 --- /dev/null +++ b/src/Ursa/Common/XYFocusHelpers.cs @@ -0,0 +1,23 @@ +using Avalonia.Input; + +namespace Ursa.Common; + +internal static class XYFocusHelpers +{ + internal static bool IsAllowedXYNavigationMode(this InputElement visual, KeyDeviceType? keyDeviceType) + { + return IsAllowedXYNavigationMode(XYFocus.GetNavigationModes(visual), keyDeviceType); + } + + private static bool IsAllowedXYNavigationMode(XYFocusNavigationModes modes, KeyDeviceType? keyDeviceType) + { + return keyDeviceType switch + { + null => !modes.Equals(XYFocusNavigationModes.Disabled), // programmatic input, allow any subtree except Disabled. + KeyDeviceType.Keyboard => modes.HasFlag(XYFocusNavigationModes.Keyboard), + KeyDeviceType.Gamepad => modes.HasFlag(XYFocusNavigationModes.Gamepad), + KeyDeviceType.Remote => modes.HasFlag(XYFocusNavigationModes.Remote), + _ => throw new ArgumentOutOfRangeException(nameof(keyDeviceType), keyDeviceType, null) + }; + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.Properties.cs b/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.Properties.cs new file mode 100644 index 0000000..53eb4b2 --- /dev/null +++ b/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.Properties.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Metadata; + +namespace Ursa.Controls; + +public partial class MultiAutoCompleteBox + { + /// + /// Defines see property. + /// + public static readonly StyledProperty CaretIndexProperty = + TextBox.CaretIndexProperty.AddOwner(new( + defaultValue: 0, + defaultBindingMode:BindingMode.TwoWay)); + + public static readonly StyledProperty WatermarkProperty = + TextBox.WatermarkProperty.AddOwner(); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty MinimumPrefixLengthProperty = + AvaloniaProperty.Register( + nameof(MinimumPrefixLength), 1, + validate: IsValidMinimumPrefixLength); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty MinimumPopulateDelayProperty = + AvaloniaProperty.Register( + nameof(MinimumPopulateDelay), + TimeSpan.Zero, + validate: IsValidMinimumPopulateDelay); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty MaxDropDownHeightProperty = + AvaloniaProperty.Register( + nameof(MaxDropDownHeight), + double.PositiveInfinity, + validate: IsValidMaxDropDownHeight); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty IsTextCompletionEnabledProperty = + AvaloniaProperty.Register( + nameof(IsTextCompletionEnabled)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register( + nameof(ItemTemplate)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty IsDropDownOpenProperty = + AvaloniaProperty.Register( + nameof(IsDropDownOpen)); + + /// + /// Identifies the property. + /// + /// The identifier the property. + public static readonly StyledProperty SelectedItemProperty = + AvaloniaProperty.Register( + nameof(SelectedItem), + defaultBindingMode: BindingMode.TwoWay, + enableDataValidation: true); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(new(string.Empty, + defaultBindingMode: BindingMode.TwoWay, + enableDataValidation: true)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly DirectProperty SearchTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(SearchText), + o => o.SearchText, + unsetValue: string.Empty); + + /// + /// Gets the identifier for the property. + /// + public static readonly StyledProperty FilterModeProperty = + AvaloniaProperty.Register( + nameof(FilterMode), + defaultValue: AutoCompleteFilterMode.StartsWith, + validate: IsValidFilterMode); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty?> ItemFilterProperty = + AvaloniaProperty.Register?>( + nameof(ItemFilter)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty?> TextFilterProperty = + AvaloniaProperty.Register?>( + nameof(TextFilter), + defaultValue: AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty?> ItemSelectorProperty = + AvaloniaProperty.Register?>( + nameof(ItemSelector)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty?> TextSelectorProperty = + AvaloniaProperty.Register?>( + nameof(TextSelector)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty ItemsSourceProperty = + AvaloniaProperty.Register( + nameof(ItemsSource)); + + /// + /// Identifies the property. + /// + /// The identifier for the property. + public static readonly StyledProperty>>?> AsyncPopulatorProperty = + AvaloniaProperty.Register>>?>( + nameof(AsyncPopulator)); + + /// + /// Defines the property + /// + public static readonly StyledProperty MaxLengthProperty = + TextBox.MaxLengthProperty.AddOwner(); + + /// + /// Defines the property + /// + public static readonly StyledProperty InnerLeftContentProperty = + TextBox.InnerLeftContentProperty.AddOwner(); + + /// + /// Defines the property + /// + public static readonly StyledProperty InnerRightContentProperty = + TextBox.InnerRightContentProperty.AddOwner(); + + /// + /// Gets or sets the caret index + /// + public int CaretIndex + { + get => GetValue(CaretIndexProperty); + set => SetValue(CaretIndexProperty, value); + } + + /// + /// Gets or sets the minimum number of characters required to be entered + /// in the text box before the displays possible matches. + /// + /// + /// The minimum number of characters to be entered in the text box + /// before the + /// displays possible matches. The default is 1. + /// + /// + /// If you set MinimumPrefixLength to -1, the AutoCompleteBox will + /// not provide possible matches. There is no maximum value, but + /// setting MinimumPrefixLength to value that is too large will + /// prevent the AutoCompleteBox from providing possible matches as well. + /// + public int MinimumPrefixLength + { + get => GetValue(MinimumPrefixLengthProperty); + set => SetValue(MinimumPrefixLengthProperty, value); + } + + /// + /// Gets or sets a value indicating whether the first possible match + /// found during the filtering process will be displayed automatically + /// in the text box. + /// + /// + /// True if the first possible match found will be displayed + /// automatically in the text box; otherwise, false. The default is + /// false. + /// + public bool IsTextCompletionEnabled + { + get => GetValue(IsTextCompletionEnabledProperty); + set => SetValue(IsTextCompletionEnabledProperty, value); + } + + /// + /// Gets or sets the used + /// to display each item in the drop-down portion of the control. + /// + /// The used to + /// display each item in the drop-down. The default is null. + /// + /// You use the ItemTemplate property to specify the visualization + /// of the data objects in the drop-down portion of the AutoCompleteBox + /// control. If your AutoCompleteBox is bound to a collection and you + /// do not provide specific display instructions by using a + /// DataTemplate, the resulting UI of each item is a string + /// representation of each object in the underlying collection. + /// + public IDataTemplate ItemTemplate + { + get => GetValue(ItemTemplateProperty); + set => SetValue(ItemTemplateProperty, value); + } + + /// + /// Gets or sets the minimum delay, after text is typed + /// in the text box before the + /// control + /// populates the list of possible matches in the drop-down. + /// + /// The minimum delay, after text is typed in + /// the text box, but before the + /// populates + /// the list of possible matches in the drop-down. The default is 0. + public TimeSpan MinimumPopulateDelay + { + get => GetValue(MinimumPopulateDelayProperty); + set => SetValue(MinimumPopulateDelayProperty, value); + } + + /// + /// Gets or sets the maximum height of the drop-down portion of the + /// control. + /// + /// The maximum height of the drop-down portion of the + /// control. + /// The default is . + /// The specified value is less than 0. + public double MaxDropDownHeight + { + get => GetValue(MaxDropDownHeightProperty); + set => SetValue(MaxDropDownHeightProperty, value); + } + + /// + /// Gets or sets a value indicating whether the drop-down portion of + /// the control is open. + /// + /// + /// True if the drop-down is open; otherwise, false. The default is + /// false. + /// + public bool IsDropDownOpen + { + get => GetValue(IsDropDownOpenProperty); + set => SetValue(IsDropDownOpenProperty, value); + } + + /// + /// Gets or sets the that + /// is used to get the values for display in the text portion of + /// the + /// control. + /// + /// The object used + /// when binding to a collection property. + [AssignBinding] + [InheritDataTypeFromItems(nameof(ItemsSource))] + public IBinding? ValueMemberBinding + { + get => _valueBindingEvaluator?.ValueBinding; + set + { + if (ValueMemberBinding != value) + { + _valueBindingEvaluator = new BindingEvaluator(value); + OnValueMemberBindingChanged(value); + } + } + } + + /// + /// Gets or sets the selected item in the drop-down. + /// + /// The selected item in the drop-down. + /// + /// If the IsTextCompletionEnabled property is true and text typed by + /// the user matches an item in the ItemsSource collection, which is + /// then displayed in the text box, the SelectedItem property will be + /// a null reference. + /// + public object? SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + /// + /// Gets or sets the text in the text box portion of the + /// control. + /// + /// The text in the text box portion of the + /// control. + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets the text that is used to filter items in the + /// item collection. + /// + /// The text that is used to filter items in the + /// item collection. + /// + /// The SearchText value is typically the same as the + /// Text property, but is set after the TextChanged event occurs + /// and before the Populating event. + /// + public string? SearchText + { + get => _searchText; + private set + { + try + { + _allowWrite = true; + SetAndRaise(SearchTextProperty, ref _searchText, value); + } + finally + { + _allowWrite = false; + } + } + } + + /// + /// Gets or sets how the text in the text box is used to filter items + /// specified by the + /// property for display in the drop-down. + /// + /// One of the + /// values The default is . + /// The specified value is not a valid + /// . + /// + /// Use the FilterMode property to specify how possible matches are + /// filtered. For example, possible matches can be filtered in a + /// predefined or custom way. The search mode is automatically set to + /// Custom if you set the ItemFilter property. + /// + public AutoCompleteFilterMode FilterMode + { + get => GetValue(FilterModeProperty); + set => SetValue(FilterModeProperty, value); + } + + public string? Watermark + { + get => GetValue(WatermarkProperty); + set => SetValue(WatermarkProperty, value); + } + + /// + /// Gets or sets the custom method that uses user-entered text to filter + /// the items specified by the + /// property for display in the drop-down. + /// + /// The custom method that uses the user-entered text to filter + /// the items specified by the + /// property. The default is null. + /// + /// The filter mode is automatically set to Custom if you set the + /// ItemFilter property. + /// + public AutoCompleteFilterPredicate? ItemFilter + { + get => GetValue(ItemFilterProperty); + set => SetValue(ItemFilterProperty, value); + } + + /// + /// Gets or sets the custom method that uses the user-entered text to + /// filter items specified by the + /// property in a text-based way for display in the drop-down. + /// + /// The custom method that uses the user-entered text to filter + /// items specified by the + /// property in a text-based way for display in the drop-down. + /// + /// The search mode is automatically set to Custom if you set the + /// TextFilter property. + /// + public AutoCompleteFilterPredicate? TextFilter + { + get => GetValue(TextFilterProperty); + set => SetValue(TextFilterProperty, value); + } + + /// + /// Gets or sets the custom method that combines the user-entered + /// text and one of the items specified by the . + /// + /// + /// The custom method that combines the user-entered + /// text and one of the items specified by the . + /// + public AutoCompleteSelector? ItemSelector + { + get => GetValue(ItemSelectorProperty); + set => SetValue(ItemSelectorProperty, value); + } + + /// + /// Gets or sets the custom method that combines the user-entered + /// text and one of the items specified by the + /// in a text-based way. + /// + /// + /// The custom method that combines the user-entered + /// text and one of the items specified by the + /// in a text-based way. + /// + public AutoCompleteSelector? TextSelector + { + get => GetValue(TextSelectorProperty); + set => SetValue(TextSelectorProperty, value); + } + + public Func>>? AsyncPopulator + { + get => GetValue(AsyncPopulatorProperty); + set => SetValue(AsyncPopulatorProperty, value); + } + + /// + /// Gets or sets a collection that is used to generate the items for the + /// drop-down portion of the control. + /// + /// The collection that is used to generate the items of the + /// drop-down portion of the control. + public IEnumerable? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + /// + /// Gets or sets the maximum number of characters that the can accept. + /// This constraint only applies for manually entered (user-inputted) text. + /// + public int MaxLength + { + get => GetValue(MaxLengthProperty); + set => SetValue(MaxLengthProperty, value); + } + + /// + /// Gets or sets custom content that is positioned on the left side of the text layout box + /// + public object? InnerLeftContent + { + get => GetValue(InnerLeftContentProperty); + set => SetValue(InnerLeftContentProperty, value); + } + + /// + /// Gets or sets custom content that is positioned on the right side of the text layout box + /// + public object? InnerRightContent + { + get => GetValue(InnerRightContentProperty); + set => SetValue(InnerRightContentProperty, value); + } + } \ No newline at end of file diff --git a/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.cs b/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.cs new file mode 100644 index 0000000..b263b70 --- /dev/null +++ b/src/Ursa/Controls/AutoCompleteBox/MultiAutoCompleteBox.cs @@ -0,0 +1,1970 @@ +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.Helpers; +using Ursa.Common; + + +namespace Ursa.Controls; + +public partial class MultiAutoCompleteBox : 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"; + + /// + /// + 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 + 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); + } + + /// + /// 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 || property == SelectedItemProperty) 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 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 + { + 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 + + 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) + { + 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 + { + /// + /// 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; + } + } +} \ No newline at end of file