From ee8ae91f72f99957e2dfb20ac623452b4cec8ca9 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Mon, 12 Feb 2018 14:17:56 -0500 Subject: [PATCH 01/54] Ported the AutoCompleteBox control from Silverlight --- samples/ControlCatalog/ControlCatalog.csproj | 6 + samples/ControlCatalog/MainView.xaml | 5 +- .../Pages/AutoCompleteBoxPage.xaml | 55 + .../Pages/AutoCompleteBoxPage.xaml.cs | 125 + src/Avalonia.Controls/AutoCompleteBox.cs | 2669 +++++++++++++++++ .../Utils/ISelectionAdapter.cs | 64 + .../SelectingItemsControlSelectionAdapter.cs | 342 +++ .../AutoCompleteBox.xaml | 43 + src/Avalonia.Themes.Default/DefaultTheme.xaml | 3 +- 9 files changed, 3309 insertions(+), 3 deletions(-) create mode 100644 samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml create mode 100644 samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs create mode 100644 src/Avalonia.Controls/AutoCompleteBox.cs create mode 100644 src/Avalonia.Controls/Utils/ISelectionAdapter.cs create mode 100644 src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs create mode 100644 src/Avalonia.Themes.Default/AutoCompleteBox.xaml diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index 37f9da0c43..f6d5627555 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -38,6 +38,9 @@ Designer + + Designer + Designer @@ -110,6 +113,9 @@ BorderPage.xaml + + AutoCompleteBoxPage.xaml + ButtonPage.xaml diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 060369e404..e8b87e99b0 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -5,9 +5,10 @@ + - + @@ -25,4 +26,4 @@ - \ No newline at end of file + diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml new file mode 100644 index 0000000000..491f41ecbf --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -0,0 +1,55 @@ + + + AutoCompleteBox + A control into which the user can input text + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs new file mode 100644 index 0000000000..9f181d44f2 --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -0,0 +1,125 @@ +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.Markup; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.Data; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ControlCatalog.Pages +{ + public class AutoCompleteBoxPage : UserControl + { + public class StateData + { + public string Name { get; private set; } + public string Abbreviation { get; private set; } + public string Capital { get; private set; } + + public StateData(string name, string abbreviatoin, string capital) + { + Name = name; + Abbreviation = abbreviatoin; + Capital = capital; + } + + public override string ToString() + { + return Name; + } + } + + private StateData[] BuildAllStates() + { + return new StateData[] + { + new StateData("Alabama","AL","Montgomery"), + new StateData("Alaska","AK","Juneau"), + new StateData("Arizona","AZ","Phoenix"), + new StateData("Arkansas","AR","Little Rock"), + new StateData("California","CA","Sacramento"), + new StateData("Colorado","CO","Denver"), + new StateData("Connecticut","CT","Hartford"), + new StateData("Delaware","DE","Dover"), + new StateData("Florida","FL","Tallahassee"), + new StateData("Georgia","GA","Atlanta"), + new StateData("Hawaii","HI","Honolulu"), + new StateData("Idaho","ID","Boise"), + new StateData("Illinois","IL","Springfield"), + new StateData("Indiana","IN","Indianapolis"), + new StateData("Iowa","IA","Des Moines"), + new StateData("Kansas","KS","Topeka"), + new StateData("Kentucky","KY","Frankfort"), + new StateData("Louisiana","LA","Baton Rouge"), + new StateData("Maine","ME","Augusta"), + new StateData("Maryland","MD","Annapolis"), + new StateData("Massachusetts","MA","Boston"), + new StateData("Michigan","MI","Lansing"), + new StateData("Minnesota","MN","St. Paul"), + new StateData("Mississippi","MS","Jackson"), + new StateData("Missouri","MO","Jefferson City"), + new StateData("Montana","MT","Helena"), + new StateData("Nebraska","NE","Lincoln"), + new StateData("Nevada","NV","Carson City"), + new StateData("New Hampshire","NH","Concord"), + new StateData("New Jersey","NJ","Trenton"), + new StateData("New Mexico","NM","Santa Fe"), + new StateData("New York","NY","Albany"), + new StateData("North Carolina","NC","Raleigh"), + new StateData("North Dakota","ND","Bismarck"), + new StateData("Ohio","OH","Columbus"), + new StateData("Oklahoma","OK","Oklahoma City"), + new StateData("Oregon","OR","Salem"), + new StateData("Pennsylvania","PA","Harrisburg"), + new StateData("Rhode Island","RI","Providence"), + new StateData("South Carolina","SC","Columbia"), + new StateData("South Dakota","SD","Pierre"), + new StateData("Tennessee","TN","Nashville"), + new StateData("Texas","TX","Austin"), + new StateData("Utah","UT","Salt Lake City"), + new StateData("Vermont","VT","Montpelier"), + new StateData("Virginia","VA","Richmond"), + new StateData("Washington","WA","Olympia"), + new StateData("West Virginia","WV","Charleston"), + new StateData("Wisconsin","WI","Madison"), + new StateData("Wyoming","WY","Cheyenne"), + }; + } + public StateData[] States { get; private set; } + + public AutoCompleteBoxPage() + { + this.InitializeComponent(); + + States = BuildAllStates(); + + foreach (AutoCompleteBox box in GetAllAutoCompleteBox()) + { + box.Items = States; + } + + var converter = new FuncMultiValueConverter(parts => + { + return String.Format("{0} ({1})", parts.ToArray()); + }); + var binding = new MultiBinding { Converter = converter }; + binding.Bindings.Add(new Binding("Name")); + binding.Bindings.Add(new Binding("Abbreviation")); + + var multibindingBox = this.FindControl("MultiBindingBox"); + multibindingBox.ValueMemberBinding = binding; + } + private IEnumerable GetAllAutoCompleteBox() + { + return + this.GetLogicalDescendants() + .OfType(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs new file mode 100644 index 0000000000..0e8b93146c --- /dev/null +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -0,0 +1,2669 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Collections; +using System; +using System.Linq; +using System.Collections.Generic; +using System.Collections; +using System.Collections.ObjectModel; +using System.Diagnostics; +using Avalonia.Threading; +using Avalonia.Controls.Templates; +using Avalonia.VisualTree; +using Avalonia.Utilities; +using System.Globalization; +using System.Collections.Specialized; +using System.Reactive.Disposables; + +namespace Avalonia.Controls +{ + public class CancelableEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public CancelableEventArgs() + : base() + { } + } + + /// + /// Provides data for the + /// + /// event. + /// + public class PopulatedEventArgs : EventArgs + { + /// + /// Gets the list of possible matches added to the drop-down portion of + /// the + /// control. + /// + /// The list of possible matches added to the + /// . + public IEnumerable Data { get; private set; } + + /// + /// Initializes a new instance of the + /// . + /// + /// The list of possible matches added to the + /// drop-down portion of the + /// control. + public PopulatedEventArgs(IEnumerable data) + { + Data = data; + } + } + + /// + /// Provides data for the + /// + /// event. + /// + /// Stable + public class PopulatingEventArgs : CancelableEventArgs + { + /// + /// Gets the text that is used to determine which items to display in + /// the + /// control. + /// + /// The text that is used to determine which items to display in + /// the . + public string Parameter { get; private set; } + + /// + /// Initializes a new instance of the + /// . + /// + /// The value of the + /// + /// property, which is used to filter items for the + /// control. + public PopulatingEventArgs(string parameter) + { + Parameter = parameter; + } + } + + /// + /// Represents the filter used by the + /// control to + /// determine whether an item is a possible match for the specified text. + /// + /// true to indicate is a possible match + /// for ; otherwise false. + /// The string used as the basis for filtering. + /// The item that is compared with the + /// parameter. + /// The type used for filtering the + /// . This type can + /// be either a string or an object. + /// Stable + public delegate bool AutoCompleteFilterPredicate(string search, T item); + + /// + /// Specifies how text in the text box portion of the + /// control is used + /// to filter items specified by the + /// + /// property for display in the drop-down. + /// + /// Stable + public enum AutoCompleteFilterMode + { + /// + /// Specifies that no filter is used. All items are returned. + /// + None = 0, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items start with the specified text. The filter uses the + /// + /// method, specifying + /// as + /// the string comparison criteria. + /// + StartsWith = 1, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items start with the specified text. The filter uses the + /// + /// method, specifying + /// as the string + /// comparison criteria. + /// + StartsWithCaseSensitive = 2, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items start with the specified text. The filter uses the + /// + /// method, specifying + /// as the + /// string comparison criteria. + /// + StartsWithOrdinal = 3, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// start with the specified text. The filter uses the + /// + /// method, specifying as + /// the string comparison criteria. + /// + StartsWithOrdinalCaseSensitive = 4, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items contain the specified text. + /// + Contains = 5, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items contain the specified text. + /// + ContainsCaseSensitive = 6, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items contain the specified text. + /// + ContainsOrdinal = 7, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// contain the specified text. + /// + ContainsOrdinalCaseSensitive = 8, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items equal the specified text. The filter uses the + /// + /// method, specifying + /// as + /// the search comparison criteria. + /// + Equals = 9, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items equal the specified text. The filter uses the + /// + /// method, specifying + /// as the string + /// comparison criteria. + /// + EqualsCaseSensitive = 10, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items equal the specified text. The filter uses the + /// + /// method, specifying + /// as the + /// string comparison criteria. + /// + EqualsOrdinal = 11, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// equal the specified text. The filter uses the + /// + /// method, specifying as + /// the string comparison criteria. + /// + EqualsOrdinalCaseSensitive = 12, + + /// + /// Specifies that a custom filter is used. This mode is used when the + /// + /// or + /// + /// properties are set. + /// + Custom = 13, + } + + /// + /// Represents a control that provides a text box for user input and a + /// drop-down that contains possible matches based on the input in the text + /// box. + /// + public class AutoCompleteBox : TemplatedControl + { + /// + /// Specifies the name of the selection adapter TemplatePart. + /// + private const string ElementSelectionAdapter = "PART_SelectionAdapter"; + + /// + /// Specifies the name of the Selector TemplatePart. + /// + private const string ElementSelector = "PART_SelectingItemsControl"; + + /// + /// Specifies the name of the Popup TemplatePart. + /// + private const string ElementPopup = "PART_Popup"; + + /// + /// The name for the text box part. + /// + private const string ElementTextBox = "PART_TextBox"; + + private IEnumerable _itemsEnumerable; + + /// + /// Gets or sets a local cached copy of the items data. + /// + private List _items; + + /// + /// Gets or sets the observable collection that contains references to + /// all of the items in the generated view of data that is provided to + /// the selection-style control adapter. + /// + private AvaloniaList _view; + + /// + /// Gets or sets a value to ignore a number of pending change handlers. + /// The value is decremented after each use. This is used to reset the + /// value of properties without performing any of the actions in their + /// change handlers. + /// + /// The int is important as a value because the TextBox + /// TextChanged event does not immediately fire, and this will allow for + /// nested property changes to be ignored. + private int _ignoreTextPropertyChange; + + /// + /// Gets or sets a value indicating whether to ignore calling a pending + /// change handlers. + /// + private bool _ignorePropertyChange; + + /// + /// Gets or sets a value indicating whether to ignore the selection + /// changed event. + /// + private bool _ignoreTextSelectionChange; + + /// + /// Gets or sets a value indicating whether to skip the text update + /// processing when the selected item is updated. + /// + private bool _skipSelectedItemTextUpdate; + + /// + /// Gets or sets the last observed text box selection start location. + /// + private int _textSelectionStart; + + /// + /// Gets or sets a value indicating whether the user initiated the + /// current populate call. + /// + private bool _userCalledPopulate; + + /// + /// A value indicating whether the popup has been opened at least once. + /// + private bool _popupHasOpened; + + /// + /// Gets or sets the DispatcherTimer used for the MinimumPopulateDelay + /// condition for auto completion. + /// + private DispatcherTimer _delayTimer; + + /// + /// Gets or sets a value indicating whether a read-only dependency + /// property change handler should allow the value to be set. This is + /// used to ensure that read-only properties cannot be changed via + /// SetValue, etc. + /// + private bool _allowWrite; + + /// + /// The TextBox template part. + /// + private TextBox _textBox; + private IDisposable _textBoxSubscriptions; + + /// + /// The SelectionAdapter. + /// + private ISelectionAdapter _adapter; + + /// + /// A control that can provide updated string values from a binding. + /// + private BindingEvaluator _valueBindingEvaluator; + + /// + /// A weak subscription for the collection changed event. + /// + private IDisposable _collectionChangeSubscription; + + private IMemberSelector _valueMemberSelector; + + private bool _itemTemplateIsFromValueMemeberBinding = true; + private bool _settingItemTemplateFromValueMemeberBinding; + + private object _selectedItem; + private bool _isDropDownOpen; + private bool _isFocused = false; + + private string _text = string.Empty; + private string _searchText = string.Empty; + + private AutoCompleteFilterPredicate _itemFilter; + private AutoCompleteFilterPredicate _textFilter = AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith); + + public static readonly RoutedEvent SelectionChangedEvent = + RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Bubble, typeof(AutoCompleteBox)); + + public static readonly StyledProperty WatermarkProperty = + TextBox.WatermarkProperty.AddOwner(); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MinimumPrefixLengthProperty = + AvaloniaProperty.Register( + nameof(MinimumPrefixLength), 1, + validate: ValidateMinimumPrefixLength); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MinimumPopulateDelayProperty = + AvaloniaProperty.Register( + nameof(MinimumPopulateDelay), + TimeSpan.Zero, + validate: ValidateMinimumPopulateDelay); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MaxDropDownHeightProperty = + AvaloniaProperty.Register( + nameof(MaxDropDownHeight), + double.PositiveInfinity, + validate: ValidateMaxDropDownHeight); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty IsTextCompletionEnabledProperty = + AvaloniaProperty.Register(nameof(IsTextCompletionEnabled)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register(nameof(ItemTemplate)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty IsDropDownOpenProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDropDownOpen), + o => o.IsDropDownOpen, + (o, v) => o.IsDropDownOpen = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier the + /// + /// dependency property. + public static readonly DirectProperty SelectedItemProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedItem), + o => o.SelectedItem, + (o, v) => o.SelectedItem = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty TextProperty = + AvaloniaProperty.RegisterDirect( + nameof(Text), + o => o.Text, + (o, v) => o.Text = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty SearchTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(SearchText), + o => o.SearchText, + unsetValue: string.Empty); + + /// + /// Gets the identifier for the + /// + /// dependency property. + /// + public static readonly StyledProperty FilterModeProperty = + AvaloniaProperty.Register( + nameof(FilterMode), + defaultValue: AutoCompleteFilterMode.StartsWith, + validate: ValidateFilterMode); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty> ItemFilterProperty = + AvaloniaProperty.RegisterDirect>( + nameof(ItemFilter), + o => o.ItemFilter, + (o, v) => o.ItemFilter = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty> TextFilterProperty = + AvaloniaProperty.RegisterDirect>( + nameof(TextFilter), + o => o.TextFilter, + (o, v) => o.TextFilter = v, + unsetValue: AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty ItemsProperty = + AvaloniaProperty.RegisterDirect( + nameof(Items), + o => o.Items, + (o, v) => o.Items = v); + + public static readonly DirectProperty ValueMemberSelectorProperty = + AvaloniaProperty.RegisterDirect( + nameof(ValueMemberSelector), + o => o.ValueMemberSelector, + (o, v) => o.ValueMemberSelector = v); + + private static int ValidateMinimumPrefixLength(AutoCompleteBox control, int value) + { + Contract.Requires(value >= -1); + + return value; + } + + private static TimeSpan ValidateMinimumPopulateDelay(AutoCompleteBox control, TimeSpan value) + { + Contract.Requires(value.TotalMilliseconds >= 0.0); + + return value; + } + + private static double ValidateMaxDropDownHeight(AutoCompleteBox control, double value) + { + Contract.Requires(value >= 0.0); + + return value; + } + + 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; + } + } + private static AutoCompleteFilterMode ValidateFilterMode(AutoCompleteBox control, AutoCompleteFilterMode value) + { + Contract.Requires(IsValidFilterMode(value)); + + return value; + } + + /// + /// Handle the change of the IsEnabled property. + /// + /// The event data. + private void OnControlIsEnabledChanged(AvaloniaPropertyChangedEventArgs e) + { + bool isEnabled = (bool)e.NewValue; + if (!isEnabled) + { + IsDropDownOpen = 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 = null; + } + } + + if (newValue > TimeSpan.Zero) + { + // Create or clear a dispatcher timer instance + if (_delayTimer == null) + { + _delayTimer = new DispatcherTimer(); + _delayTimer.Tick += PopulateDropDown; + } + + // Set the new tick interval + _delayTimer.Interval = newValue; + } + } + + /// + /// IsDropDownOpenProperty property changed handler. + /// + /// Event arguments. + private void OnIsDropDownOpenChanged(AvaloniaPropertyChangedEventArgs e) + { + // Ignore the change if requested + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + bool oldValue = (bool)e.OldValue; + bool newValue = (bool)e.NewValue; + + if (newValue) + { + TextUpdated(Text, true); + } + else + { + ClosingDropDown(oldValue); + } + + UpdatePseudoClasses(); + } + + private void OnSelectedItemPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + // Update the text display + if (_skipSelectedItemTextUpdate) + { + _skipSelectedItemTextUpdate = false; + } + else + { + OnSelectedItemChanged(e.NewValue); + } + + // Fire the SelectionChanged event + List removed = new List(); + if (e.OldValue != null) + { + removed.Add(e.OldValue); + } + + List added = new List(); + if (e.NewValue != null) + { + added.Add(e.NewValue); + } + + OnSelectionChanged(new SelectionChangedEventArgs(SelectionChangedEvent, removed, added)); + } + + /// + /// TextProperty property changed handler. + /// + /// Event arguments. + private void OnTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + TextUpdated((string)e.NewValue, false); + } + + private void OnSearchTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + // Ensure the property is only written when expected + if (!_allowWrite) + { + // Reset the old value before it was incorrectly written + _ignorePropertyChange = true; + SetValue(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 + TextFilter = AutoCompleteSearch.GetFilter(mode); + } + + /// + /// ItemFilterProperty property changed handler. + /// + /// Event arguments. + private void OnItemFilterPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + AutoCompleteFilterPredicate value = e.NewValue as AutoCompleteFilterPredicate; + + // If null, revert to the "None" predicate + if (value == null) + { + FilterMode = AutoCompleteFilterMode.None; + } + else + { + FilterMode = AutoCompleteFilterMode.Custom; + TextFilter = null; + } + } + + /// + /// ItemsSourceProperty property changed handler. + /// + /// Event arguments. + private void OnItemsPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + OnItemsChanged((IEnumerable)e.NewValue); + } + + private void OnItemTemplatePropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (!_settingItemTemplateFromValueMemeberBinding) + _itemTemplateIsFromValueMemeberBinding = false; + } + private void OnValueMemberBindingChanged(IBinding value) + { + if(_itemTemplateIsFromValueMemeberBinding) + { + var template = + new FuncDataTemplate( + typeof(object), + o => + { + var control = new ContentControl(); + control.Bind(ContentControl.ContentProperty, value); + return control; + }); + + _settingItemTemplateFromValueMemeberBinding = true; + ItemTemplate = template; + _settingItemTemplateFromValueMemeberBinding = false; + } + } + + static AutoCompleteBox() + { + FocusableProperty.OverrideDefaultValue(true); + + MinimumPopulateDelayProperty.Changed.AddClassHandler(x => x.OnMinimumPopulateDelayChanged); + IsDropDownOpenProperty.Changed.AddClassHandler(x => x.OnIsDropDownOpenChanged); + SelectedItemProperty.Changed.AddClassHandler(x => x.OnSelectedItemPropertyChanged); + TextProperty.Changed.AddClassHandler(x => x.OnTextPropertyChanged); + SearchTextProperty.Changed.AddClassHandler(x => x.OnSearchTextPropertyChanged); + FilterModeProperty.Changed.AddClassHandler(x => x.OnFilterModePropertyChanged); + ItemFilterProperty.Changed.AddClassHandler(x => x.OnItemFilterPropertyChanged); + ItemsProperty.Changed.AddClassHandler(x => x.OnItemsPropertyChanged); + IsEnabledProperty.Changed.AddClassHandler(x => x.OnControlIsEnabledChanged); + } + + /// + /// Initializes a new instance of the + /// class. + /// + public AutoCompleteBox() + { + ClearView(); + } + + /// + /// Gets or sets the minimum number of characters required to be entered + /// in the text box before the + /// displays + /// possible matches. + /// 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 { return 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 { return 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 { return 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 { return 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 { return 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 { return _isDropDownOpen; } + set { SetAndRaise(IsDropDownOpenProperty, ref _isDropDownOpen, 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] + public IBinding ValueMemberBinding + { + get { return _valueBindingEvaluator?.ValueBinding; } + set + { + if (ValueMemberBinding != value) + { + _valueBindingEvaluator = new BindingEvaluator(value); + OnValueMemberBindingChanged(value); + } + } + } + + /// + /// Gets or sets the MemberSelector that is used to get values for + /// display in the text portion of the + /// control. + /// + /// The MemberSelector that is used to get values for display in + /// the text portion of the + /// control. + public IMemberSelector ValueMemberSelector + { + get { return _valueMemberSelector; } + set { SetAndRaise(ValueMemberSelectorProperty, ref _valueMemberSelector, 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 { return _selectedItem; } + set { SetAndRaise(SelectedItemProperty, ref _selectedItem, 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 { return _text; } + set { SetAndRaise(TextProperty, ref _text, 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 { return _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 { return GetValue(FilterModeProperty); } + set { SetValue(FilterModeProperty, value); } + } + + public string Watermark + { + get { return 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 { return _itemFilter; } + set { SetAndRaise(ItemFilterProperty, ref _itemFilter, 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 { return _textFilter; } + set { SetAndRaise(TextFilterProperty, ref _textFilter, 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 Items + { + get { return _itemsEnumerable; } + set { SetAndRaise(ItemsProperty, ref _itemsEnumerable, value); } + } + + /// + /// Gets or sets the drop down popup control. + /// + private Popup DropDownPopup { get; set; } + + /// + /// Gets or sets the Text template part. + /// + private TextBox TextBox + { + get { return _textBox; } + set + { + _textBoxSubscriptions?.Dispose(); + _textBox = value; + + // Attach handlers + if (_textBox != null) + { + _textBoxSubscriptions = + _textBox.GetObservable(TextBox.TextProperty) + .Subscribe(_ => OnTextBoxTextChanged()); + + if (Text != null) + { + UpdateTextValue(Text); + } + } + } + } + + private int TextBoxSelectionStart + { + get + { + if (TextBox != null) + { + return Math.Min(TextBox.SelectionStart, TextBox.SelectionEnd); + } + else + { + return 0; + } + } + } + private int TextBoxSelectionLength + { + get + { + if (TextBox != null) + { + return Math.Abs(TextBox.SelectionEnd - TextBox.SelectionStart); + } + else + { + return 0; + } + } + } + + /// + /// Gets or sets the selection adapter used to populate the drop-down + /// with a list of selectable items. + /// + /// The selection adapter used to populate the drop-down with a + /// list of selectable items. + /// + /// You can use this property when you create an automation peer to + /// use with AutoCompleteBox or deriving from AutoCompleteBox to + /// create a custom control. + /// + protected ISelectionAdapter SelectionAdapter + { + get { return _adapter; } + set + { + if (_adapter != null) + { + _adapter.SelectionChanged -= OnAdapterSelectionChanged; + _adapter.Commit -= OnAdapterSelectionComplete; + _adapter.Cancel -= OnAdapterSelectionCanceled; + _adapter.Cancel -= OnAdapterSelectionComplete; + _adapter.Items = null; + } + + _adapter = value; + + if (_adapter != null) + { + _adapter.SelectionChanged += OnAdapterSelectionChanged; + _adapter.Commit += OnAdapterSelectionComplete; + _adapter.Cancel += OnAdapterSelectionCanceled; + _adapter.Cancel += OnAdapterSelectionComplete; + _adapter.Items = _view; + } + } + } + + /// + /// Returns the + /// part, if + /// possible. + /// + /// + /// A object, + /// if possible. Otherwise, null. + /// + protected virtual ISelectionAdapter GetSelectionAdapterPart(INameScope nameScope) + { + ISelectionAdapter adapter = null; + SelectingItemsControl selector = nameScope.Find(ElementSelector); + if (selector != null) + { + // Check if it is already an IItemsSelector + adapter = selector as ISelectionAdapter; + if (adapter == null) + { + // Built in support for wrapping a Selector control + adapter = new SelectingItemsControlSelectionAdapter(selector); + } + } + if (adapter == null) + { + adapter = nameScope.Find(ElementSelectionAdapter); + } + return adapter; + } + + /// + /// Builds the visual tree for the + /// control + /// when a new template is applied. + /// + protected override void OnTemplateApplied(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.OnTemplateApplied(e); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnKeyDown(KeyEventArgs e) + { + Contract.Requires(e != null); + + 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. + if (e.Key == Key.Down) + { + IsDropDownOpen = true; + e.Handled = true; + } + } + + // Standard drop down navigation + switch (e.Key) + { + case Key.F4: + IsDropDownOpen = !IsDropDownOpen; + e.Handled = true; + break; + + case Key.Enter: + OnAdapterSelectionComplete(this, new RoutedEventArgs()); + e.Handled = true; + break; + + default: + break; + } + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Determines whether the text box or drop-down portion of the + /// control has + /// focus. + /// + /// true to indicate the + /// has focus; + /// otherwise, false. + protected bool HasFocus() + { + IVisual focused = FocusManager.Instance.Current; + + while (focused != null) + { + if (object.ReferenceEquals(focused, this)) + { + return true; + } + + // This helps deal with popups that may not be in the same + // visual tree + IVisual parent = focused.GetVisualParent(); + if (parent == null) + { + // Try the logical parent. + IControl element = focused as IControl; + if (element != null) + { + parent = element.Parent; + } + } + focused = parent; + } + return false; + } + + /// + /// Handles the FocusChanged event. + /// + /// A value indicating whether the control + /// currently has the focus. + private void FocusChanged(bool hasFocus) + { + // The OnGotFocus & OnLostFocus are asynchronously and cannot + // reliably tell you that have the focus. All they do is let you + // know that the focus changed sometime in the past. To determine + // if you currently have the focus you need to do consult the + // FocusManager (see HasFocus()). + + bool wasFocused = _isFocused; + _isFocused = hasFocus; + + if (hasFocus) + { + + if (!wasFocused && TextBox != null && TextBoxSelectionLength <= 0) + { + TextBox.Focus(); + TextBox.SelectionStart = 0; + TextBox.SelectionEnd = TextBox.Text?.Length ?? 0; + } + } + else + { + IsDropDownOpen = false; + _userCalledPopulate = false; + ClearTextBoxSelection(); + } + + _isFocused = hasFocus; + } + + /// + /// Occurs when the text in the text box portion of the + /// changes. + /// + public event EventHandler TextChanged; + + /// + /// 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(CancelableEventArgs e) + { + DropDownOpening?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownOpened(EventArgs e) + { + DropDownOpened?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownClosing(CancelableEventArgs e) + { + DropDownClosing?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// which contains the event data. + protected virtual void OnDropDownClosed(EventArgs e) + { + DropDownClosed?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// that contains the event data. + protected virtual void OnTextChanged(RoutedEventArgs e) + { + TextChanged?.Invoke(this, e); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + var args = new CancelableEventArgs(); + OnDropDownClosing(args); + + if (args.Cancel) + { + _ignorePropertyChange = true; + SetValue(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 CancelableEventArgs(); + + // Opening + OnDropDownOpening(args); + + if (args.Cancel) + { + _ignorePropertyChange = true; + SetValue(IsDropDownOpenProperty, oldValue); + } + else + { + OpenDropDown(); + } + + UpdatePseudoClasses(); + } + + /// + /// Connects to the DropDownPopup Closed event. + /// + /// The source object. + /// The event data. + private void DropDownPopup_Closed(object sender, EventArgs e) + { + // Force the drop down dependency property to be false. + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + + // Fire the DropDownClosed event + if (_popupHasOpened) + { + OnDropDownClosed(EventArgs.Empty); + } + } + + /// + /// Handles the timer tick when using a populate delay. + /// + /// The source object. + /// The event arguments. + private void PopulateDropDown(object sender, EventArgs e) + { + if (_delayTimer != null) + { + _delayTimer.Stop(); + } + + // Update the prefix/search text. + SearchText = Text; + + // 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 method that directly opens the popup, checks the expander + /// button, and then fires the Opened event. + /// + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + _popupHasOpened = true; + OnDropDownOpened(EventArgs.Empty); + } + + /// + /// Private method that directly closes the popup, flips the Checked + /// value, and then fires the Closed event. + /// + private void CloseDropDown() + { + if (_popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + OnDropDownClosed(EventArgs.Empty); + } + } + + /// + /// Formats an Item for text comparisons based on Converter + /// and ConverterCulture properties. + /// + /// The object to format. + /// A value indicating whether to clear + /// the data context after the lookup is performed. + /// Formatted Value. + private string FormatValue(object value, bool clearDataContext) + { + string result = FormatValue(value); + if(clearDataContext && _valueBindingEvaluator != null) + { + _valueBindingEvaluator.ClearDataContext(); + } + + return result; + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + if (_valueBindingEvaluator != null) + { + return _valueBindingEvaluator.GetDynamicValue(value) ?? String.Empty; + } + + if (_valueMemberSelector != null) + { + value = _valueMemberSelector.Select(value); + } + + return value == null ? String.Empty : value.ToString(); + } + + /// + /// Handle the TextChanged event that is directly attached to the + /// TextBox part. This ensures that only user initiated actions will + /// result in an AutoCompleteBox suggestion and operation. + /// + private void OnTextBoxTextChanged() + { + //Uses Dispatcher.Post to allow the TextBox selection to update before processing + Dispatcher.UIThread.Post(() => + { + // Call the central updated text method as a user-initiated action + TextUpdated(_textBox.Text, true); + }); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + private void UpdateTextValue(string value) + { + UpdateTextValue(value, null); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + /// A nullable bool value indicating whether + /// the action was user initiated. In a user initiated mode, the + /// underlying text dependency property is updated. In a non-user + /// interaction, the text box value is updated. When user initiated is + /// null, all values are updated. + private void UpdateTextValue(string value, bool? userInitiated) + { + bool callTextChanged = false; + // Update the Text dependency property + if ((userInitiated == null || userInitiated == true) && Text != value) + { + _ignoreTextPropertyChange++; + Text = 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 RoutedEventArgs()); + } + } + + /// + /// 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) + { + return; + } + + // Evaluate the conditions needed for completion. + // 1. Minimum prefix length + // 2. If a delay timer is in use, use it + bool populateReady = newText.Length >= MinimumPrefixLength && MinimumPrefixLength >= 0; + _userCalledPopulate = populateReady ? userInitiated : false; + + // Update the interface and values only as necessary + UpdateTextValue(newText, userInitiated); + + if (populateReady) + { + _ignoreTextSelectionChange = true; + + if (_delayTimer != null) + { + _delayTimer.Start(); + } + else + { + PopulateDropDown(this, EventArgs.Empty); + } + } + else + { + SearchText = string.Empty; + if (SelectedItem != null) + { + _skipSelectedItemTextUpdate = true; + } + SelectedItem = null; + if (IsDropDownOpen) + { + IsDropDownOpen = 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 (_items == null) + { + ClearView(); + return; + } + + // Cache the current text value + string text = Text ?? string.Empty; + + // Determine if any filtering mode is on + bool stringFiltering = TextFilter != null; + bool objectFiltering = FilterMode == AutoCompleteFilterMode.Custom && TextFilter == null; + + int view_index = 0; + int view_count = _view.Count; + List items = _items; + foreach (object item in items) + { + bool inResults = !(stringFiltering || objectFiltering); + if (!inResults) + { + inResults = stringFiltering ? TextFilter(text, FormatValue(item)) : ItemFilter(text, item); + } + + if (view_count > view_index && inResults && _view[view_index] == item) + { + // Item is still in the view + view_index++; + } + else if (inResults) + { + // Insert the item + if (view_count > view_index && _view[view_index] != item) + { + // Replace item + // Unfortunately replacing via index throws a fatal + // exception: View[view_index] = item; + // Cost: O(n) vs O(1) + _view.RemoveAt(view_index); + _view.Insert(view_index, item); + view_index++; + } + else + { + // Add the item + if (view_index == view_count) + { + // Constant time is preferred (Add). + _view.Add(item); + } + else + { + _view.Insert(view_index, item); + } + view_index++; + view_count++; + } + } + else if (view_count > view_index && _view[view_index] == item) + { + // Remove the item + _view.RemoveAt(view_index); + view_count--; + } + } + + // Clear the evaluator to discard a reference to the last item + if (_valueBindingEvaluator != null) + { + _valueBindingEvaluator.ClearDataContext(); + } + } + + /// + /// 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 OnItemsChanged(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().ToList()); + + // Clear and set the view on the selection adapter + ClearView(); + if (SelectionAdapter != null && SelectionAdapter.Items != _view) + { + SelectionAdapter.Items = _view; + } + if (IsDropDownOpen) + { + RefreshView(); + } + } + + /// + /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. + /// + /// The object that raised the event. + /// The event data. + private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // Update the cache + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + _items.RemoveAt(e.OldStartingIndex); + } + } + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && _items.Count >= e.NewStartingIndex) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + _items.Insert(e.NewStartingIndex + index, e.NewItems[index]); + } + } + if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + _items[e.NewStartingIndex] = e.NewItems[index]; + } + } + + // Update the view + if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + _view.Remove(e.OldItems[index]); + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Significant changes to the underlying data. + ClearView(); + if (Items != null) + { + _items = new List(Items.Cast().ToList()); + } + } + + // 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.Items != _view) + { + SelectionAdapter.Items = _view; + } + + bool isDropDownOpen = _userCalledPopulate && (_view.Count > 0); + if (isDropDownOpen != IsDropDownOpen) + { + _ignorePropertyChange = true; + IsDropDownOpen = isDropDownOpen; + } + if (IsDropDownOpen) + { + OpeningDropDown(false); + } + else + { + ClosingDropDown(true); + } + + UpdateTextCompletion(_userCalledPopulate); + } + + /// + /// Performs text completion, if enabled, and a lookup on the underlying + /// item values for an exact match. Will update the SelectedItem value. + /// + /// A value indicating whether the operation + /// was user initiated. Text completion will not be performed when not + /// directly initiated by the user. + private void UpdateTextCompletion(bool userInitiated) + { + // By default this method will clear the selected value + object newSelectedItem = null; + string text = Text; + + // Text search is StartsWith explicit and only when enabled, in + // line with WPF's ComboBox lookup. When in use it will associate + // a Value with the Text if it is found in ItemsSource. This is + // only valid when there is data and the user initiated the action. + if (_view.Count > 0) + { + if (IsTextCompletionEnabled && TextBox != null && userInitiated) + { + int currentLength = TextBox.Text.Length; + int selectionStart = TextBoxSelectionStart; + if (selectionStart == text.Length && selectionStart > _textSelectionStart) + { + // When the FilterMode dependency property is set to + // either StartsWith or StartsWithCaseSensitive, the + // first item in the view is used. This will improve + // performance on the lookup. It assumes that the + // FilterMode the user has selected is an acceptable + // case sensitive matching function for their scenario. + object top = FilterMode == AutoCompleteFilterMode.StartsWith || FilterMode == AutoCompleteFilterMode.StartsWithCaseSensitive + ? _view[0] + : TryGetMatch(text, _view, AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); + + // If the search was successful, update SelectedItem + if (top != null) + { + newSelectedItem = top; + string topString = FormatValue(top, true); + + // Only replace partially when the two words being the same + int minLength = Math.Min(topString.Length, Text.Length); + 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; + } + } + } + } + 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; + } + SelectedItem = 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 (view != null && view.Count > 0) + { + foreach (object o in view) + { + if (predicate(searchText, FormatValue(o))) + { + return o; + } + } + } + + return null; + } + + private void UpdatePseudoClasses() + { + PseudoClasses.Set(":dropdownopen", IsDropDownOpen); + } + + private void ClearTextBoxSelection() + { + if (TextBox != null) + { + int length = TextBox.Text?.Length ?? 0; + TextBox.SelectionStart = length; + TextBox.SelectionEnd = length; + } + } + + /// + /// Called when the selected item is changed, updates the text value + /// that is displayed in the text box part. + /// + /// The new item. + private void OnSelectedItemChanged(object newItem) + { + string text; + + if (newItem == null) + { + text = SearchText; + } + else + { + 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) + { + SelectedItem = _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) + { + IsDropDownOpen = 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) + { + return s.IndexOf(value, comparison) >= 0; + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.CurrentCultureIgnoreCase); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.CurrentCulture); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.Ordinal); + } + + /// + /// 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 value.Equals(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 value.Equals(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 value.Equals(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 value.Equals(text, StringComparison.Ordinal); + } + } + + /// + /// A framework element that permits a binding to be evaluated in a new data + /// context leaf node. + /// + /// The type of dynamic binding to return. + public class BindingEvaluator : Control + { + /// + /// Gets or sets the string value binding used by the control. + /// + private IBinding _binding; + + #region public T Value + + /// + /// Identifies the Value dependency property. + /// + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register, T>(nameof(Value)); + + /// + /// Gets or sets the data item value. + /// + public T Value + { + get { return GetValue(ValueProperty); } + set { SetValue(ValueProperty, value); } + } + + #endregion public string Value + + /// + /// Gets or sets the value binding. + /// + public IBinding ValueBinding + { + get { return _binding; } + set + { + _binding = value; + AvaloniaObjectExtensions.Bind(this, ValueProperty, value); + } + } + + /// + /// Initializes a new instance of the BindingEvaluator class. + /// + public BindingEvaluator() + { } + + /// + /// Initializes a new instance of the BindingEvaluator class, + /// setting the initial binding to the provided parameter. + /// + /// The initial string value binding. + public BindingEvaluator(IBinding binding) + : this() + { + ValueBinding = binding; + } + + /// + /// Clears the data context so that the control does not keep a + /// reference to the last-looked up item. + /// + public void ClearDataContext() + { + DataContext = null; + } + + /// + /// Updates the data context of the framework element and returns the + /// updated binding value. + /// + /// The object to use as the data context. + /// If set to true, this parameter will + /// clear the data context immediately after retrieving the value. + /// Returns the evaluated T value of the bound dependency + /// property. + public T GetDynamicValue(object o, bool clearDataContext) + { + DataContext = o; + T value = Value; + if (clearDataContext) + { + DataContext = null; + } + return value; + } + + /// + /// Updates the data context of the framework element and returns the + /// updated binding value. + /// + /// The object to use as the data context. + /// Returns the evaluated T value of the bound dependency + /// property. + public T GetDynamicValue(object o) + { + DataContext = o; + return Value; + } + } + } +} diff --git a/src/Avalonia.Controls/Utils/ISelectionAdapter.cs b/src/Avalonia.Controls/Utils/ISelectionAdapter.cs new file mode 100644 index 0000000000..3c1006a12e --- /dev/null +++ b/src/Avalonia.Controls/Utils/ISelectionAdapter.cs @@ -0,0 +1,64 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using System; +using System.Collections; +using Avalonia.Interactivity; +using Avalonia.Input; + +namespace Avalonia.Controls.Utils +{ + /// + /// Defines an item collection, selection members, and key handling for the + /// selection adapter contained in the drop-down portion of an + /// control. + /// + public interface ISelectionAdapter + { + /// + /// Gets or sets the selected item. + /// + /// The currently selected item. + object SelectedItem { get; set; } + + /// + /// Occurs when the + /// + /// property value changes. + /// + event EventHandler SelectionChanged; + + /// + /// Gets or sets a collection that is used to generate content for the + /// selection adapter. + /// + /// The collection that is used to generate content for the + /// selection adapter. + IEnumerable Items { get; set; } + + /// + /// Occurs when a selected item is not cancelled and is committed as the + /// selected item. + /// + event EventHandler Commit; + + /// + /// Occurs when a selection has been canceled. + /// + event EventHandler Cancel; + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + void HandleKeyDown(KeyEventArgs e); + } + +} diff --git a/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs b/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs new file mode 100644 index 0000000000..43c8a5aa6c --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs @@ -0,0 +1,342 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Input; +using Avalonia.LogicalTree; +using System.Collections; +using System.Diagnostics; + +namespace Avalonia.Controls.Utils +{ + /// + /// Represents the selection adapter contained in the drop-down portion of + /// an control. + /// + public class SelectingItemsControlSelectionAdapter : ISelectionAdapter + { + /// + /// The SelectingItemsControl instance. + /// + private SelectingItemsControl _selector; + + /// + /// Gets or sets a value indicating whether the selection change event + /// should not be fired. + /// + private bool IgnoringSelectionChanged { get; set; } + + /// + /// Gets or sets the underlying + /// + /// control. + /// + /// The underlying + /// + /// control. + public SelectingItemsControl SelectorControl + { + get { return _selector; } + + set + { + if (_selector != null) + { + _selector.SelectionChanged -= OnSelectionChanged; + _selector.PointerReleased -= OnSelectorPointerReleased; + } + + _selector = value; + + if (_selector != null) + { + _selector.SelectionChanged += OnSelectionChanged; + _selector.PointerReleased += OnSelectorPointerReleased; + } + } + } + + /// + /// Occurs when the + /// + /// property value changes. + /// + public event EventHandler SelectionChanged; + + /// + /// Occurs when an item is selected and is committed to the underlying + /// + /// control. + /// + public event EventHandler Commit; + + /// + /// Occurs when a selection is canceled before it is committed. + /// + public event EventHandler Cancel; + + /// + /// Initializes a new instance of the + /// + /// class. + /// + public SelectingItemsControlSelectionAdapter() + { + + } + + /// + /// Initializes a new instance of the + /// + /// class with the specified + /// + /// control. + /// + /// The + /// control + /// to wrap as a + /// . + public SelectingItemsControlSelectionAdapter(SelectingItemsControl selector) + { + SelectorControl = selector; + } + + /// + /// Gets or sets the selected item of the selection adapter. + /// + /// The selected item of the underlying selection adapter. + public object SelectedItem + { + get + { + return SelectorControl?.SelectedItem; + } + + set + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = value; + } + + // Attempt to reset the scroll viewer's position + if (value == null) + { + ResetScrollViewer(); + } + + IgnoringSelectionChanged = false; + } + } + + /// + /// Gets or sets a collection that is used to generate the content of + /// the selection adapter. + /// + /// The collection used to generate content for the selection + /// adapter. + public IEnumerable Items + { + get + { + return SelectorControl?.Items; + } + set + { + if (SelectorControl != null) + { + SelectorControl.Items = value; + } + } + } + + /// + /// If the control contains a ScrollViewer, this will reset the viewer + /// to be scrolled to the top. + /// + private void ResetScrollViewer() + { + if (SelectorControl != null) + { + ScrollViewer sv = SelectorControl.GetLogicalDescendants().OfType().FirstOrDefault(); + if (sv != null) + { + sv.Offset = new Vector(0, 0); + } + } + } + + /// + /// Handles the mouse left button up event on the selector control. + /// + /// The source object. + /// The event data. + private void OnSelectorPointerReleased(object sender, PointerReleasedEventArgs e) + { + if (e.MouseButton == MouseButton.Left) + { + OnCommit(); + } + } + + /// + /// Handles the SelectionChanged event on the SelectingItemsControl control. + /// + /// The source object. + /// The selection changed event data. + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IgnoringSelectionChanged) + { + return; + } + + SelectionChanged?.Invoke(sender, e); + } + + /// + /// Increments the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexIncrement() + { + if (SelectorControl != null) + { + SelectorControl.SelectedIndex = SelectorControl.SelectedIndex + 1 >= SelectorControl.ItemCount ? -1 : SelectorControl.SelectedIndex + 1; + } + } + + /// + /// Decrements the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexDecrement() + { + if (SelectorControl != null) + { + int index = SelectorControl.SelectedIndex; + if (index >= 0) + { + SelectorControl.SelectedIndex--; + } + else if (index == -1) + { + SelectorControl.SelectedIndex = SelectorControl.ItemCount - 1; + } + } + } + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + public void HandleKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + OnCommit(); + e.Handled = true; + break; + + case Key.Up: + SelectedIndexDecrement(); + e.Handled = true; + break; + + case Key.Down: + if ((e.Modifiers & InputModifiers.Alt) == InputModifiers.None) + { + SelectedIndexIncrement(); + e.Handled = true; + } + break; + + case Key.Escape: + OnCancel(); + e.Handled = true; + break; + + default: + break; + } + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCommit() + { + OnCommit(this, new RoutedEventArgs()); + } + + /// + /// Fires the Commit event. + /// + /// The source object. + /// The event data. + private void OnCommit(object sender, RoutedEventArgs e) + { + Commit?.Invoke(sender, e); + + AfterAdapterAction(); + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCancel() + { + OnCancel(this, new RoutedEventArgs()); + } + + /// + /// Fires the Cancel event. + /// + /// The source object. + /// The event data. + private void OnCancel(object sender, RoutedEventArgs e) + { + Cancel?.Invoke(sender, e); + + AfterAdapterAction(); + } + + /// + /// Change the selection after the actions are complete. + /// + private void AfterAdapterAction() + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = null; + SelectorControl.SelectedIndex = -1; + } + IgnoringSelectionChanged = false; + } + } +} diff --git a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml new file mode 100644 index 0000000000..82dbf6064b --- /dev/null +++ b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 5b6f4b78fd..7d3090de66 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -23,7 +23,7 @@ - + @@ -42,4 +42,5 @@ + From e2ff0a02584303b7c7f2308a23f020b848ab7bb8 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Wed, 28 Feb 2018 20:09:11 -0500 Subject: [PATCH 02/54] Fixed an off by one bug --- src/Avalonia.Controls/TextBox.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 3ec3d6ed5b..27410c5948 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -788,7 +788,7 @@ namespace Avalonia.Controls int pos = 0; int i; - for (i = 0; i < lines.Count; ++i) + for (i = 0; i < lines.Count - 1; ++i) { var line = lines[i]; pos += line.Length; From c5608bd386ddfed5ba1cc27b43326f53a846dc98 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Wed, 28 Feb 2018 20:10:35 -0500 Subject: [PATCH 03/54] Moved the setting of FocusedElement This allows the LostFocus event to check the newly focused element --- src/Avalonia.Input/KeyboardDevice.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Input/KeyboardDevice.cs b/src/Avalonia.Input/KeyboardDevice.cs index d815f8082b..2a1cdec1c0 100644 --- a/src/Avalonia.Input/KeyboardDevice.cs +++ b/src/Avalonia.Input/KeyboardDevice.cs @@ -46,13 +46,13 @@ namespace Avalonia.Input if (element != FocusedElement) { var interactive = FocusedElement as IInteractive; + FocusedElement = element; interactive?.RaiseEvent(new RoutedEventArgs { RoutedEvent = InputElement.LostFocusEvent, }); - FocusedElement = element; interactive = element as IInteractive; interactive?.RaiseEvent(new GotFocusEventArgs From 5927e7acfe17dc0e47e2a8fab5dbdbba50ffb7bb Mon Sep 17 00:00:00 2001 From: sdoroff Date: Mon, 5 Mar 2018 12:09:56 -0500 Subject: [PATCH 04/54] Added Unit Tests --- .../AutoCompleteBoxTests.cs | 1042 +++++++++++++++++ 1 file changed, 1042 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs diff --git a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs new file mode 100644 index 0000000000..f9da2ab6f3 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs @@ -0,0 +1,1042 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Markup.Xaml.Data; +using Avalonia.Platform; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Moq; +using Xunit; +using System.Collections.ObjectModel; + +namespace Avalonia.Controls.UnitTests +{ + public class AutoCompleteBoxTests + { + [Fact] + public void Search_Filters() + { + Assert.True(GetFilter(AutoCompleteFilterMode.Contains)("am", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.Contains)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.Contains)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("hello", "name")); + + Assert.Null(GetFilter(AutoCompleteFilterMode.Custom)); + Assert.Null(GetFilter(AutoCompleteFilterMode.None)); + + Assert.True(GetFilter(AutoCompleteFilterMode.Equals)("na", "na")); + Assert.True(GetFilter(AutoCompleteFilterMode.Equals)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.Equals)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("na", "na")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWith)("na", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWith)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWith)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("hello", "name")); + } + + [Fact] + public void Ordinal_Search_Filters() + { + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("am", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("na", "na")); + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("na", "na")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("na", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("hello", "name")); + } + + [Fact] + public void Fires_DropDown_Events() + { + RunTest((control, textbox) => + { + bool openEvent = false; + bool closeEvent = false; + control.DropDownOpened += (s, e) => openEvent = true; + control.DropDownClosed += (s, e) => closeEvent = true; + control.Items = CreateSimpleStringArray(); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.SearchText == "a"); + Assert.True(control.IsDropDownOpen); + Assert.True(openEvent); + + textbox.Text = String.Empty; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.SearchText == String.Empty); + Assert.False(control.IsDropDownOpen); + Assert.True(closeEvent); + }); + } + + [Fact] + public void Text_Completion_Via_Text_Property() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + + Assert.Equal(String.Empty, control.Text); + control.Text = "close"; + Assert.NotNull(control.SelectedItem); + }); + } + + [Fact] + public void Text_Completion_Selects_Text() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + + textbox.Text = "ac"; + textbox.SelectionEnd = textbox.SelectionStart = 2; + Dispatcher.UIThread.RunJobs(); + + Assert.True(control.IsDropDownOpen); + Assert.True(Math.Abs(textbox.SelectionEnd - textbox.SelectionStart) > 2); + }); + } + + [Fact] + public void TextChanged_Event_Fires() + { + RunTest((control, textbox) => + { + bool textChanged = false; + control.TextChanged += (s, e) => textChanged = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + + textChanged = false; + control.Text = "conversati"; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + + textChanged = false; + control.Text = null; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + }); + } + + [Fact] + public void MinimumPrefixLength_Works() + { + RunTest((control, textbox) => + { + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + + + textbox.Text = String.Empty; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + + control.MinimumPrefixLength = 3; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_DropDown_Opening() + { + RunTest((control, textbox) => + { + control.DropDownOpening += (s, e) => e.Cancel = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_DropDown_Closing() + { + RunTest((control, textbox) => + { + control.DropDownClosing += (s, e) => e.Cancel = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + + control.IsDropDownOpen = false; + Assert.True(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_Population() + { + RunTest((control, textbox) => + { + bool populating = false; + bool populated = false; + control.FilterMode = AutoCompleteFilterMode.None; + control.Populating += (s, e) => + { + e.Cancel = true; + populating = true; + }; + control.Populated += (s, e) => populated = true; + + textbox.Text = "accounti"; + Dispatcher.UIThread.RunJobs(); + + Assert.True(populating); + Assert.False(populated); + }); + } + + [Fact] + public void Custom_Population_Supported() + { + RunTest((control, textbox) => + { + string custom = "Custom!"; + string search = "accounti"; + bool populated = false; + bool populatedOk = false; + control.FilterMode = AutoCompleteFilterMode.None; + control.Populating += (s, e) => + { + control.Items = new string[] { custom }; + Assert.Equal(search, e.Parameter); + }; + control.Populated += (s, e) => + { + populated = true; + ReadOnlyCollection collection = e.Data as ReadOnlyCollection; + populatedOk = collection != null && collection.Count == 1; + }; + + textbox.Text = search; + Dispatcher.UIThread.RunJobs(); + + Assert.True(populated); + Assert.True(populatedOk); + }); + } + + [Fact] + public void Text_Completion() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + textbox.Text = "accounti"; + textbox.SelectionStart = textbox.SelectionEnd = textbox.Text.Length; + Dispatcher.UIThread.RunJobs(); + Assert.Equal("accounti", control.SearchText); + Assert.Equal("accounting", textbox.Text); + }); + } + + [Fact] + public void String_Search() + { + RunTest((control, textbox) => + { + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = ""; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "accept"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + }); + } + + [Fact] + public void Item_Search() + { + RunTest((control, textbox) => + { + control.FilterMode = AutoCompleteFilterMode.Custom; + control.ItemFilter = (search, item) => + { + string s = item as string; + return s == null ? false : true; + }; + + // Just set to null briefly to exercise that code path + AutoCompleteFilterPredicate filter = control.ItemFilter; + Assert.NotNull(filter); + control.ItemFilter = null; + Assert.Null(control.ItemFilter); + control.ItemFilter = filter; + Assert.NotNull(control.ItemFilter); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = ""; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "accept"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + }); + } + + /// + /// Retrieves a defined predicate filter through a new AutoCompleteBox + /// control instance. + /// + /// The FilterMode of interest. + /// Returns the predicate instance. + private static AutoCompleteFilterPredicate GetFilter(AutoCompleteFilterMode mode) + { + return new AutoCompleteBox { FilterMode = mode } + .TextFilter; + } + + /// + /// Creates a large list of strings for AutoCompleteBox testing. + /// + /// Returns a new List of string values. + private IList CreateSimpleStringArray() + { + return new List + { + "a", + "abide", + "able", + "about", + "above", + "absence", + "absurd", + "accept", + "acceptance", + "accepted", + "accepting", + "access", + "accessed", + "accessible", + "accident", + "accidentally", + "accordance", + "account", + "accounting", + "accounts", + "accusation", + "accustomed", + "ache", + "across", + "act", + "active", + "actual", + "actually", + "ada", + "added", + "adding", + "addition", + "additional", + "additions", + "address", + "addressed", + "addresses", + "addressing", + "adjourn", + "adoption", + "advance", + "advantage", + "adventures", + "advice", + "advisable", + "advise", + "affair", + "affectionately", + "afford", + "afore", + "afraid", + "after", + "afterwards", + "again", + "against", + "age", + "aged", + "agent", + "ago", + "agony", + "agree", + "agreed", + "agreement", + "ah", + "ahem", + "air", + "airs", + "ak", + "alarm", + "alarmed", + "alas", + "alice", + "alive", + "all", + "allow", + "almost", + "alone", + "along", + "aloud", + "already", + "also", + "alteration", + "altered", + "alternate", + "alternately", + "altogether", + "always", + "am", + "ambition", + "among", + "an", + "ancient", + "and", + "anger", + "angrily", + "angry", + "animal", + "animals", + "ann", + "annoy", + "annoyed", + "another", + "answer", + "answered", + "answers", + "antipathies", + "anxious", + "anxiously", + "any", + "anyone", + "anything", + "anywhere", + "appealed", + "appear", + "appearance", + "appeared", + "appearing", + "appears", + "applause", + "apple", + "apples", + "applicable", + "apply", + "approach", + "arch", + "archbishop", + "arches", + "archive", + "are", + "argue", + "argued", + "argument", + "arguments", + "arise", + "arithmetic", + "arm", + "arms", + "around", + "arranged", + "array", + "arrived", + "arrow", + "arrum", + "as", + "ascii", + "ashamed", + "ask", + "askance", + "asked", + "asking", + "asleep", + "assembled", + "assistance", + "associated", + "at", + "ate", + "atheling", + "atom", + "attached", + "attempt", + "attempted", + "attempts", + "attended", + "attending", + "attends", + "audibly", + "australia", + "author", + "authority", + "available", + "avoid", + "away", + "awfully", + "axes", + "axis", + "b", + "baby", + "back", + "backs", + "bad", + "bag", + "baked", + "balanced", + "bank", + "banks", + "banquet", + "bark", + "barking", + "barley", + "barrowful", + "based", + "bat", + "bathing", + "bats", + "bawled", + "be", + "beak", + "bear", + "beast", + "beasts", + "beat", + "beating", + "beau", + "beauti", + "beautiful", + "beautifully", + "beautify", + "became", + "because", + "become", + "becoming", + "bed", + "beds", + "bee", + "been", + "before", + "beg", + "began", + "begged", + "begin", + "beginning", + "begins", + "begun", + "behead", + "beheaded", + "beheading", + "behind", + "being", + "believe", + "believed", + "bells", + "belong", + "belongs", + "beloved", + "below", + "belt", + "bend", + "bent", + "besides", + "best", + "better", + "between", + "bill", + "binary", + "bird", + "birds", + "birthday", + "bit", + "bite", + "bitter", + "blacking", + "blades", + "blame", + "blasts", + "bleeds", + "blew", + "blow", + "blown", + "blows", + "body", + "boldly", + "bone", + "bones", + "book", + "books", + "boon", + "boots", + "bore", + "both", + "bother", + "bottle", + "bottom", + "bough", + "bound", + "bowed", + "bowing", + "box", + "boxed", + "boy", + "brain", + "branch", + "branches", + "brandy", + "brass", + "brave", + "breach", + "bread", + "break", + "breath", + "breathe", + "breeze", + "bright", + "brightened", + "bring", + "bringing", + "bristling", + "broke", + "broken", + "brother", + "brought", + "brown", + "brush", + "brushing", + "burn", + "burning", + "burnt", + "burst", + "bursting", + "busily", + "business", + "business@pglaf", + "busy", + "but", + "butter", + "buttercup", + "buttered", + "butterfly", + "buttons", + "by", + "bye", + "c", + "cackled", + "cake", + "cakes", + "calculate", + "calculated", + "call", + "called", + "calling", + "calmly", + "came", + "camomile", + "can", + "canary", + "candle", + "cannot", + "canterbury", + "canvas", + "capering", + "capital", + "card", + "cardboard", + "cards", + "care", + "carefully", + "cares", + "carried", + "carrier", + "carroll", + "carry", + "carrying", + "cart", + "cartwheels", + "case", + "cat", + "catch", + "catching", + "caterpillar", + "cats", + "cattle", + "caucus", + "caught", + "cauldron", + "cause", + "caused", + "cautiously", + "cease", + "ceiling", + "centre", + "certain", + "certainly", + "chain", + "chains", + "chair", + "chance", + "chanced", + "change", + "changed", + "changes", + "changing", + "chapter", + "character", + "charge", + "charges", + "charitable", + "charities", + "chatte", + "cheap", + "cheated", + "check", + "checked", + "checks", + "cheeks", + "cheered", + "cheerfully", + "cherry", + "cheshire", + "chief", + "child", + "childhood", + "children", + "chimney", + "chimneys", + "chin", + "choice", + "choke", + "choked", + "choking", + "choose", + "choosing", + "chop", + "chorus", + "chose", + "christmas", + "chrysalis", + "chuckled", + "circle", + "circumstances", + "city", + "civil", + "claim", + "clamour", + "clapping", + "clasped", + "classics", + "claws", + "clean", + "clear", + "cleared", + "clearer", + "clearly", + "clever", + "climb", + "clinging", + "clock", + "close", + "closed", + "closely", + "closer", + "clubs", + "coast", + "coaxing", + "codes", + "coils", + "cold", + "collar", + "collected", + "collection", + "come", + "comes", + "comfits", + "comfort", + "comfortable", + "comfortably", + "coming", + "commercial", + "committed", + "common", + "commotion", + "company", + "compilation", + "complained", + "complaining", + "completely", + "compliance", + "comply", + "complying", + "compressed", + "computer", + "computers", + "concept", + "concerning", + "concert", + "concluded", + "conclusion", + "condemn", + "conduct", + "confirmation", + "confirmed", + "confused", + "confusing", + "confusion", + "conger", + "conqueror", + "conquest", + "consented", + "consequential", + "consider", + "considerable", + "considered", + "considering", + "constant", + "consultation", + "contact", + "contain", + "containing", + "contempt", + "contemptuous", + "contemptuously", + "content", + "continued", + "contract", + "contradicted", + "contributions", + "conversation", + "conversations", + "convert", + "cook", + "cool", + "copied", + "copies", + "copy", + "copying", + "copyright", + "corner", + "corners", + "corporation", + "corrupt", + "cost", + "costs", + "could", + "couldn", + "counting", + "countries", + "country", + "couple", + "couples", + "courage", + "course", + "court", + "courtiers", + "coward", + "crab", + "crash", + "crashed", + "crawled", + "crawling", + "crazy", + "created", + "creating", + "creation", + "creature", + "creatures", + "credit", + "creep", + "crept", + "cried", + "cries", + "crimson", + "critical", + "crocodile", + "croquet", + "croqueted", + "croqueting", + "cross", + "crossed", + "crossly", + "crouched", + "crowd", + "crowded", + "crown", + "crumbs", + "crust", + "cry", + "crying", + "cucumber", + "cunning", + "cup", + "cupboards", + "cur", + "curiosity", + "curious", + "curiouser", + "curled", + "curls", + "curly", + "currants", + "current", + "curtain", + "curtsey", + "curtseying", + "curving", + "cushion", + "custard", + "custody", + "cut", + "cutting", + }; + } + private void RunTest(Action test) + { + using (UnitTestApplication.Start(Services)) + { + AutoCompleteBox control = CreateControl(); + control.Items = CreateSimpleStringArray(); + TextBox textBox = GetTextBox(control); + Dispatcher.UIThread.RunJobs(); + test.Invoke(control, textBox); + } + } + + private static TestServices Services => TestServices.StyledWindow; + + /*private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of(), + windowingPlatform: new MockWindowingPlatform());*/ + + private AutoCompleteBox CreateControl() + { + var datePicker = + new AutoCompleteBox + { + Template = CreateTemplate() + }; + + datePicker.ApplyTemplate(); + return datePicker; + } + private TextBox GetTextBox(AutoCompleteBox control) + { + return control.GetTemplateChildren() + .OfType() + .First(); + } + private IControlTemplate CreateTemplate() + { + return new FuncControlTemplate(control => + { + var textBox = + new TextBox + { + Name = "PART_TextBox" + }; + var listbox = + new ListBox + { + Name = "PART_SelectingItemsControl" + }; + var popup = + new Popup + { + Name = "PART_Popup" + }; + + var panel = new Panel(); + panel.Children.Add(textBox); + panel.Children.Add(popup); + panel.Children.Add(listbox); + + return panel; + }); + } + } +} From 8361c1fec12d4ad3a8f82007c56afc6ac7aff48d Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:41:38 -0400 Subject: [PATCH 05/54] Fixed a property name mistake in GradientBrush --- src/Avalonia.Visuals/Media/GradientBrush.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Visuals/Media/GradientBrush.cs b/src/Avalonia.Visuals/Media/GradientBrush.cs index 8c2c9a2c01..fb29f68373 100644 --- a/src/Avalonia.Visuals/Media/GradientBrush.cs +++ b/src/Avalonia.Visuals/Media/GradientBrush.cs @@ -22,7 +22,7 @@ namespace Avalonia.Media /// Defines the property. /// public static readonly StyledProperty> GradientStopsProperty = - AvaloniaProperty.Register>(nameof(Opacity)); + AvaloniaProperty.Register>(nameof(GradientStops)); /// /// Initializes a new instance of the class. From 427a634b2132493b18b71316748d736253e66642 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:42:27 -0400 Subject: [PATCH 06/54] Added async population feature --- .../Pages/AutoCompleteBoxPage.xaml | 8 ++- .../Pages/AutoCompleteBoxPage.xaml.cs | 18 +++++ src/Avalonia.Controls/AutoCompleteBox.cs | 70 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml index 491f41ecbf..943fadf100 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -8,7 +8,6 @@ HorizontalAlignment="Center" Gap="8"> - + + + diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs index 9f181d44f2..6f3b8361cd 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -6,6 +6,8 @@ using Avalonia.Markup.Xaml.Data; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace ControlCatalog.Pages { @@ -109,6 +111,9 @@ namespace ControlCatalog.Pages var multibindingBox = this.FindControl("MultiBindingBox"); multibindingBox.ValueMemberBinding = binding; + + var asyncBox = this.FindControl("AsyncBox"); + asyncBox.AsyncPopulator = PopulateAsync; } private IEnumerable GetAllAutoCompleteBox() { @@ -117,6 +122,19 @@ namespace ControlCatalog.Pages .OfType(); } + private bool StringContains(string str, string query) + { + return str.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } + private async Task> PopulateAsync(string searchText, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(1.5), cancellationToken); + + return + States.Where(data => StringContains(data.Name, searchText) || StringContains(data.Capital, searchText)) + .ToList(); + } + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 0e8b93146c..e4962dc069 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -23,6 +23,8 @@ using Avalonia.Utilities; using System.Globalization; using System.Collections.Specialized; using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; namespace Avalonia.Controls { @@ -360,6 +362,8 @@ namespace Avalonia.Controls private IDisposable _collectionChangeSubscription; private IMemberSelector _valueMemberSelector; + private Func>> _asyncPopulator; + private CancellationTokenSource _populationCancellationTokenSource; private bool _itemTemplateIsFromValueMemeberBinding = true; private bool _settingItemTemplateFromValueMemeberBinding; @@ -559,6 +563,12 @@ namespace Avalonia.Controls o => o.ValueMemberSelector, (o, v) => o.ValueMemberSelector = v); + public static readonly DirectProperty>>> AsyncPopulatorProperty = + AvaloniaProperty.RegisterDirect>>>( + nameof(AsyncPopulator), + o => o.AsyncPopulator, + (o, v) => o.AsyncPopulator = v); + private static int ValidateMinimumPrefixLength(AutoCompleteBox control, int value) { Contract.Requires(value >= -1); @@ -1107,6 +1117,12 @@ namespace Avalonia.Controls set { SetAndRaise(TextFilterProperty, ref _textFilter, value); } } + public Func>> AsyncPopulator + { + get { return _asyncPopulator; } + set { SetAndRaise(AsyncPopulatorProperty, ref _asyncPopulator, value); } + } + /// /// Gets or sets a collection that is used to generate the items for the /// drop-down portion of the @@ -1702,6 +1718,11 @@ namespace Avalonia.Controls // 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 @@ -1713,6 +1734,55 @@ namespace Avalonia.Controls PopulateComplete(); } } + private bool TryPopulateAsync(string searchText) + { + _populationCancellationTokenSource?.Cancel(false); + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + + if(_asyncPopulator == null) + { + return false; + } + + _populationCancellationTokenSource = new CancellationTokenSource(); + var task = PopulateAsync(searchText, _populationCancellationTokenSource.Token); + if (task.Status == TaskStatus.Created) + task.Start(); + + return true; + } + private async Task PopulateAsync(string searchText, CancellationToken cancellationToken) + { + + try + { + IEnumerable result = await _asyncPopulator.Invoke(searchText, cancellationToken); + var resultList = result.ToList(); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (!cancellationToken.IsCancellationRequested) + { + Items = resultList; + PopulateComplete(); + } + }); + } + catch (TaskCanceledException) + { } + finally + { + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + } + + } /// /// Private method that directly opens the popup, checks the expander From 7a1305f2d8e9921a415d85fee2acae1505a8160f Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:47:13 -0400 Subject: [PATCH 07/54] Moved CancelableEventArgs to Avalonia.Interactivity --- src/Avalonia.Controls/AutoCompleteBox.cs | 9 --------- .../CancelableEventArgs.cs | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 src/Avalonia.Interactivity/CancelableEventArgs.cs diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index e4962dc069..730c1e895e 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -28,15 +28,6 @@ using System.Threading.Tasks; namespace Avalonia.Controls { - public class CancelableEventArgs : EventArgs - { - public bool Cancel { get; set; } - - public CancelableEventArgs() - : base() - { } - } - /// /// Provides data for the /// diff --git a/src/Avalonia.Interactivity/CancelableEventArgs.cs b/src/Avalonia.Interactivity/CancelableEventArgs.cs new file mode 100644 index 0000000000..13ff9239f1 --- /dev/null +++ b/src/Avalonia.Interactivity/CancelableEventArgs.cs @@ -0,0 +1,16 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Interactivity +{ + public class CancelableEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public CancelableEventArgs() + : base() + { } + } +} From e329140c04fd7a0a7d555b954f6e9522c15811c6 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 17 Mar 2018 23:22:01 +0100 Subject: [PATCH 08/54] Added failing test for #1447. --- .../ContentPresenterTests_Layout.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs index 2ab02a0418..b3c617c4ab 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs @@ -80,6 +80,31 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(new Rect(expectedX, expectedY, expectedWidth, expectedHeight), content.Bounds); } + [Fact] + public void Should_Correctly_Align_Child_With_Fixed_Size() + { + Border content; + var target = new ContentPresenter + { + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + Content = content = new Border + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Bottom, + Width = 16, + Height = 16, + }, + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + // Check correct result for Issue #1447. + Assert.Equal(new Rect(0, 84, 16, 16), content.Bounds); + } + [Fact] public void Content_Can_Be_Stretched() { From 0c94c82b42fd59fd105635eddea7e26d5a22d2d2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sat, 17 Mar 2018 23:23:11 +0100 Subject: [PATCH 09/54] Don't apply layout constraints to child. The child itself will take care of applying its own layout constraints, applying them in the `ContentPresenter` arrange causes #1447. Fixes #1447. --- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index d0a438cc2b..54626e9e97 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -221,7 +221,7 @@ namespace Avalonia.Controls.Presenters { var content = Content; var oldChild = Child; - var newChild = CreateChild(); + var newChild = CreateChild(); // Remove the old child if we're not recycling it. if (oldChild != null && newChild != oldChild) @@ -397,8 +397,6 @@ namespace Avalonia.Controls.Presenters size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); } - size = LayoutHelper.ApplyLayoutConstraints(Child, size); - if (useLayoutRounding) { size = new Size( @@ -412,7 +410,6 @@ namespace Avalonia.Controls.Presenters switch (horizontalContentAlignment) { case HorizontalAlignment.Center: - case HorizontalAlignment.Stretch: originX += (availableSizeMinusMargins.Width - size.Width) / 2; break; case HorizontalAlignment.Right: @@ -423,7 +420,6 @@ namespace Avalonia.Controls.Presenters switch (verticalContentAlignment) { case VerticalAlignment.Center: - case VerticalAlignment.Stretch: originY += (availableSizeMinusMargins.Height - size.Height) / 2; break; case VerticalAlignment.Bottom: From f0afca5eadb4421bf164dd7684a26f6fb1b639f0 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Sun, 18 Mar 2018 02:54:54 +0100 Subject: [PATCH 10/54] Clear TextBox undo stack on binding change. This makes `TextBox` act like WPF's `TextBox` where the undo/redo stack is cleared when a change is made from non-user input such as from a binding. Fixes #336 --- src/Avalonia.Controls/TextBox.cs | 6 +++- src/Avalonia.Controls/Utils/UndoRedoHelper.cs | 36 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 3ec3d6ed5b..a91b7b2a72 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -198,7 +198,11 @@ namespace Avalonia.Controls if (!_ignoreTextChanges) { CaretIndex = CoerceCaretIndex(CaretIndex, value?.Length ?? 0); - SetAndRaise(TextProperty, ref _text, value); + + if (SetAndRaise(TextProperty, ref _text, value)) + { + _undoRedoHelper.Clear(); + } } } } diff --git a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs index be4c1aa6c4..fce002f0f1 100644 --- a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs +++ b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs @@ -10,6 +10,7 @@ namespace Avalonia.Controls.Utils class UndoRedoHelper : WeakTimer.IWeakTimerSubscriber where TState : struct, IEquatable { private readonly IUndoRedoHost _host; + bool _undoRedoing; public interface IUndoRedoHost { @@ -32,10 +33,19 @@ namespace Avalonia.Controls.Utils public void Undo() { - if (_currentNode?.Previous != null) + _undoRedoing = true; + + try + { + if (_currentNode?.Previous != null) + { + _currentNode = _currentNode.Previous; + _host.UndoRedoState = _currentNode.Value; + } + } + finally { - _currentNode = _currentNode.Previous; - _host.UndoRedoState = _currentNode.Value; + _undoRedoing = false; } } @@ -70,10 +80,19 @@ namespace Avalonia.Controls.Utils public void Redo() { - if (_currentNode?.Next != null) + _undoRedoing = true; + + try + { + if (_currentNode?.Next != null) + { + _currentNode = _currentNode.Next; + _host.UndoRedoState = _currentNode.Value; + } + } + finally { - _currentNode = _currentNode.Next; - _host.UndoRedoState = _currentNode.Value; + _undoRedoing = false; } } @@ -91,6 +110,11 @@ namespace Avalonia.Controls.Utils } } + public void Clear() + { + if (!_undoRedoing) _states.Clear(); + } + bool WeakTimer.IWeakTimerSubscriber.Tick() { Snapshot(); From 8acc7c6f157a396d12e1ed477aac65070de58597 Mon Sep 17 00:00:00 2001 From: Artyom Date: Mon, 19 Mar 2018 23:05:12 +0300 Subject: [PATCH 11/54] Remove dollar! Close https://github.com/AvaloniaUI/Avalonia/issues/1397 --- src/Avalonia.Base/Collections/AvaloniaDictionary.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Collections/AvaloniaDictionary.cs b/src/Avalonia.Base/Collections/AvaloniaDictionary.cs index b90dccf74e..84ac85d3db 100644 --- a/src/Avalonia.Base/Collections/AvaloniaDictionary.cs +++ b/src/Avalonia.Base/Collections/AvaloniaDictionary.cs @@ -117,7 +117,7 @@ namespace Avalonia.Collections _inner = new Dictionary(); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count")); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"Item[]")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); if (CollectionChanged != null) @@ -222,4 +222,4 @@ namespace Avalonia.Collections } } } -} \ No newline at end of file +} From 3e73046098037ce4bc6bf316a87d2178057c7add Mon Sep 17 00:00:00 2001 From: sdoroff Date: Mon, 12 Feb 2018 14:17:56 -0500 Subject: [PATCH 12/54] Ported the AutoCompleteBox control from Silverlight --- samples/ControlCatalog/ControlCatalog.csproj | 6 + samples/ControlCatalog/MainView.xaml | 5 +- .../Pages/AutoCompleteBoxPage.xaml | 55 + .../Pages/AutoCompleteBoxPage.xaml.cs | 125 + src/Avalonia.Controls/AutoCompleteBox.cs | 2669 +++++++++++++++++ .../Utils/ISelectionAdapter.cs | 64 + .../SelectingItemsControlSelectionAdapter.cs | 342 +++ .../AutoCompleteBox.xaml | 43 + src/Avalonia.Themes.Default/DefaultTheme.xaml | 3 +- 9 files changed, 3309 insertions(+), 3 deletions(-) create mode 100644 samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml create mode 100644 samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs create mode 100644 src/Avalonia.Controls/AutoCompleteBox.cs create mode 100644 src/Avalonia.Controls/Utils/ISelectionAdapter.cs create mode 100644 src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs create mode 100644 src/Avalonia.Themes.Default/AutoCompleteBox.xaml diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index a3d7a0cdce..acbf1e86e3 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -41,6 +41,9 @@ Designer + + Designer + Designer @@ -113,6 +116,9 @@ BorderPage.xaml + + AutoCompleteBoxPage.xaml + ButtonPage.xaml diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 142d0d42b1..37e4396371 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -5,10 +5,11 @@ + - + @@ -26,4 +27,4 @@ - \ No newline at end of file + diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml new file mode 100644 index 0000000000..491f41ecbf --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -0,0 +1,55 @@ + + + AutoCompleteBox + A control into which the user can input text + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs new file mode 100644 index 0000000000..9f181d44f2 --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -0,0 +1,125 @@ +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.Markup; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.Data; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ControlCatalog.Pages +{ + public class AutoCompleteBoxPage : UserControl + { + public class StateData + { + public string Name { get; private set; } + public string Abbreviation { get; private set; } + public string Capital { get; private set; } + + public StateData(string name, string abbreviatoin, string capital) + { + Name = name; + Abbreviation = abbreviatoin; + Capital = capital; + } + + public override string ToString() + { + return Name; + } + } + + private StateData[] BuildAllStates() + { + return new StateData[] + { + new StateData("Alabama","AL","Montgomery"), + new StateData("Alaska","AK","Juneau"), + new StateData("Arizona","AZ","Phoenix"), + new StateData("Arkansas","AR","Little Rock"), + new StateData("California","CA","Sacramento"), + new StateData("Colorado","CO","Denver"), + new StateData("Connecticut","CT","Hartford"), + new StateData("Delaware","DE","Dover"), + new StateData("Florida","FL","Tallahassee"), + new StateData("Georgia","GA","Atlanta"), + new StateData("Hawaii","HI","Honolulu"), + new StateData("Idaho","ID","Boise"), + new StateData("Illinois","IL","Springfield"), + new StateData("Indiana","IN","Indianapolis"), + new StateData("Iowa","IA","Des Moines"), + new StateData("Kansas","KS","Topeka"), + new StateData("Kentucky","KY","Frankfort"), + new StateData("Louisiana","LA","Baton Rouge"), + new StateData("Maine","ME","Augusta"), + new StateData("Maryland","MD","Annapolis"), + new StateData("Massachusetts","MA","Boston"), + new StateData("Michigan","MI","Lansing"), + new StateData("Minnesota","MN","St. Paul"), + new StateData("Mississippi","MS","Jackson"), + new StateData("Missouri","MO","Jefferson City"), + new StateData("Montana","MT","Helena"), + new StateData("Nebraska","NE","Lincoln"), + new StateData("Nevada","NV","Carson City"), + new StateData("New Hampshire","NH","Concord"), + new StateData("New Jersey","NJ","Trenton"), + new StateData("New Mexico","NM","Santa Fe"), + new StateData("New York","NY","Albany"), + new StateData("North Carolina","NC","Raleigh"), + new StateData("North Dakota","ND","Bismarck"), + new StateData("Ohio","OH","Columbus"), + new StateData("Oklahoma","OK","Oklahoma City"), + new StateData("Oregon","OR","Salem"), + new StateData("Pennsylvania","PA","Harrisburg"), + new StateData("Rhode Island","RI","Providence"), + new StateData("South Carolina","SC","Columbia"), + new StateData("South Dakota","SD","Pierre"), + new StateData("Tennessee","TN","Nashville"), + new StateData("Texas","TX","Austin"), + new StateData("Utah","UT","Salt Lake City"), + new StateData("Vermont","VT","Montpelier"), + new StateData("Virginia","VA","Richmond"), + new StateData("Washington","WA","Olympia"), + new StateData("West Virginia","WV","Charleston"), + new StateData("Wisconsin","WI","Madison"), + new StateData("Wyoming","WY","Cheyenne"), + }; + } + public StateData[] States { get; private set; } + + public AutoCompleteBoxPage() + { + this.InitializeComponent(); + + States = BuildAllStates(); + + foreach (AutoCompleteBox box in GetAllAutoCompleteBox()) + { + box.Items = States; + } + + var converter = new FuncMultiValueConverter(parts => + { + return String.Format("{0} ({1})", parts.ToArray()); + }); + var binding = new MultiBinding { Converter = converter }; + binding.Bindings.Add(new Binding("Name")); + binding.Bindings.Add(new Binding("Abbreviation")); + + var multibindingBox = this.FindControl("MultiBindingBox"); + multibindingBox.ValueMemberBinding = binding; + } + private IEnumerable GetAllAutoCompleteBox() + { + return + this.GetLogicalDescendants() + .OfType(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs new file mode 100644 index 0000000000..0e8b93146c --- /dev/null +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -0,0 +1,2669 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Collections; +using System; +using System.Linq; +using System.Collections.Generic; +using System.Collections; +using System.Collections.ObjectModel; +using System.Diagnostics; +using Avalonia.Threading; +using Avalonia.Controls.Templates; +using Avalonia.VisualTree; +using Avalonia.Utilities; +using System.Globalization; +using System.Collections.Specialized; +using System.Reactive.Disposables; + +namespace Avalonia.Controls +{ + public class CancelableEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public CancelableEventArgs() + : base() + { } + } + + /// + /// Provides data for the + /// + /// event. + /// + public class PopulatedEventArgs : EventArgs + { + /// + /// Gets the list of possible matches added to the drop-down portion of + /// the + /// control. + /// + /// The list of possible matches added to the + /// . + public IEnumerable Data { get; private set; } + + /// + /// Initializes a new instance of the + /// . + /// + /// The list of possible matches added to the + /// drop-down portion of the + /// control. + public PopulatedEventArgs(IEnumerable data) + { + Data = data; + } + } + + /// + /// Provides data for the + /// + /// event. + /// + /// Stable + public class PopulatingEventArgs : CancelableEventArgs + { + /// + /// Gets the text that is used to determine which items to display in + /// the + /// control. + /// + /// The text that is used to determine which items to display in + /// the . + public string Parameter { get; private set; } + + /// + /// Initializes a new instance of the + /// . + /// + /// The value of the + /// + /// property, which is used to filter items for the + /// control. + public PopulatingEventArgs(string parameter) + { + Parameter = parameter; + } + } + + /// + /// Represents the filter used by the + /// control to + /// determine whether an item is a possible match for the specified text. + /// + /// true to indicate is a possible match + /// for ; otherwise false. + /// The string used as the basis for filtering. + /// The item that is compared with the + /// parameter. + /// The type used for filtering the + /// . This type can + /// be either a string or an object. + /// Stable + public delegate bool AutoCompleteFilterPredicate(string search, T item); + + /// + /// Specifies how text in the text box portion of the + /// control is used + /// to filter items specified by the + /// + /// property for display in the drop-down. + /// + /// Stable + public enum AutoCompleteFilterMode + { + /// + /// Specifies that no filter is used. All items are returned. + /// + None = 0, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items start with the specified text. The filter uses the + /// + /// method, specifying + /// as + /// the string comparison criteria. + /// + StartsWith = 1, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items start with the specified text. The filter uses the + /// + /// method, specifying + /// as the string + /// comparison criteria. + /// + StartsWithCaseSensitive = 2, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items start with the specified text. The filter uses the + /// + /// method, specifying + /// as the + /// string comparison criteria. + /// + StartsWithOrdinal = 3, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// start with the specified text. The filter uses the + /// + /// method, specifying as + /// the string comparison criteria. + /// + StartsWithOrdinalCaseSensitive = 4, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items contain the specified text. + /// + Contains = 5, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items contain the specified text. + /// + ContainsCaseSensitive = 6, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items contain the specified text. + /// + ContainsOrdinal = 7, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// contain the specified text. + /// + ContainsOrdinalCaseSensitive = 8, + + /// + /// Specifies a culture-sensitive, case-insensitive filter where the + /// returned items equal the specified text. The filter uses the + /// + /// method, specifying + /// as + /// the search comparison criteria. + /// + Equals = 9, + + /// + /// Specifies a culture-sensitive, case-sensitive filter where the + /// returned items equal the specified text. The filter uses the + /// + /// method, specifying + /// as the string + /// comparison criteria. + /// + EqualsCaseSensitive = 10, + + /// + /// Specifies an ordinal, case-insensitive filter where the returned + /// items equal the specified text. The filter uses the + /// + /// method, specifying + /// as the + /// string comparison criteria. + /// + EqualsOrdinal = 11, + + /// + /// Specifies an ordinal, case-sensitive filter where the returned items + /// equal the specified text. The filter uses the + /// + /// method, specifying as + /// the string comparison criteria. + /// + EqualsOrdinalCaseSensitive = 12, + + /// + /// Specifies that a custom filter is used. This mode is used when the + /// + /// or + /// + /// properties are set. + /// + Custom = 13, + } + + /// + /// Represents a control that provides a text box for user input and a + /// drop-down that contains possible matches based on the input in the text + /// box. + /// + public class AutoCompleteBox : TemplatedControl + { + /// + /// Specifies the name of the selection adapter TemplatePart. + /// + private const string ElementSelectionAdapter = "PART_SelectionAdapter"; + + /// + /// Specifies the name of the Selector TemplatePart. + /// + private const string ElementSelector = "PART_SelectingItemsControl"; + + /// + /// Specifies the name of the Popup TemplatePart. + /// + private const string ElementPopup = "PART_Popup"; + + /// + /// The name for the text box part. + /// + private const string ElementTextBox = "PART_TextBox"; + + private IEnumerable _itemsEnumerable; + + /// + /// Gets or sets a local cached copy of the items data. + /// + private List _items; + + /// + /// Gets or sets the observable collection that contains references to + /// all of the items in the generated view of data that is provided to + /// the selection-style control adapter. + /// + private AvaloniaList _view; + + /// + /// Gets or sets a value to ignore a number of pending change handlers. + /// The value is decremented after each use. This is used to reset the + /// value of properties without performing any of the actions in their + /// change handlers. + /// + /// The int is important as a value because the TextBox + /// TextChanged event does not immediately fire, and this will allow for + /// nested property changes to be ignored. + private int _ignoreTextPropertyChange; + + /// + /// Gets or sets a value indicating whether to ignore calling a pending + /// change handlers. + /// + private bool _ignorePropertyChange; + + /// + /// Gets or sets a value indicating whether to ignore the selection + /// changed event. + /// + private bool _ignoreTextSelectionChange; + + /// + /// Gets or sets a value indicating whether to skip the text update + /// processing when the selected item is updated. + /// + private bool _skipSelectedItemTextUpdate; + + /// + /// Gets or sets the last observed text box selection start location. + /// + private int _textSelectionStart; + + /// + /// Gets or sets a value indicating whether the user initiated the + /// current populate call. + /// + private bool _userCalledPopulate; + + /// + /// A value indicating whether the popup has been opened at least once. + /// + private bool _popupHasOpened; + + /// + /// Gets or sets the DispatcherTimer used for the MinimumPopulateDelay + /// condition for auto completion. + /// + private DispatcherTimer _delayTimer; + + /// + /// Gets or sets a value indicating whether a read-only dependency + /// property change handler should allow the value to be set. This is + /// used to ensure that read-only properties cannot be changed via + /// SetValue, etc. + /// + private bool _allowWrite; + + /// + /// The TextBox template part. + /// + private TextBox _textBox; + private IDisposable _textBoxSubscriptions; + + /// + /// The SelectionAdapter. + /// + private ISelectionAdapter _adapter; + + /// + /// A control that can provide updated string values from a binding. + /// + private BindingEvaluator _valueBindingEvaluator; + + /// + /// A weak subscription for the collection changed event. + /// + private IDisposable _collectionChangeSubscription; + + private IMemberSelector _valueMemberSelector; + + private bool _itemTemplateIsFromValueMemeberBinding = true; + private bool _settingItemTemplateFromValueMemeberBinding; + + private object _selectedItem; + private bool _isDropDownOpen; + private bool _isFocused = false; + + private string _text = string.Empty; + private string _searchText = string.Empty; + + private AutoCompleteFilterPredicate _itemFilter; + private AutoCompleteFilterPredicate _textFilter = AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith); + + public static readonly RoutedEvent SelectionChangedEvent = + RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Bubble, typeof(AutoCompleteBox)); + + public static readonly StyledProperty WatermarkProperty = + TextBox.WatermarkProperty.AddOwner(); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MinimumPrefixLengthProperty = + AvaloniaProperty.Register( + nameof(MinimumPrefixLength), 1, + validate: ValidateMinimumPrefixLength); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MinimumPopulateDelayProperty = + AvaloniaProperty.Register( + nameof(MinimumPopulateDelay), + TimeSpan.Zero, + validate: ValidateMinimumPopulateDelay); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty MaxDropDownHeightProperty = + AvaloniaProperty.Register( + nameof(MaxDropDownHeight), + double.PositiveInfinity, + validate: ValidateMaxDropDownHeight); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty IsTextCompletionEnabledProperty = + AvaloniaProperty.Register(nameof(IsTextCompletionEnabled)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly StyledProperty ItemTemplateProperty = + AvaloniaProperty.Register(nameof(ItemTemplate)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty IsDropDownOpenProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsDropDownOpen), + o => o.IsDropDownOpen, + (o, v) => o.IsDropDownOpen = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier the + /// + /// dependency property. + public static readonly DirectProperty SelectedItemProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedItem), + o => o.SelectedItem, + (o, v) => o.SelectedItem = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty TextProperty = + AvaloniaProperty.RegisterDirect( + nameof(Text), + o => o.Text, + (o, v) => o.Text = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty SearchTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(SearchText), + o => o.SearchText, + unsetValue: string.Empty); + + /// + /// Gets the identifier for the + /// + /// dependency property. + /// + public static readonly StyledProperty FilterModeProperty = + AvaloniaProperty.Register( + nameof(FilterMode), + defaultValue: AutoCompleteFilterMode.StartsWith, + validate: ValidateFilterMode); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty> ItemFilterProperty = + AvaloniaProperty.RegisterDirect>( + nameof(ItemFilter), + o => o.ItemFilter, + (o, v) => o.ItemFilter = v); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty> TextFilterProperty = + AvaloniaProperty.RegisterDirect>( + nameof(TextFilter), + o => o.TextFilter, + (o, v) => o.TextFilter = v, + unsetValue: AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); + + /// + /// Identifies the + /// + /// dependency property. + /// + /// The identifier for the + /// + /// dependency property. + public static readonly DirectProperty ItemsProperty = + AvaloniaProperty.RegisterDirect( + nameof(Items), + o => o.Items, + (o, v) => o.Items = v); + + public static readonly DirectProperty ValueMemberSelectorProperty = + AvaloniaProperty.RegisterDirect( + nameof(ValueMemberSelector), + o => o.ValueMemberSelector, + (o, v) => o.ValueMemberSelector = v); + + private static int ValidateMinimumPrefixLength(AutoCompleteBox control, int value) + { + Contract.Requires(value >= -1); + + return value; + } + + private static TimeSpan ValidateMinimumPopulateDelay(AutoCompleteBox control, TimeSpan value) + { + Contract.Requires(value.TotalMilliseconds >= 0.0); + + return value; + } + + private static double ValidateMaxDropDownHeight(AutoCompleteBox control, double value) + { + Contract.Requires(value >= 0.0); + + return value; + } + + 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; + } + } + private static AutoCompleteFilterMode ValidateFilterMode(AutoCompleteBox control, AutoCompleteFilterMode value) + { + Contract.Requires(IsValidFilterMode(value)); + + return value; + } + + /// + /// Handle the change of the IsEnabled property. + /// + /// The event data. + private void OnControlIsEnabledChanged(AvaloniaPropertyChangedEventArgs e) + { + bool isEnabled = (bool)e.NewValue; + if (!isEnabled) + { + IsDropDownOpen = 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 = null; + } + } + + if (newValue > TimeSpan.Zero) + { + // Create or clear a dispatcher timer instance + if (_delayTimer == null) + { + _delayTimer = new DispatcherTimer(); + _delayTimer.Tick += PopulateDropDown; + } + + // Set the new tick interval + _delayTimer.Interval = newValue; + } + } + + /// + /// IsDropDownOpenProperty property changed handler. + /// + /// Event arguments. + private void OnIsDropDownOpenChanged(AvaloniaPropertyChangedEventArgs e) + { + // Ignore the change if requested + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + bool oldValue = (bool)e.OldValue; + bool newValue = (bool)e.NewValue; + + if (newValue) + { + TextUpdated(Text, true); + } + else + { + ClosingDropDown(oldValue); + } + + UpdatePseudoClasses(); + } + + private void OnSelectedItemPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + // Update the text display + if (_skipSelectedItemTextUpdate) + { + _skipSelectedItemTextUpdate = false; + } + else + { + OnSelectedItemChanged(e.NewValue); + } + + // Fire the SelectionChanged event + List removed = new List(); + if (e.OldValue != null) + { + removed.Add(e.OldValue); + } + + List added = new List(); + if (e.NewValue != null) + { + added.Add(e.NewValue); + } + + OnSelectionChanged(new SelectionChangedEventArgs(SelectionChangedEvent, removed, added)); + } + + /// + /// TextProperty property changed handler. + /// + /// Event arguments. + private void OnTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + TextUpdated((string)e.NewValue, false); + } + + private void OnSearchTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (_ignorePropertyChange) + { + _ignorePropertyChange = false; + return; + } + + // Ensure the property is only written when expected + if (!_allowWrite) + { + // Reset the old value before it was incorrectly written + _ignorePropertyChange = true; + SetValue(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 + TextFilter = AutoCompleteSearch.GetFilter(mode); + } + + /// + /// ItemFilterProperty property changed handler. + /// + /// Event arguments. + private void OnItemFilterPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + AutoCompleteFilterPredicate value = e.NewValue as AutoCompleteFilterPredicate; + + // If null, revert to the "None" predicate + if (value == null) + { + FilterMode = AutoCompleteFilterMode.None; + } + else + { + FilterMode = AutoCompleteFilterMode.Custom; + TextFilter = null; + } + } + + /// + /// ItemsSourceProperty property changed handler. + /// + /// Event arguments. + private void OnItemsPropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + OnItemsChanged((IEnumerable)e.NewValue); + } + + private void OnItemTemplatePropertyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (!_settingItemTemplateFromValueMemeberBinding) + _itemTemplateIsFromValueMemeberBinding = false; + } + private void OnValueMemberBindingChanged(IBinding value) + { + if(_itemTemplateIsFromValueMemeberBinding) + { + var template = + new FuncDataTemplate( + typeof(object), + o => + { + var control = new ContentControl(); + control.Bind(ContentControl.ContentProperty, value); + return control; + }); + + _settingItemTemplateFromValueMemeberBinding = true; + ItemTemplate = template; + _settingItemTemplateFromValueMemeberBinding = false; + } + } + + static AutoCompleteBox() + { + FocusableProperty.OverrideDefaultValue(true); + + MinimumPopulateDelayProperty.Changed.AddClassHandler(x => x.OnMinimumPopulateDelayChanged); + IsDropDownOpenProperty.Changed.AddClassHandler(x => x.OnIsDropDownOpenChanged); + SelectedItemProperty.Changed.AddClassHandler(x => x.OnSelectedItemPropertyChanged); + TextProperty.Changed.AddClassHandler(x => x.OnTextPropertyChanged); + SearchTextProperty.Changed.AddClassHandler(x => x.OnSearchTextPropertyChanged); + FilterModeProperty.Changed.AddClassHandler(x => x.OnFilterModePropertyChanged); + ItemFilterProperty.Changed.AddClassHandler(x => x.OnItemFilterPropertyChanged); + ItemsProperty.Changed.AddClassHandler(x => x.OnItemsPropertyChanged); + IsEnabledProperty.Changed.AddClassHandler(x => x.OnControlIsEnabledChanged); + } + + /// + /// Initializes a new instance of the + /// class. + /// + public AutoCompleteBox() + { + ClearView(); + } + + /// + /// Gets or sets the minimum number of characters required to be entered + /// in the text box before the + /// displays + /// possible matches. + /// 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 { return 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 { return 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 { return 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 { return 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 { return 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 { return _isDropDownOpen; } + set { SetAndRaise(IsDropDownOpenProperty, ref _isDropDownOpen, 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] + public IBinding ValueMemberBinding + { + get { return _valueBindingEvaluator?.ValueBinding; } + set + { + if (ValueMemberBinding != value) + { + _valueBindingEvaluator = new BindingEvaluator(value); + OnValueMemberBindingChanged(value); + } + } + } + + /// + /// Gets or sets the MemberSelector that is used to get values for + /// display in the text portion of the + /// control. + /// + /// The MemberSelector that is used to get values for display in + /// the text portion of the + /// control. + public IMemberSelector ValueMemberSelector + { + get { return _valueMemberSelector; } + set { SetAndRaise(ValueMemberSelectorProperty, ref _valueMemberSelector, 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 { return _selectedItem; } + set { SetAndRaise(SelectedItemProperty, ref _selectedItem, 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 { return _text; } + set { SetAndRaise(TextProperty, ref _text, 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 { return _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 { return GetValue(FilterModeProperty); } + set { SetValue(FilterModeProperty, value); } + } + + public string Watermark + { + get { return 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 { return _itemFilter; } + set { SetAndRaise(ItemFilterProperty, ref _itemFilter, 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 { return _textFilter; } + set { SetAndRaise(TextFilterProperty, ref _textFilter, 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 Items + { + get { return _itemsEnumerable; } + set { SetAndRaise(ItemsProperty, ref _itemsEnumerable, value); } + } + + /// + /// Gets or sets the drop down popup control. + /// + private Popup DropDownPopup { get; set; } + + /// + /// Gets or sets the Text template part. + /// + private TextBox TextBox + { + get { return _textBox; } + set + { + _textBoxSubscriptions?.Dispose(); + _textBox = value; + + // Attach handlers + if (_textBox != null) + { + _textBoxSubscriptions = + _textBox.GetObservable(TextBox.TextProperty) + .Subscribe(_ => OnTextBoxTextChanged()); + + if (Text != null) + { + UpdateTextValue(Text); + } + } + } + } + + private int TextBoxSelectionStart + { + get + { + if (TextBox != null) + { + return Math.Min(TextBox.SelectionStart, TextBox.SelectionEnd); + } + else + { + return 0; + } + } + } + private int TextBoxSelectionLength + { + get + { + if (TextBox != null) + { + return Math.Abs(TextBox.SelectionEnd - TextBox.SelectionStart); + } + else + { + return 0; + } + } + } + + /// + /// Gets or sets the selection adapter used to populate the drop-down + /// with a list of selectable items. + /// + /// The selection adapter used to populate the drop-down with a + /// list of selectable items. + /// + /// You can use this property when you create an automation peer to + /// use with AutoCompleteBox or deriving from AutoCompleteBox to + /// create a custom control. + /// + protected ISelectionAdapter SelectionAdapter + { + get { return _adapter; } + set + { + if (_adapter != null) + { + _adapter.SelectionChanged -= OnAdapterSelectionChanged; + _adapter.Commit -= OnAdapterSelectionComplete; + _adapter.Cancel -= OnAdapterSelectionCanceled; + _adapter.Cancel -= OnAdapterSelectionComplete; + _adapter.Items = null; + } + + _adapter = value; + + if (_adapter != null) + { + _adapter.SelectionChanged += OnAdapterSelectionChanged; + _adapter.Commit += OnAdapterSelectionComplete; + _adapter.Cancel += OnAdapterSelectionCanceled; + _adapter.Cancel += OnAdapterSelectionComplete; + _adapter.Items = _view; + } + } + } + + /// + /// Returns the + /// part, if + /// possible. + /// + /// + /// A object, + /// if possible. Otherwise, null. + /// + protected virtual ISelectionAdapter GetSelectionAdapterPart(INameScope nameScope) + { + ISelectionAdapter adapter = null; + SelectingItemsControl selector = nameScope.Find(ElementSelector); + if (selector != null) + { + // Check if it is already an IItemsSelector + adapter = selector as ISelectionAdapter; + if (adapter == null) + { + // Built in support for wrapping a Selector control + adapter = new SelectingItemsControlSelectionAdapter(selector); + } + } + if (adapter == null) + { + adapter = nameScope.Find(ElementSelectionAdapter); + } + return adapter; + } + + /// + /// Builds the visual tree for the + /// control + /// when a new template is applied. + /// + protected override void OnTemplateApplied(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.OnTemplateApplied(e); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnKeyDown(KeyEventArgs e) + { + Contract.Requires(e != null); + + 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. + if (e.Key == Key.Down) + { + IsDropDownOpen = true; + e.Handled = true; + } + } + + // Standard drop down navigation + switch (e.Key) + { + case Key.F4: + IsDropDownOpen = !IsDropDownOpen; + e.Handled = true; + break; + + case Key.Enter: + OnAdapterSelectionComplete(this, new RoutedEventArgs()); + e.Handled = true; + break; + + default: + break; + } + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Provides handling for the + /// event. + /// + /// A + /// that contains the event data. + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + FocusChanged(HasFocus()); + } + + /// + /// Determines whether the text box or drop-down portion of the + /// control has + /// focus. + /// + /// true to indicate the + /// has focus; + /// otherwise, false. + protected bool HasFocus() + { + IVisual focused = FocusManager.Instance.Current; + + while (focused != null) + { + if (object.ReferenceEquals(focused, this)) + { + return true; + } + + // This helps deal with popups that may not be in the same + // visual tree + IVisual parent = focused.GetVisualParent(); + if (parent == null) + { + // Try the logical parent. + IControl element = focused as IControl; + if (element != null) + { + parent = element.Parent; + } + } + focused = parent; + } + return false; + } + + /// + /// Handles the FocusChanged event. + /// + /// A value indicating whether the control + /// currently has the focus. + private void FocusChanged(bool hasFocus) + { + // The OnGotFocus & OnLostFocus are asynchronously and cannot + // reliably tell you that have the focus. All they do is let you + // know that the focus changed sometime in the past. To determine + // if you currently have the focus you need to do consult the + // FocusManager (see HasFocus()). + + bool wasFocused = _isFocused; + _isFocused = hasFocus; + + if (hasFocus) + { + + if (!wasFocused && TextBox != null && TextBoxSelectionLength <= 0) + { + TextBox.Focus(); + TextBox.SelectionStart = 0; + TextBox.SelectionEnd = TextBox.Text?.Length ?? 0; + } + } + else + { + IsDropDownOpen = false; + _userCalledPopulate = false; + ClearTextBoxSelection(); + } + + _isFocused = hasFocus; + } + + /// + /// Occurs when the text in the text box portion of the + /// changes. + /// + public event EventHandler TextChanged; + + /// + /// 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(CancelableEventArgs e) + { + DropDownOpening?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownOpened(EventArgs e) + { + DropDownOpened?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownClosing(CancelableEventArgs e) + { + DropDownClosing?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// which contains the event data. + protected virtual void OnDropDownClosed(EventArgs e) + { + DropDownClosed?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// that contains the event data. + protected virtual void OnTextChanged(RoutedEventArgs e) + { + TextChanged?.Invoke(this, e); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + var args = new CancelableEventArgs(); + OnDropDownClosing(args); + + if (args.Cancel) + { + _ignorePropertyChange = true; + SetValue(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 CancelableEventArgs(); + + // Opening + OnDropDownOpening(args); + + if (args.Cancel) + { + _ignorePropertyChange = true; + SetValue(IsDropDownOpenProperty, oldValue); + } + else + { + OpenDropDown(); + } + + UpdatePseudoClasses(); + } + + /// + /// Connects to the DropDownPopup Closed event. + /// + /// The source object. + /// The event data. + private void DropDownPopup_Closed(object sender, EventArgs e) + { + // Force the drop down dependency property to be false. + if (IsDropDownOpen) + { + IsDropDownOpen = false; + } + + // Fire the DropDownClosed event + if (_popupHasOpened) + { + OnDropDownClosed(EventArgs.Empty); + } + } + + /// + /// Handles the timer tick when using a populate delay. + /// + /// The source object. + /// The event arguments. + private void PopulateDropDown(object sender, EventArgs e) + { + if (_delayTimer != null) + { + _delayTimer.Stop(); + } + + // Update the prefix/search text. + SearchText = Text; + + // 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 method that directly opens the popup, checks the expander + /// button, and then fires the Opened event. + /// + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + _popupHasOpened = true; + OnDropDownOpened(EventArgs.Empty); + } + + /// + /// Private method that directly closes the popup, flips the Checked + /// value, and then fires the Closed event. + /// + private void CloseDropDown() + { + if (_popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + OnDropDownClosed(EventArgs.Empty); + } + } + + /// + /// Formats an Item for text comparisons based on Converter + /// and ConverterCulture properties. + /// + /// The object to format. + /// A value indicating whether to clear + /// the data context after the lookup is performed. + /// Formatted Value. + private string FormatValue(object value, bool clearDataContext) + { + string result = FormatValue(value); + if(clearDataContext && _valueBindingEvaluator != null) + { + _valueBindingEvaluator.ClearDataContext(); + } + + return result; + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + if (_valueBindingEvaluator != null) + { + return _valueBindingEvaluator.GetDynamicValue(value) ?? String.Empty; + } + + if (_valueMemberSelector != null) + { + value = _valueMemberSelector.Select(value); + } + + return value == null ? String.Empty : value.ToString(); + } + + /// + /// Handle the TextChanged event that is directly attached to the + /// TextBox part. This ensures that only user initiated actions will + /// result in an AutoCompleteBox suggestion and operation. + /// + private void OnTextBoxTextChanged() + { + //Uses Dispatcher.Post to allow the TextBox selection to update before processing + Dispatcher.UIThread.Post(() => + { + // Call the central updated text method as a user-initiated action + TextUpdated(_textBox.Text, true); + }); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + private void UpdateTextValue(string value) + { + UpdateTextValue(value, null); + } + + /// + /// Updates both the text box value and underlying text dependency + /// property value if and when they change. Automatically fires the + /// text changed events when there is a change. + /// + /// The new string value. + /// A nullable bool value indicating whether + /// the action was user initiated. In a user initiated mode, the + /// underlying text dependency property is updated. In a non-user + /// interaction, the text box value is updated. When user initiated is + /// null, all values are updated. + private void UpdateTextValue(string value, bool? userInitiated) + { + bool callTextChanged = false; + // Update the Text dependency property + if ((userInitiated == null || userInitiated == true) && Text != value) + { + _ignoreTextPropertyChange++; + Text = 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 RoutedEventArgs()); + } + } + + /// + /// 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) + { + return; + } + + // Evaluate the conditions needed for completion. + // 1. Minimum prefix length + // 2. If a delay timer is in use, use it + bool populateReady = newText.Length >= MinimumPrefixLength && MinimumPrefixLength >= 0; + _userCalledPopulate = populateReady ? userInitiated : false; + + // Update the interface and values only as necessary + UpdateTextValue(newText, userInitiated); + + if (populateReady) + { + _ignoreTextSelectionChange = true; + + if (_delayTimer != null) + { + _delayTimer.Start(); + } + else + { + PopulateDropDown(this, EventArgs.Empty); + } + } + else + { + SearchText = string.Empty; + if (SelectedItem != null) + { + _skipSelectedItemTextUpdate = true; + } + SelectedItem = null; + if (IsDropDownOpen) + { + IsDropDownOpen = 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 (_items == null) + { + ClearView(); + return; + } + + // Cache the current text value + string text = Text ?? string.Empty; + + // Determine if any filtering mode is on + bool stringFiltering = TextFilter != null; + bool objectFiltering = FilterMode == AutoCompleteFilterMode.Custom && TextFilter == null; + + int view_index = 0; + int view_count = _view.Count; + List items = _items; + foreach (object item in items) + { + bool inResults = !(stringFiltering || objectFiltering); + if (!inResults) + { + inResults = stringFiltering ? TextFilter(text, FormatValue(item)) : ItemFilter(text, item); + } + + if (view_count > view_index && inResults && _view[view_index] == item) + { + // Item is still in the view + view_index++; + } + else if (inResults) + { + // Insert the item + if (view_count > view_index && _view[view_index] != item) + { + // Replace item + // Unfortunately replacing via index throws a fatal + // exception: View[view_index] = item; + // Cost: O(n) vs O(1) + _view.RemoveAt(view_index); + _view.Insert(view_index, item); + view_index++; + } + else + { + // Add the item + if (view_index == view_count) + { + // Constant time is preferred (Add). + _view.Add(item); + } + else + { + _view.Insert(view_index, item); + } + view_index++; + view_count++; + } + } + else if (view_count > view_index && _view[view_index] == item) + { + // Remove the item + _view.RemoveAt(view_index); + view_count--; + } + } + + // Clear the evaluator to discard a reference to the last item + if (_valueBindingEvaluator != null) + { + _valueBindingEvaluator.ClearDataContext(); + } + } + + /// + /// 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 OnItemsChanged(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().ToList()); + + // Clear and set the view on the selection adapter + ClearView(); + if (SelectionAdapter != null && SelectionAdapter.Items != _view) + { + SelectionAdapter.Items = _view; + } + if (IsDropDownOpen) + { + RefreshView(); + } + } + + /// + /// Method that handles the ObservableCollection.CollectionChanged event for the ItemsSource property. + /// + /// The object that raised the event. + /// The event data. + private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // Update the cache + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + _items.RemoveAt(e.OldStartingIndex); + } + } + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null && _items.Count >= e.NewStartingIndex) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + _items.Insert(e.NewStartingIndex + index, e.NewItems[index]); + } + } + if (e.Action == NotifyCollectionChangedAction.Replace && e.NewItems != null && e.OldItems != null) + { + for (int index = 0; index < e.NewItems.Count; index++) + { + _items[e.NewStartingIndex] = e.NewItems[index]; + } + } + + // Update the view + if (e.Action == NotifyCollectionChangedAction.Remove || e.Action == NotifyCollectionChangedAction.Replace) + { + for (int index = 0; index < e.OldItems.Count; index++) + { + _view.Remove(e.OldItems[index]); + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + // Significant changes to the underlying data. + ClearView(); + if (Items != null) + { + _items = new List(Items.Cast().ToList()); + } + } + + // 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.Items != _view) + { + SelectionAdapter.Items = _view; + } + + bool isDropDownOpen = _userCalledPopulate && (_view.Count > 0); + if (isDropDownOpen != IsDropDownOpen) + { + _ignorePropertyChange = true; + IsDropDownOpen = isDropDownOpen; + } + if (IsDropDownOpen) + { + OpeningDropDown(false); + } + else + { + ClosingDropDown(true); + } + + UpdateTextCompletion(_userCalledPopulate); + } + + /// + /// Performs text completion, if enabled, and a lookup on the underlying + /// item values for an exact match. Will update the SelectedItem value. + /// + /// A value indicating whether the operation + /// was user initiated. Text completion will not be performed when not + /// directly initiated by the user. + private void UpdateTextCompletion(bool userInitiated) + { + // By default this method will clear the selected value + object newSelectedItem = null; + string text = Text; + + // Text search is StartsWith explicit and only when enabled, in + // line with WPF's ComboBox lookup. When in use it will associate + // a Value with the Text if it is found in ItemsSource. This is + // only valid when there is data and the user initiated the action. + if (_view.Count > 0) + { + if (IsTextCompletionEnabled && TextBox != null && userInitiated) + { + int currentLength = TextBox.Text.Length; + int selectionStart = TextBoxSelectionStart; + if (selectionStart == text.Length && selectionStart > _textSelectionStart) + { + // When the FilterMode dependency property is set to + // either StartsWith or StartsWithCaseSensitive, the + // first item in the view is used. This will improve + // performance on the lookup. It assumes that the + // FilterMode the user has selected is an acceptable + // case sensitive matching function for their scenario. + object top = FilterMode == AutoCompleteFilterMode.StartsWith || FilterMode == AutoCompleteFilterMode.StartsWithCaseSensitive + ? _view[0] + : TryGetMatch(text, _view, AutoCompleteSearch.GetFilter(AutoCompleteFilterMode.StartsWith)); + + // If the search was successful, update SelectedItem + if (top != null) + { + newSelectedItem = top; + string topString = FormatValue(top, true); + + // Only replace partially when the two words being the same + int minLength = Math.Min(topString.Length, Text.Length); + 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; + } + } + } + } + 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; + } + SelectedItem = 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 (view != null && view.Count > 0) + { + foreach (object o in view) + { + if (predicate(searchText, FormatValue(o))) + { + return o; + } + } + } + + return null; + } + + private void UpdatePseudoClasses() + { + PseudoClasses.Set(":dropdownopen", IsDropDownOpen); + } + + private void ClearTextBoxSelection() + { + if (TextBox != null) + { + int length = TextBox.Text?.Length ?? 0; + TextBox.SelectionStart = length; + TextBox.SelectionEnd = length; + } + } + + /// + /// Called when the selected item is changed, updates the text value + /// that is displayed in the text box part. + /// + /// The new item. + private void OnSelectedItemChanged(object newItem) + { + string text; + + if (newItem == null) + { + text = SearchText; + } + else + { + 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) + { + SelectedItem = _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) + { + IsDropDownOpen = 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) + { + return s.IndexOf(value, comparison) >= 0; + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.CurrentCultureIgnoreCase); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.CurrentCulture); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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) + { + return value.StartsWith(text, StringComparison.Ordinal); + } + + /// + /// 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 value.Equals(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 value.Equals(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 value.Equals(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 value.Equals(text, StringComparison.Ordinal); + } + } + + /// + /// A framework element that permits a binding to be evaluated in a new data + /// context leaf node. + /// + /// The type of dynamic binding to return. + public class BindingEvaluator : Control + { + /// + /// Gets or sets the string value binding used by the control. + /// + private IBinding _binding; + + #region public T Value + + /// + /// Identifies the Value dependency property. + /// + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register, T>(nameof(Value)); + + /// + /// Gets or sets the data item value. + /// + public T Value + { + get { return GetValue(ValueProperty); } + set { SetValue(ValueProperty, value); } + } + + #endregion public string Value + + /// + /// Gets or sets the value binding. + /// + public IBinding ValueBinding + { + get { return _binding; } + set + { + _binding = value; + AvaloniaObjectExtensions.Bind(this, ValueProperty, value); + } + } + + /// + /// Initializes a new instance of the BindingEvaluator class. + /// + public BindingEvaluator() + { } + + /// + /// Initializes a new instance of the BindingEvaluator class, + /// setting the initial binding to the provided parameter. + /// + /// The initial string value binding. + public BindingEvaluator(IBinding binding) + : this() + { + ValueBinding = binding; + } + + /// + /// Clears the data context so that the control does not keep a + /// reference to the last-looked up item. + /// + public void ClearDataContext() + { + DataContext = null; + } + + /// + /// Updates the data context of the framework element and returns the + /// updated binding value. + /// + /// The object to use as the data context. + /// If set to true, this parameter will + /// clear the data context immediately after retrieving the value. + /// Returns the evaluated T value of the bound dependency + /// property. + public T GetDynamicValue(object o, bool clearDataContext) + { + DataContext = o; + T value = Value; + if (clearDataContext) + { + DataContext = null; + } + return value; + } + + /// + /// Updates the data context of the framework element and returns the + /// updated binding value. + /// + /// The object to use as the data context. + /// Returns the evaluated T value of the bound dependency + /// property. + public T GetDynamicValue(object o) + { + DataContext = o; + return Value; + } + } + } +} diff --git a/src/Avalonia.Controls/Utils/ISelectionAdapter.cs b/src/Avalonia.Controls/Utils/ISelectionAdapter.cs new file mode 100644 index 0000000000..3c1006a12e --- /dev/null +++ b/src/Avalonia.Controls/Utils/ISelectionAdapter.cs @@ -0,0 +1,64 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using System; +using System.Collections; +using Avalonia.Interactivity; +using Avalonia.Input; + +namespace Avalonia.Controls.Utils +{ + /// + /// Defines an item collection, selection members, and key handling for the + /// selection adapter contained in the drop-down portion of an + /// control. + /// + public interface ISelectionAdapter + { + /// + /// Gets or sets the selected item. + /// + /// The currently selected item. + object SelectedItem { get; set; } + + /// + /// Occurs when the + /// + /// property value changes. + /// + event EventHandler SelectionChanged; + + /// + /// Gets or sets a collection that is used to generate content for the + /// selection adapter. + /// + /// The collection that is used to generate content for the + /// selection adapter. + IEnumerable Items { get; set; } + + /// + /// Occurs when a selected item is not cancelled and is committed as the + /// selected item. + /// + event EventHandler Commit; + + /// + /// Occurs when a selection has been canceled. + /// + event EventHandler Cancel; + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + void HandleKeyDown(KeyEventArgs e); + } + +} diff --git a/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs b/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs new file mode 100644 index 0000000000..43c8a5aa6c --- /dev/null +++ b/src/Avalonia.Controls/Utils/SelectingItemsControlSelectionAdapter.cs @@ -0,0 +1,342 @@ +// (c) Copyright Microsoft Corporation. +// This source is subject to the Microsoft Public License (Ms-PL). +// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. +// All other rights reserved. + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Input; +using Avalonia.LogicalTree; +using System.Collections; +using System.Diagnostics; + +namespace Avalonia.Controls.Utils +{ + /// + /// Represents the selection adapter contained in the drop-down portion of + /// an control. + /// + public class SelectingItemsControlSelectionAdapter : ISelectionAdapter + { + /// + /// The SelectingItemsControl instance. + /// + private SelectingItemsControl _selector; + + /// + /// Gets or sets a value indicating whether the selection change event + /// should not be fired. + /// + private bool IgnoringSelectionChanged { get; set; } + + /// + /// Gets or sets the underlying + /// + /// control. + /// + /// The underlying + /// + /// control. + public SelectingItemsControl SelectorControl + { + get { return _selector; } + + set + { + if (_selector != null) + { + _selector.SelectionChanged -= OnSelectionChanged; + _selector.PointerReleased -= OnSelectorPointerReleased; + } + + _selector = value; + + if (_selector != null) + { + _selector.SelectionChanged += OnSelectionChanged; + _selector.PointerReleased += OnSelectorPointerReleased; + } + } + } + + /// + /// Occurs when the + /// + /// property value changes. + /// + public event EventHandler SelectionChanged; + + /// + /// Occurs when an item is selected and is committed to the underlying + /// + /// control. + /// + public event EventHandler Commit; + + /// + /// Occurs when a selection is canceled before it is committed. + /// + public event EventHandler Cancel; + + /// + /// Initializes a new instance of the + /// + /// class. + /// + public SelectingItemsControlSelectionAdapter() + { + + } + + /// + /// Initializes a new instance of the + /// + /// class with the specified + /// + /// control. + /// + /// The + /// control + /// to wrap as a + /// . + public SelectingItemsControlSelectionAdapter(SelectingItemsControl selector) + { + SelectorControl = selector; + } + + /// + /// Gets or sets the selected item of the selection adapter. + /// + /// The selected item of the underlying selection adapter. + public object SelectedItem + { + get + { + return SelectorControl?.SelectedItem; + } + + set + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = value; + } + + // Attempt to reset the scroll viewer's position + if (value == null) + { + ResetScrollViewer(); + } + + IgnoringSelectionChanged = false; + } + } + + /// + /// Gets or sets a collection that is used to generate the content of + /// the selection adapter. + /// + /// The collection used to generate content for the selection + /// adapter. + public IEnumerable Items + { + get + { + return SelectorControl?.Items; + } + set + { + if (SelectorControl != null) + { + SelectorControl.Items = value; + } + } + } + + /// + /// If the control contains a ScrollViewer, this will reset the viewer + /// to be scrolled to the top. + /// + private void ResetScrollViewer() + { + if (SelectorControl != null) + { + ScrollViewer sv = SelectorControl.GetLogicalDescendants().OfType().FirstOrDefault(); + if (sv != null) + { + sv.Offset = new Vector(0, 0); + } + } + } + + /// + /// Handles the mouse left button up event on the selector control. + /// + /// The source object. + /// The event data. + private void OnSelectorPointerReleased(object sender, PointerReleasedEventArgs e) + { + if (e.MouseButton == MouseButton.Left) + { + OnCommit(); + } + } + + /// + /// Handles the SelectionChanged event on the SelectingItemsControl control. + /// + /// The source object. + /// The selection changed event data. + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IgnoringSelectionChanged) + { + return; + } + + SelectionChanged?.Invoke(sender, e); + } + + /// + /// Increments the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexIncrement() + { + if (SelectorControl != null) + { + SelectorControl.SelectedIndex = SelectorControl.SelectedIndex + 1 >= SelectorControl.ItemCount ? -1 : SelectorControl.SelectedIndex + 1; + } + } + + /// + /// Decrements the + /// + /// property of the underlying + /// + /// control. + /// + protected void SelectedIndexDecrement() + { + if (SelectorControl != null) + { + int index = SelectorControl.SelectedIndex; + if (index >= 0) + { + SelectorControl.SelectedIndex--; + } + else if (index == -1) + { + SelectorControl.SelectedIndex = SelectorControl.ItemCount - 1; + } + } + } + + /// + /// Provides handling for the + /// event that occurs + /// when a key is pressed while the drop-down portion of the + /// has focus. + /// + /// A + /// that contains data about the + /// event. + public void HandleKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + OnCommit(); + e.Handled = true; + break; + + case Key.Up: + SelectedIndexDecrement(); + e.Handled = true; + break; + + case Key.Down: + if ((e.Modifiers & InputModifiers.Alt) == InputModifiers.None) + { + SelectedIndexIncrement(); + e.Handled = true; + } + break; + + case Key.Escape: + OnCancel(); + e.Handled = true; + break; + + default: + break; + } + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCommit() + { + OnCommit(this, new RoutedEventArgs()); + } + + /// + /// Fires the Commit event. + /// + /// The source object. + /// The event data. + private void OnCommit(object sender, RoutedEventArgs e) + { + Commit?.Invoke(sender, e); + + AfterAdapterAction(); + } + + /// + /// Raises the + /// + /// event. + /// + protected virtual void OnCancel() + { + OnCancel(this, new RoutedEventArgs()); + } + + /// + /// Fires the Cancel event. + /// + /// The source object. + /// The event data. + private void OnCancel(object sender, RoutedEventArgs e) + { + Cancel?.Invoke(sender, e); + + AfterAdapterAction(); + } + + /// + /// Change the selection after the actions are complete. + /// + private void AfterAdapterAction() + { + IgnoringSelectionChanged = true; + if (SelectorControl != null) + { + SelectorControl.SelectedItem = null; + SelectorControl.SelectedIndex = -1; + } + IgnoringSelectionChanged = false; + } + } +} diff --git a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml new file mode 100644 index 0000000000..82dbf6064b --- /dev/null +++ b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml @@ -0,0 +1,43 @@ + + + + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 7c567c1835..9a88b7ab55 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -23,7 +23,7 @@ - + @@ -43,4 +43,5 @@ + From 6bc4c1764c06a7e3efea4ce82db8bb5ca8a4abb8 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Wed, 28 Feb 2018 20:09:11 -0500 Subject: [PATCH 13/54] Fixed an off by one bug --- src/Avalonia.Controls/TextBox.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index f73725f95a..b4a8095247 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -791,7 +791,7 @@ namespace Avalonia.Controls int pos = 0; int i; - for (i = 0; i < lines.Count; ++i) + for (i = 0; i < lines.Count - 1; ++i) { var line = lines[i]; pos += line.Length; From 28c78d3fc9cad02777a7f16f559f7b5c95152c41 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Wed, 28 Feb 2018 20:10:35 -0500 Subject: [PATCH 14/54] Moved the setting of FocusedElement This allows the LostFocus event to check the newly focused element --- src/Avalonia.Input/KeyboardDevice.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Input/KeyboardDevice.cs b/src/Avalonia.Input/KeyboardDevice.cs index d815f8082b..2a1cdec1c0 100644 --- a/src/Avalonia.Input/KeyboardDevice.cs +++ b/src/Avalonia.Input/KeyboardDevice.cs @@ -46,13 +46,13 @@ namespace Avalonia.Input if (element != FocusedElement) { var interactive = FocusedElement as IInteractive; + FocusedElement = element; interactive?.RaiseEvent(new RoutedEventArgs { RoutedEvent = InputElement.LostFocusEvent, }); - FocusedElement = element; interactive = element as IInteractive; interactive?.RaiseEvent(new GotFocusEventArgs From cee1a79911da2115cb5852ac0bde23b3804c775c Mon Sep 17 00:00:00 2001 From: sdoroff Date: Mon, 5 Mar 2018 12:09:56 -0500 Subject: [PATCH 15/54] Added Unit Tests --- .../AutoCompleteBoxTests.cs | 1042 +++++++++++++++++ 1 file changed, 1042 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs diff --git a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs new file mode 100644 index 0000000000..f9da2ab6f3 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs @@ -0,0 +1,1042 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Markup.Xaml.Data; +using Avalonia.Platform; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Moq; +using Xunit; +using System.Collections.ObjectModel; + +namespace Avalonia.Controls.UnitTests +{ + public class AutoCompleteBoxTests + { + [Fact] + public void Search_Filters() + { + Assert.True(GetFilter(AutoCompleteFilterMode.Contains)("am", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.Contains)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.Contains)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsCaseSensitive)("hello", "name")); + + Assert.Null(GetFilter(AutoCompleteFilterMode.Custom)); + Assert.Null(GetFilter(AutoCompleteFilterMode.None)); + + Assert.True(GetFilter(AutoCompleteFilterMode.Equals)("na", "na")); + Assert.True(GetFilter(AutoCompleteFilterMode.Equals)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.Equals)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("na", "na")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWith)("na", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWith)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWith)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithCaseSensitive)("hello", "name")); + } + + [Fact] + public void Ordinal_Search_Filters() + { + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("am", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("AME", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.ContainsOrdinalCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("na", "na")); + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("na", "na")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("na", "NA")); + Assert.False(GetFilter(AutoCompleteFilterMode.EqualsOrdinalCaseSensitive)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("na", "name")); + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinal)("hello", "name")); + + Assert.True(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("na", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("NAM", "name")); + Assert.False(GetFilter(AutoCompleteFilterMode.StartsWithOrdinalCaseSensitive)("hello", "name")); + } + + [Fact] + public void Fires_DropDown_Events() + { + RunTest((control, textbox) => + { + bool openEvent = false; + bool closeEvent = false; + control.DropDownOpened += (s, e) => openEvent = true; + control.DropDownClosed += (s, e) => closeEvent = true; + control.Items = CreateSimpleStringArray(); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.SearchText == "a"); + Assert.True(control.IsDropDownOpen); + Assert.True(openEvent); + + textbox.Text = String.Empty; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.SearchText == String.Empty); + Assert.False(control.IsDropDownOpen); + Assert.True(closeEvent); + }); + } + + [Fact] + public void Text_Completion_Via_Text_Property() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + + Assert.Equal(String.Empty, control.Text); + control.Text = "close"; + Assert.NotNull(control.SelectedItem); + }); + } + + [Fact] + public void Text_Completion_Selects_Text() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + + textbox.Text = "ac"; + textbox.SelectionEnd = textbox.SelectionStart = 2; + Dispatcher.UIThread.RunJobs(); + + Assert.True(control.IsDropDownOpen); + Assert.True(Math.Abs(textbox.SelectionEnd - textbox.SelectionStart) > 2); + }); + } + + [Fact] + public void TextChanged_Event_Fires() + { + RunTest((control, textbox) => + { + bool textChanged = false; + control.TextChanged += (s, e) => textChanged = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + + textChanged = false; + control.Text = "conversati"; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + + textChanged = false; + control.Text = null; + Dispatcher.UIThread.RunJobs(); + Assert.True(textChanged); + }); + } + + [Fact] + public void MinimumPrefixLength_Works() + { + RunTest((control, textbox) => + { + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + + + textbox.Text = String.Empty; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + + control.MinimumPrefixLength = 3; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_DropDown_Opening() + { + RunTest((control, textbox) => + { + control.DropDownOpening += (s, e) => e.Cancel = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.False(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_DropDown_Closing() + { + RunTest((control, textbox) => + { + control.DropDownClosing += (s, e) => e.Cancel = true; + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.True(control.IsDropDownOpen); + + control.IsDropDownOpen = false; + Assert.True(control.IsDropDownOpen); + }); + } + + [Fact] + public void Can_Cancel_Population() + { + RunTest((control, textbox) => + { + bool populating = false; + bool populated = false; + control.FilterMode = AutoCompleteFilterMode.None; + control.Populating += (s, e) => + { + e.Cancel = true; + populating = true; + }; + control.Populated += (s, e) => populated = true; + + textbox.Text = "accounti"; + Dispatcher.UIThread.RunJobs(); + + Assert.True(populating); + Assert.False(populated); + }); + } + + [Fact] + public void Custom_Population_Supported() + { + RunTest((control, textbox) => + { + string custom = "Custom!"; + string search = "accounti"; + bool populated = false; + bool populatedOk = false; + control.FilterMode = AutoCompleteFilterMode.None; + control.Populating += (s, e) => + { + control.Items = new string[] { custom }; + Assert.Equal(search, e.Parameter); + }; + control.Populated += (s, e) => + { + populated = true; + ReadOnlyCollection collection = e.Data as ReadOnlyCollection; + populatedOk = collection != null && collection.Count == 1; + }; + + textbox.Text = search; + Dispatcher.UIThread.RunJobs(); + + Assert.True(populated); + Assert.True(populatedOk); + }); + } + + [Fact] + public void Text_Completion() + { + RunTest((control, textbox) => + { + control.IsTextCompletionEnabled = true; + textbox.Text = "accounti"; + textbox.SelectionStart = textbox.SelectionEnd = textbox.Text.Length; + Dispatcher.UIThread.RunJobs(); + Assert.Equal("accounti", control.SearchText); + Assert.Equal("accounting", textbox.Text); + }); + } + + [Fact] + public void String_Search() + { + RunTest((control, textbox) => + { + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = ""; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "accept"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + }); + } + + [Fact] + public void Item_Search() + { + RunTest((control, textbox) => + { + control.FilterMode = AutoCompleteFilterMode.Custom; + control.ItemFilter = (search, item) => + { + string s = item as string; + return s == null ? false : true; + }; + + // Just set to null briefly to exercise that code path + AutoCompleteFilterPredicate filter = control.ItemFilter; + Assert.NotNull(filter); + control.ItemFilter = null; + Assert.Null(control.ItemFilter); + control.ItemFilter = filter; + Assert.NotNull(control.ItemFilter); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "acc"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "a"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = ""; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "accept"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + + textbox.Text = "cook"; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(textbox.Text, control.Text); + }); + } + + /// + /// Retrieves a defined predicate filter through a new AutoCompleteBox + /// control instance. + /// + /// The FilterMode of interest. + /// Returns the predicate instance. + private static AutoCompleteFilterPredicate GetFilter(AutoCompleteFilterMode mode) + { + return new AutoCompleteBox { FilterMode = mode } + .TextFilter; + } + + /// + /// Creates a large list of strings for AutoCompleteBox testing. + /// + /// Returns a new List of string values. + private IList CreateSimpleStringArray() + { + return new List + { + "a", + "abide", + "able", + "about", + "above", + "absence", + "absurd", + "accept", + "acceptance", + "accepted", + "accepting", + "access", + "accessed", + "accessible", + "accident", + "accidentally", + "accordance", + "account", + "accounting", + "accounts", + "accusation", + "accustomed", + "ache", + "across", + "act", + "active", + "actual", + "actually", + "ada", + "added", + "adding", + "addition", + "additional", + "additions", + "address", + "addressed", + "addresses", + "addressing", + "adjourn", + "adoption", + "advance", + "advantage", + "adventures", + "advice", + "advisable", + "advise", + "affair", + "affectionately", + "afford", + "afore", + "afraid", + "after", + "afterwards", + "again", + "against", + "age", + "aged", + "agent", + "ago", + "agony", + "agree", + "agreed", + "agreement", + "ah", + "ahem", + "air", + "airs", + "ak", + "alarm", + "alarmed", + "alas", + "alice", + "alive", + "all", + "allow", + "almost", + "alone", + "along", + "aloud", + "already", + "also", + "alteration", + "altered", + "alternate", + "alternately", + "altogether", + "always", + "am", + "ambition", + "among", + "an", + "ancient", + "and", + "anger", + "angrily", + "angry", + "animal", + "animals", + "ann", + "annoy", + "annoyed", + "another", + "answer", + "answered", + "answers", + "antipathies", + "anxious", + "anxiously", + "any", + "anyone", + "anything", + "anywhere", + "appealed", + "appear", + "appearance", + "appeared", + "appearing", + "appears", + "applause", + "apple", + "apples", + "applicable", + "apply", + "approach", + "arch", + "archbishop", + "arches", + "archive", + "are", + "argue", + "argued", + "argument", + "arguments", + "arise", + "arithmetic", + "arm", + "arms", + "around", + "arranged", + "array", + "arrived", + "arrow", + "arrum", + "as", + "ascii", + "ashamed", + "ask", + "askance", + "asked", + "asking", + "asleep", + "assembled", + "assistance", + "associated", + "at", + "ate", + "atheling", + "atom", + "attached", + "attempt", + "attempted", + "attempts", + "attended", + "attending", + "attends", + "audibly", + "australia", + "author", + "authority", + "available", + "avoid", + "away", + "awfully", + "axes", + "axis", + "b", + "baby", + "back", + "backs", + "bad", + "bag", + "baked", + "balanced", + "bank", + "banks", + "banquet", + "bark", + "barking", + "barley", + "barrowful", + "based", + "bat", + "bathing", + "bats", + "bawled", + "be", + "beak", + "bear", + "beast", + "beasts", + "beat", + "beating", + "beau", + "beauti", + "beautiful", + "beautifully", + "beautify", + "became", + "because", + "become", + "becoming", + "bed", + "beds", + "bee", + "been", + "before", + "beg", + "began", + "begged", + "begin", + "beginning", + "begins", + "begun", + "behead", + "beheaded", + "beheading", + "behind", + "being", + "believe", + "believed", + "bells", + "belong", + "belongs", + "beloved", + "below", + "belt", + "bend", + "bent", + "besides", + "best", + "better", + "between", + "bill", + "binary", + "bird", + "birds", + "birthday", + "bit", + "bite", + "bitter", + "blacking", + "blades", + "blame", + "blasts", + "bleeds", + "blew", + "blow", + "blown", + "blows", + "body", + "boldly", + "bone", + "bones", + "book", + "books", + "boon", + "boots", + "bore", + "both", + "bother", + "bottle", + "bottom", + "bough", + "bound", + "bowed", + "bowing", + "box", + "boxed", + "boy", + "brain", + "branch", + "branches", + "brandy", + "brass", + "brave", + "breach", + "bread", + "break", + "breath", + "breathe", + "breeze", + "bright", + "brightened", + "bring", + "bringing", + "bristling", + "broke", + "broken", + "brother", + "brought", + "brown", + "brush", + "brushing", + "burn", + "burning", + "burnt", + "burst", + "bursting", + "busily", + "business", + "business@pglaf", + "busy", + "but", + "butter", + "buttercup", + "buttered", + "butterfly", + "buttons", + "by", + "bye", + "c", + "cackled", + "cake", + "cakes", + "calculate", + "calculated", + "call", + "called", + "calling", + "calmly", + "came", + "camomile", + "can", + "canary", + "candle", + "cannot", + "canterbury", + "canvas", + "capering", + "capital", + "card", + "cardboard", + "cards", + "care", + "carefully", + "cares", + "carried", + "carrier", + "carroll", + "carry", + "carrying", + "cart", + "cartwheels", + "case", + "cat", + "catch", + "catching", + "caterpillar", + "cats", + "cattle", + "caucus", + "caught", + "cauldron", + "cause", + "caused", + "cautiously", + "cease", + "ceiling", + "centre", + "certain", + "certainly", + "chain", + "chains", + "chair", + "chance", + "chanced", + "change", + "changed", + "changes", + "changing", + "chapter", + "character", + "charge", + "charges", + "charitable", + "charities", + "chatte", + "cheap", + "cheated", + "check", + "checked", + "checks", + "cheeks", + "cheered", + "cheerfully", + "cherry", + "cheshire", + "chief", + "child", + "childhood", + "children", + "chimney", + "chimneys", + "chin", + "choice", + "choke", + "choked", + "choking", + "choose", + "choosing", + "chop", + "chorus", + "chose", + "christmas", + "chrysalis", + "chuckled", + "circle", + "circumstances", + "city", + "civil", + "claim", + "clamour", + "clapping", + "clasped", + "classics", + "claws", + "clean", + "clear", + "cleared", + "clearer", + "clearly", + "clever", + "climb", + "clinging", + "clock", + "close", + "closed", + "closely", + "closer", + "clubs", + "coast", + "coaxing", + "codes", + "coils", + "cold", + "collar", + "collected", + "collection", + "come", + "comes", + "comfits", + "comfort", + "comfortable", + "comfortably", + "coming", + "commercial", + "committed", + "common", + "commotion", + "company", + "compilation", + "complained", + "complaining", + "completely", + "compliance", + "comply", + "complying", + "compressed", + "computer", + "computers", + "concept", + "concerning", + "concert", + "concluded", + "conclusion", + "condemn", + "conduct", + "confirmation", + "confirmed", + "confused", + "confusing", + "confusion", + "conger", + "conqueror", + "conquest", + "consented", + "consequential", + "consider", + "considerable", + "considered", + "considering", + "constant", + "consultation", + "contact", + "contain", + "containing", + "contempt", + "contemptuous", + "contemptuously", + "content", + "continued", + "contract", + "contradicted", + "contributions", + "conversation", + "conversations", + "convert", + "cook", + "cool", + "copied", + "copies", + "copy", + "copying", + "copyright", + "corner", + "corners", + "corporation", + "corrupt", + "cost", + "costs", + "could", + "couldn", + "counting", + "countries", + "country", + "couple", + "couples", + "courage", + "course", + "court", + "courtiers", + "coward", + "crab", + "crash", + "crashed", + "crawled", + "crawling", + "crazy", + "created", + "creating", + "creation", + "creature", + "creatures", + "credit", + "creep", + "crept", + "cried", + "cries", + "crimson", + "critical", + "crocodile", + "croquet", + "croqueted", + "croqueting", + "cross", + "crossed", + "crossly", + "crouched", + "crowd", + "crowded", + "crown", + "crumbs", + "crust", + "cry", + "crying", + "cucumber", + "cunning", + "cup", + "cupboards", + "cur", + "curiosity", + "curious", + "curiouser", + "curled", + "curls", + "curly", + "currants", + "current", + "curtain", + "curtsey", + "curtseying", + "curving", + "cushion", + "custard", + "custody", + "cut", + "cutting", + }; + } + private void RunTest(Action test) + { + using (UnitTestApplication.Start(Services)) + { + AutoCompleteBox control = CreateControl(); + control.Items = CreateSimpleStringArray(); + TextBox textBox = GetTextBox(control); + Dispatcher.UIThread.RunJobs(); + test.Invoke(control, textBox); + } + } + + private static TestServices Services => TestServices.StyledWindow; + + /*private static TestServices Services => TestServices.MockThreadingInterface.With( + standardCursorFactory: Mock.Of(), + windowingPlatform: new MockWindowingPlatform());*/ + + private AutoCompleteBox CreateControl() + { + var datePicker = + new AutoCompleteBox + { + Template = CreateTemplate() + }; + + datePicker.ApplyTemplate(); + return datePicker; + } + private TextBox GetTextBox(AutoCompleteBox control) + { + return control.GetTemplateChildren() + .OfType() + .First(); + } + private IControlTemplate CreateTemplate() + { + return new FuncControlTemplate(control => + { + var textBox = + new TextBox + { + Name = "PART_TextBox" + }; + var listbox = + new ListBox + { + Name = "PART_SelectingItemsControl" + }; + var popup = + new Popup + { + Name = "PART_Popup" + }; + + var panel = new Panel(); + panel.Children.Add(textBox); + panel.Children.Add(popup); + panel.Children.Add(listbox); + + return panel; + }); + } + } +} From bac877cd91a4b7d487a3bde7160af68aced45c89 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:41:38 -0400 Subject: [PATCH 16/54] Fixed a property name mistake in GradientBrush --- src/Avalonia.Visuals/Media/GradientBrush.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Visuals/Media/GradientBrush.cs b/src/Avalonia.Visuals/Media/GradientBrush.cs index 8c2c9a2c01..fb29f68373 100644 --- a/src/Avalonia.Visuals/Media/GradientBrush.cs +++ b/src/Avalonia.Visuals/Media/GradientBrush.cs @@ -22,7 +22,7 @@ namespace Avalonia.Media /// Defines the property. /// public static readonly StyledProperty> GradientStopsProperty = - AvaloniaProperty.Register>(nameof(Opacity)); + AvaloniaProperty.Register>(nameof(GradientStops)); /// /// Initializes a new instance of the class. From 242b9251df78cb16f67320fa358d22d0e4b88866 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:42:27 -0400 Subject: [PATCH 17/54] Added async population feature --- .../Pages/AutoCompleteBoxPage.xaml | 8 ++- .../Pages/AutoCompleteBoxPage.xaml.cs | 18 +++++ src/Avalonia.Controls/AutoCompleteBox.cs | 70 +++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml index 491f41ecbf..943fadf100 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -8,7 +8,6 @@ HorizontalAlignment="Center" Gap="8"> - + + + diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs index 9f181d44f2..6f3b8361cd 100644 --- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -6,6 +6,8 @@ using Avalonia.Markup.Xaml.Data; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace ControlCatalog.Pages { @@ -109,6 +111,9 @@ namespace ControlCatalog.Pages var multibindingBox = this.FindControl("MultiBindingBox"); multibindingBox.ValueMemberBinding = binding; + + var asyncBox = this.FindControl("AsyncBox"); + asyncBox.AsyncPopulator = PopulateAsync; } private IEnumerable GetAllAutoCompleteBox() { @@ -117,6 +122,19 @@ namespace ControlCatalog.Pages .OfType(); } + private bool StringContains(string str, string query) + { + return str.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0; + } + private async Task> PopulateAsync(string searchText, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(1.5), cancellationToken); + + return + States.Where(data => StringContains(data.Name, searchText) || StringContains(data.Capital, searchText)) + .ToList(); + } + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 0e8b93146c..e4962dc069 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -23,6 +23,8 @@ using Avalonia.Utilities; using System.Globalization; using System.Collections.Specialized; using System.Reactive.Disposables; +using System.Threading; +using System.Threading.Tasks; namespace Avalonia.Controls { @@ -360,6 +362,8 @@ namespace Avalonia.Controls private IDisposable _collectionChangeSubscription; private IMemberSelector _valueMemberSelector; + private Func>> _asyncPopulator; + private CancellationTokenSource _populationCancellationTokenSource; private bool _itemTemplateIsFromValueMemeberBinding = true; private bool _settingItemTemplateFromValueMemeberBinding; @@ -559,6 +563,12 @@ namespace Avalonia.Controls o => o.ValueMemberSelector, (o, v) => o.ValueMemberSelector = v); + public static readonly DirectProperty>>> AsyncPopulatorProperty = + AvaloniaProperty.RegisterDirect>>>( + nameof(AsyncPopulator), + o => o.AsyncPopulator, + (o, v) => o.AsyncPopulator = v); + private static int ValidateMinimumPrefixLength(AutoCompleteBox control, int value) { Contract.Requires(value >= -1); @@ -1107,6 +1117,12 @@ namespace Avalonia.Controls set { SetAndRaise(TextFilterProperty, ref _textFilter, value); } } + public Func>> AsyncPopulator + { + get { return _asyncPopulator; } + set { SetAndRaise(AsyncPopulatorProperty, ref _asyncPopulator, value); } + } + /// /// Gets or sets a collection that is used to generate the items for the /// drop-down portion of the @@ -1702,6 +1718,11 @@ namespace Avalonia.Controls // 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 @@ -1713,6 +1734,55 @@ namespace Avalonia.Controls PopulateComplete(); } } + private bool TryPopulateAsync(string searchText) + { + _populationCancellationTokenSource?.Cancel(false); + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + + if(_asyncPopulator == null) + { + return false; + } + + _populationCancellationTokenSource = new CancellationTokenSource(); + var task = PopulateAsync(searchText, _populationCancellationTokenSource.Token); + if (task.Status == TaskStatus.Created) + task.Start(); + + return true; + } + private async Task PopulateAsync(string searchText, CancellationToken cancellationToken) + { + + try + { + IEnumerable result = await _asyncPopulator.Invoke(searchText, cancellationToken); + var resultList = result.ToList(); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (!cancellationToken.IsCancellationRequested) + { + Items = resultList; + PopulateComplete(); + } + }); + } + catch (TaskCanceledException) + { } + finally + { + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + } + + } /// /// Private method that directly opens the popup, checks the expander From 2eafd111ecb51f1dde432d1a25ec7c657df119a9 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Tue, 13 Mar 2018 21:47:13 -0400 Subject: [PATCH 18/54] Moved CancelableEventArgs to Avalonia.Interactivity --- src/Avalonia.Controls/AutoCompleteBox.cs | 9 --------- .../CancelableEventArgs.cs | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 src/Avalonia.Interactivity/CancelableEventArgs.cs diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index e4962dc069..730c1e895e 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -28,15 +28,6 @@ using System.Threading.Tasks; namespace Avalonia.Controls { - public class CancelableEventArgs : EventArgs - { - public bool Cancel { get; set; } - - public CancelableEventArgs() - : base() - { } - } - /// /// Provides data for the /// diff --git a/src/Avalonia.Interactivity/CancelableEventArgs.cs b/src/Avalonia.Interactivity/CancelableEventArgs.cs new file mode 100644 index 0000000000..13ff9239f1 --- /dev/null +++ b/src/Avalonia.Interactivity/CancelableEventArgs.cs @@ -0,0 +1,16 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Interactivity +{ + public class CancelableEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public CancelableEventArgs() + : base() + { } + } +} From e52f966877e941f6adfafe426c57f6cbca85d1a3 Mon Sep 17 00:00:00 2001 From: sdoroff Date: Mon, 19 Mar 2018 17:31:42 -0400 Subject: [PATCH 19/54] Made use of existing CancelEventArgs Uses System.ComponentModel.CancelEventArgs instead of custom CancelableEventArgs class --- src/Avalonia.Controls/AutoCompleteBox.cs | 44 +++++++++---------- .../CancelableEventArgs.cs | 16 ------- 2 files changed, 20 insertions(+), 40 deletions(-) delete mode 100644 src/Avalonia.Interactivity/CancelableEventArgs.cs diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 730c1e895e..f1854cf8fb 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -3,28 +3,24 @@ // Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Collections; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; -using Avalonia.Media; -using Avalonia.Collections; -using System; -using System.Linq; -using System.Collections.Generic; -using System.Collections; -using System.Collections.ObjectModel; -using System.Diagnostics; using Avalonia.Threading; -using Avalonia.Controls.Templates; using Avalonia.VisualTree; -using Avalonia.Utilities; -using System.Globalization; -using System.Collections.Specialized; -using System.Reactive.Disposables; -using System.Threading; -using System.Threading.Tasks; namespace Avalonia.Controls { @@ -63,7 +59,7 @@ namespace Avalonia.Controls /// event. /// /// Stable - public class PopulatingEventArgs : CancelableEventArgs + public class PopulatingEventArgs : CancelEventArgs { /// /// Gets the text that is used to determine which items to display in @@ -1490,7 +1486,7 @@ namespace Avalonia.Controls /// /// property is changing from false to true. /// - public event EventHandler DropDownOpening; + public event EventHandler DropDownOpening; /// /// Occurs when the value of the @@ -1504,7 +1500,7 @@ namespace Avalonia.Controls /// /// property is changing from true to false. /// - public event EventHandler DropDownClosing; + public event EventHandler DropDownClosing; /// /// Occurs when the @@ -1569,9 +1565,9 @@ namespace Avalonia.Controls /// event. /// /// A - /// + /// /// that contains the event data. - protected virtual void OnDropDownOpening(CancelableEventArgs e) + protected virtual void OnDropDownOpening(CancelEventArgs e) { DropDownOpening?.Invoke(this, e); } @@ -1595,9 +1591,9 @@ namespace Avalonia.Controls /// event. /// /// A - /// + /// /// that contains the event data. - protected virtual void OnDropDownClosing(CancelableEventArgs e) + protected virtual void OnDropDownClosing(CancelEventArgs e) { DropDownClosing?.Invoke(this, e); } @@ -1633,7 +1629,7 @@ namespace Avalonia.Controls /// The original value. private void ClosingDropDown(bool oldValue) { - var args = new CancelableEventArgs(); + var args = new CancelEventArgs(); OnDropDownClosing(args); if (args.Cancel) @@ -1656,7 +1652,7 @@ namespace Avalonia.Controls /// The original value, if needed for a revert. private void OpeningDropDown(bool oldValue) { - var args = new CancelableEventArgs(); + var args = new CancelEventArgs(); // Opening OnDropDownOpening(args); diff --git a/src/Avalonia.Interactivity/CancelableEventArgs.cs b/src/Avalonia.Interactivity/CancelableEventArgs.cs deleted file mode 100644 index 13ff9239f1..0000000000 --- a/src/Avalonia.Interactivity/CancelableEventArgs.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; - -namespace Avalonia.Interactivity -{ - public class CancelableEventArgs : EventArgs - { - public bool Cancel { get; set; } - - public CancelableEventArgs() - : base() - { } - } -} From a3cdb6c1edd7927dc933148ecc481f60bd0e505a Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 12:36:28 +0300 Subject: [PATCH 20/54] Update buttons of ButtonSpinner on ValidSpinDirections changes --- src/Avalonia.Controls/ButtonSpinner.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Avalonia.Controls/ButtonSpinner.cs b/src/Avalonia.Controls/ButtonSpinner.cs index 3ce81e0b12..866237ecce 100644 --- a/src/Avalonia.Controls/ButtonSpinner.cs +++ b/src/Avalonia.Controls/ButtonSpinner.cs @@ -201,6 +201,11 @@ namespace Avalonia.Controls } } + protected override void OnValidSpinDirectionChanged(ValidSpinDirections oldValue, ValidSpinDirections newValue) + { + SetButtonUsage(); + } + /// /// Called when the property value changed. /// From 0953673b763e4d479ddf864b17f09207f32fe2d3 Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 13:57:55 +0300 Subject: [PATCH 21/54] Added UpDownBase class --- .../NumericUpDown/UpDownBase.cs | 824 ++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 src/Avalonia.Controls/NumericUpDown/UpDownBase.cs diff --git a/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs b/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs new file mode 100644 index 0000000000..fa95eb01a7 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs @@ -0,0 +1,824 @@ +using System; +using System.Globalization; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace Avalonia.Controls +{ + public abstract class UpDownBase : TemplatedControl + { + } + + /// + /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing values. + /// + public abstract class UpDownBase : UpDownBase + { + /// + /// Defines the property. + /// + public static readonly StyledProperty AllowSpinProperty = + ButtonSpinner.AllowSpinProperty.AddOwner>(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ButtonSpinnerLocationProperty = + ButtonSpinner.ButtonSpinnerLocationProperty.AddOwner>(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty ShowButtonSpinnerProperty = + ButtonSpinner.ShowButtonSpinnerProperty.AddOwner>(); + + /// + /// Defines the property. + /// + public static readonly DirectProperty, bool> ClipValueToMinMaxProperty = + AvaloniaProperty.RegisterDirect, bool>(nameof(ClipValueToMinMax), + updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b); + + /// + /// Defines the property. + /// + public static readonly DirectProperty, CultureInfo> CultureInfoProperty = + AvaloniaProperty.RegisterDirect, CultureInfo>(nameof(CultureInfo), o => o.CultureInfo, + (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture); + + /// + /// Defines the property. + /// + public static readonly StyledProperty DefaultValueProperty = + AvaloniaProperty.Register, T>(nameof(DefaultValue)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty DisplayDefaultValueOnEmptyTextProperty = + AvaloniaProperty.Register, bool>(nameof(DisplayDefaultValueOnEmptyText)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register, bool>(nameof(IsReadOnly)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register, T>(nameof(Maximum), validate: OnCoerceMaximum); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register, T>(nameof(Minimum), validate: OnCoerceMinimum); + + /// + /// Defines the property. + /// + public static readonly DirectProperty, string> TextProperty = + AvaloniaProperty.RegisterDirect, string>(nameof(Text), o => o.Text, (o, v) => o.Text = v, + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly DirectProperty, T> ValueProperty = + AvaloniaProperty.RegisterDirect, T>(nameof(Value), updown => updown.Value, + (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty WatermarkProperty = + AvaloniaProperty.Register, string>(nameof(Watermark)); + + private IDisposable _textBoxTextChangedSubscription; + private T _value; + private string _text; + private bool _internalValueSet; + private bool _clipValueToMinMax; + private bool _isSyncingTextAndValueProperties; + private bool _isTextChangedFromUI; + private CultureInfo _cultureInfo; + + /// + /// Gets the Spinner template part. + /// + protected Spinner Spinner { get; private set; } + + /// + /// Gets the TextBox template part. + /// + protected TextBox TextBox { get; private set; } + + /// + /// Gets or sets the ability to perform increment/decrement operations via the keyboard, button spinners, or mouse wheel. + /// + public bool AllowSpin + { + get { return GetValue(AllowSpinProperty); } + set { SetValue(AllowSpinProperty, value); } + } + + /// + /// Gets or sets current location of the . + /// + public Location ButtonSpinnerLocation + { + get { return GetValue(ButtonSpinnerLocationProperty); } + set { SetValue(ButtonSpinnerLocationProperty, value); } + } + + /// + /// Gets or sets a value indicating whether the spin buttons should be shown. + /// + public bool ShowButtonSpinner + { + get { return GetValue(ShowButtonSpinnerProperty); } + set { SetValue(ShowButtonSpinnerProperty, value); } + } + + /// + /// Gets or sets if the value should be clipped when minimum/maximum is reached. + /// + public bool ClipValueToMinMax + { + get { return _clipValueToMinMax; } + set { SetAndRaise(ClipValueToMinMaxProperty, ref _clipValueToMinMax, value); } + } + + /// + /// Gets or sets the current CultureInfo. + /// + public CultureInfo CultureInfo + { + get { return _cultureInfo; } + set { SetAndRaise(CultureInfoProperty, ref _cultureInfo, value); } + } + + /// + /// Gets or sets the value to use when the is null and an increment/decrement operation is performed. + /// + public T DefaultValue + { + get { return GetValue(DefaultValueProperty); } + set { SetValue(DefaultValueProperty, value); } + } + + /// + /// Gets or sets if the defaultValue should be displayed when the Text is empty. + /// + public bool DisplayDefaultValueOnEmptyText + { + get { return GetValue(DisplayDefaultValueOnEmptyTextProperty); } + set { SetValue(DisplayDefaultValueOnEmptyTextProperty, value); } + } + + /// + /// Gets or sets if the control is read only. + /// + public bool IsReadOnly + { + get { return GetValue(IsReadOnlyProperty); } + set { SetValue(IsReadOnlyProperty, value); } + } + + /// + /// Gets or sets the maximum allowed value. + /// + public T Maximum + { + get { return GetValue(MaximumProperty); } + set { SetValue(MaximumProperty, value); } + } + + /// + /// Gets or sets the minimum allowed value. + /// + public T Minimum + { + get { return GetValue(MinimumProperty); } + set { SetValue(MinimumProperty, value); } + } + + /// + /// Gets or sets the formatted string representation of the value. + /// + public string Text + { + get { return _text; } + set { SetAndRaise(TextProperty, ref _text, value); } + } + + /// + /// Gets or sets the value. + /// + public T Value + { + get { return _value; } + set + { + value = OnCoerceValue(value); + SetAndRaise(ValueProperty, ref _value, value); + } + } + + /// + /// Gets or sets the object to use as a watermark if the is null. + /// + public string Watermark + { + get { return GetValue(WatermarkProperty); } + set { SetValue(WatermarkProperty, value); } + } + + /// + /// Initializes new instance of class. + /// + protected UpDownBase() + { + Initialized += (sender, e) => + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + }; + } + + /// + /// Initializes static members of the class. + /// + static UpDownBase() + { + CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged); + DefaultValueProperty.Changed.Subscribe(OnDefaultValueChanged); + DisplayDefaultValueOnEmptyTextProperty.Changed.Subscribe(OnDisplayDefaultValueOnEmptyTextChanged); + IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged); + MaximumProperty.Changed.Subscribe(OnMaximumChanged); + MinimumProperty.Changed.Subscribe(OnMinimumChanged); + TextProperty.Changed.Subscribe(OnTextChanged); + ValueProperty.Changed.Subscribe(OnValueChanged); + } + + /// + protected override void OnTemplateApplied(TemplateAppliedEventArgs e) + { + if (TextBox != null) + { + TextBox.PointerPressed -= TextBoxOnPointerPressed; + _textBoxTextChangedSubscription?.Dispose(); + } + TextBox = e.NameScope.Find("PART_TextBox"); + if (TextBox != null) + { + TextBox.Text = Text; + TextBox.PointerPressed += TextBoxOnPointerPressed; + _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged()); + } + + if (Spinner != null) + { + Spinner.Spin -= OnSpinnerSpin; + } + + Spinner = e.NameScope.Find("PART_Spinner"); + + if (Spinner != null) + { + Spinner.Spin += OnSpinnerSpin; + } + + SetValidSpinDirection(); + } + + /// + protected override void OnKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + var commitSuccess = CommitInput(); + e.Handled = !commitSuccess; + break; + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnCultureInfoChanged(CultureInfo oldValue, CultureInfo newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(false, null); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnDefaultValueChanged(T oldValue, T newValue) + { + if (IsInitialized && string.IsNullOrEmpty(Text)) + { + SyncTextAndValueProperties(true, Text); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnDisplayDefaultValueOnEmptyTextChanged(bool oldValue, bool newValue) + { + if (IsInitialized && string.IsNullOrEmpty(Text)) + { + SyncTextAndValueProperties(false, Text); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnIsReadOnlyChanged(bool oldValue, bool newValue) + { + SetValidSpinDirection(); + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnMaximumChanged(T oldValue, T newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnMinimumChanged(T oldValue, T newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnTextChanged(string oldValue, string newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(true, Text); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnValueChanged(T oldValue, T newValue) + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + + RaiseValueChangedEvent(oldValue, newValue); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual T OnCoerceMaximum(T baseValue) + { + return baseValue; + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual T OnCoerceMinimum(T baseValue) + { + return baseValue; + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual T OnCoerceValue(T baseValue) + { + return baseValue; + } + + /// + /// Raises the OnSpin event when spinning is initiated by the end-user. + /// + /// The event args. + protected virtual void OnSpin(SpinEventArgs e) + { + if (e == null) + { + throw new ArgumentNullException("e"); + } + + var handler = Spinned; + handler?.Invoke(this, e); + + if (e.Direction == SpinDirection.Increase) + { + DoIncrement(); + } + else + { + DoDecrement(); + } + } + + /// + /// Raises the event. + /// + /// The old value. + /// The new value. + protected virtual void RaiseValueChangedEvent(T oldValue, T newValue) + { + var e = new UpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue); + RaiseEvent(e); + } + + /// + /// Converts the formatted text to a value. + /// + protected abstract T ConvertTextToValue(string text); + + /// + /// Converts the value to formatted text. + /// + /// + protected abstract string ConvertValueToText(); + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Increase. + /// + protected abstract void OnIncrement(); + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Descrease. + /// + protected abstract void OnDecrement(); + + /// + /// Sets the valid spin directions. + /// + protected abstract void SetValidSpinDirection(); + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (CultureInfo)e.OldValue; + var newValue = (CultureInfo)e.NewValue; + upDown.OnCultureInfoChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnDefaultValueChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (T)e.OldValue; + var newValue = (T)e.NewValue; + upDown.OnDefaultValueChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnDisplayDefaultValueOnEmptyTextChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (bool) e.OldValue; + var newValue = (bool) e.NewValue; + upDown.OnDisplayDefaultValueOnEmptyTextChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (bool)e.OldValue; + var newValue = (bool)e.NewValue; + upDown.OnIsReadOnlyChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnMaximumChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (T)e.OldValue; + var newValue = (T)e.NewValue; + upDown.OnMaximumChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnMinimumChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (T)e.OldValue; + var newValue = (T)e.NewValue; + upDown.OnMinimumChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnTextChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (string)e.OldValue; + var newValue = (string)e.NewValue; + upDown.OnTextChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnValueChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is UpDownBase upDown) + { + var oldValue = (T)e.OldValue; + var newValue = (T)e.NewValue; + upDown.OnValueChanged(oldValue, newValue); + } + } + + private void SetValueInternal(T value) + { + _internalValueSet = true; + try + { + Value = value; + } + finally + { + _internalValueSet = false; + } + } + + private static T OnCoerceMaximum(UpDownBase upDown, T value) + { + return upDown.OnCoerceMaximum(value); + } + + private static T OnCoerceMinimum(UpDownBase upDown, T value) + { + return upDown.OnCoerceMinimum(value); + } + + private void TextBoxOnTextChanged() + { + try + { + _isTextChangedFromUI = true; + if (TextBox != null) + { + Text = TextBox.Text; + } + } + finally + { + _isTextChangedFromUI = false; + } + } + + private void OnSpinnerSpin(object sender, SpinEventArgs e) + { + if (AllowSpin && !IsReadOnly) + { + var spin = !e.UsingMouseWheel; + spin |= ((TextBox != null) && TextBox.IsFocused); + + if (spin) + { + e.Handled = true; + OnSpin(e); + } + } + } + + internal void DoDecrement() + { + if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease) + { + OnDecrement(); + } + } + + internal void DoIncrement() + { + if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Increase) == ValidSpinDirections.Increase) + { + OnIncrement(); + } + } + + public event EventHandler Spinned; + + private void TextBoxOnPointerPressed(object sender, PointerPressedEventArgs e) + { + if (e.Device.Captured != Spinner) + { + Dispatcher.UIThread.InvokeAsync(() => { e.Device.Capture(Spinner); }, DispatcherPriority.Input); + } + } + + /// + /// Defines the event. + /// + public static readonly RoutedEvent> ValueChangedEvent = + RoutedEvent.Register, UpDownValueChangedEventArgs>(nameof(ValueChanged), RoutingStrategies.Bubble); + + /// + /// Raised when the changes. + /// + public event EventHandler ValueChanged + { + add { AddHandler(ValueChangedEvent, value); } + remove { RemoveHandler(ValueChangedEvent, value); } + } + + private bool CommitInput() + { + return SyncTextAndValueProperties(true, Text); + } + + /// + /// Synchronize and properties. + /// + /// If value should be updated from text. + /// The text. + protected bool SyncTextAndValueProperties(bool updateValueFromText, string text) + { + return SyncTextAndValueProperties(updateValueFromText, text, false); + } + + /// + /// Synchronize and properties. + /// + /// If value should be updated from text. + /// The text. + /// Force text update. + private bool SyncTextAndValueProperties(bool updateValueFromText, string text, bool forceTextUpdate) + { + if (_isSyncingTextAndValueProperties) + return true; + + _isSyncingTextAndValueProperties = true; + var parsedTextIsValid = true; + try + { + if (updateValueFromText) + { + if (string.IsNullOrEmpty(text)) + { + // An empty input sets the value to the default value. + SetValueInternal(DefaultValue); + } + else + { + try + { + var newValue = ConvertTextToValue(text); + if (!Equals(newValue, Value)) + { + SetValueInternal(newValue); + } + } + catch + { + parsedTextIsValid = false; + } + } + } + + // Do not touch the ongoing text input from user. + if (!_isTextChangedFromUI) + { + // Don't replace the empty Text with the non-empty representation of DefaultValue. + var shouldKeepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text) && Equals(Value, DefaultValue) && !DisplayDefaultValueOnEmptyText; + if (!shouldKeepEmpty) + { + var newText = ConvertValueToText(); + if (!Equals(Text, newText)) + { + Text = newText; + } + } + + // Sync Text and textBox + if (TextBox != null) + { + TextBox.Text = Text; + } + } + + if (_isTextChangedFromUI && !parsedTextIsValid) + { + // Text input was made from the user and the text + // repesents an invalid value. Disable the spinner in this case. + if (Spinner != null) + { + Spinner.ValidSpinDirection = ValidSpinDirections.None; + } + } + else + { + SetValidSpinDirection(); + } + } + finally + { + _isSyncingTextAndValueProperties = false; + } + return parsedTextIsValid; + } + } + + public class UpDownValueChangedEventArgs : RoutedEventArgs + { + public UpDownValueChangedEventArgs(RoutedEvent routedEvent, T oldValue, T newValue) : base(routedEvent) + { + OldValue = oldValue; + NewValue = newValue; + } + + public T OldValue { get; } + public T NewValue { get; } + } +} From d07ceec674d1a555233cc417fe35394794326942 Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 14:11:24 +0300 Subject: [PATCH 22/54] Added NumericUpDown class. --- .../NumericUpDown/NumericUpDown.cs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs new file mode 100644 index 0000000000..402dec7284 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -0,0 +1,132 @@ +using System; +using System.Globalization; + +namespace Avalonia.Controls +{ + /// + /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. + /// + public abstract class NumericUpDown : UpDownBase + { + /// + /// Defines the property. + /// + public static readonly StyledProperty FormatStringProperty = + AvaloniaProperty.Register, string>(nameof(FormatString), string.Empty); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IncrementProperty = + AvaloniaProperty.Register, T>(nameof(Increment), default(T), validate: OnCoerceIncrement); + + /// + /// Initializes static members of the class. + /// + static NumericUpDown() + { + FormatStringProperty.Changed.Subscribe(FormatStringChanged); + IncrementProperty.Changed.Subscribe(IncrementChanged); + } + + /// + /// Gets or sets the display format of the . + /// + public string FormatString + { + get { return GetValue(FormatStringProperty); } + set { SetValue(FormatStringProperty, value); } + } + + /// + /// Gets or sets the amount in which to increment the . + /// + public T Increment + { + get { return GetValue(IncrementProperty); } + set { SetValue(IncrementProperty, value); } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnFormatStringChanged(string oldValue, string newValue) + { + if (IsInitialized) + { + SyncTextAndValueProperties(false, null); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnIncrementChanged(T oldValue, T newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual T OnCoerceIncrement(T baseValue) + { + return baseValue; + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void IncrementChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (T)e.OldValue; + var newValue = (T)e.NewValue; + upDown.OnIncrementChanged(oldValue, newValue); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void FormatStringChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (string) e.OldValue; + var newValue = (string) e.NewValue; + upDown.OnFormatStringChanged(oldValue, newValue); + } + } + + private static T OnCoerceIncrement(NumericUpDown numericUpDown, T value) + { + return numericUpDown.OnCoerceIncrement(value); + } + + /// + /// Parse percent format text + /// + /// Text to parse. + /// The culture info. + protected static decimal ParsePercent(string text, IFormatProvider cultureInfo) + { + var info = NumberFormatInfo.GetInstance(cultureInfo); + text = text.Replace(info.PercentSymbol, null); + var result = decimal.Parse(text, NumberStyles.Any, info); + result = result / 100; + return result; + } + } +} \ No newline at end of file From 272c0c882fa6c53b3b98fc9fb2af6db10bfd2ca2 Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 14:54:42 +0300 Subject: [PATCH 23/54] Added CommonNumericUpDown class. --- .../NumericUpDown/CommonNumericUpDown.cs | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs diff --git a/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs new file mode 100644 index 0000000000..1ce2508b4a --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs @@ -0,0 +1,346 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace Avalonia.Controls +{ + /// + /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. + /// + public abstract class CommonNumericUpDown : NumericUpDown where T : struct, IFormattable, IComparable + { + protected delegate bool FromText(string s, NumberStyles style, IFormatProvider provider, out T result); + protected delegate T FromDecimal(decimal d); + + private readonly FromText _fromText; + private readonly FromDecimal _fromDecimal; + private readonly Func _fromLowerThan; + private readonly Func _fromGreaterThan; + + private NumberStyles _parsingNumberStyle = NumberStyles.Any; + + /// + /// Defines the property. + /// + public static readonly DirectProperty, NumberStyles> ParsingNumberStyleProperty = + AvaloniaProperty.RegisterDirect, NumberStyles>(nameof(ParsingNumberStyle), + updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style); + + + /// + /// Initializes new instance of the class. + /// + /// Delegate to parse value from text. + /// Delegate to parse value from decimal. + /// Delegate to compare if one value is lower than another. + /// Delegate to compare if one value is greater than another. + protected CommonNumericUpDown(FromText fromText, FromDecimal fromDecimal, Func fromLowerThan, Func fromGreaterThan) + { + _fromText = fromText ?? throw new ArgumentNullException(nameof(fromText)); + _fromDecimal = fromDecimal ?? throw new ArgumentNullException(nameof(fromDecimal)); + _fromLowerThan = fromLowerThan ?? throw new ArgumentNullException(nameof(fromLowerThan)); + _fromGreaterThan = fromGreaterThan ?? throw new ArgumentNullException(nameof(fromGreaterThan)); + } + + /// + /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. + /// + public NumberStyles ParsingNumberStyle + { + get { return _parsingNumberStyle; } + set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); } + } + + /// + protected override void OnIncrement() + { + if (!HandleNullSpin()) + { + var result = IncrementValue(Value.Value, Increment.Value); + Value = CoerceValueMinMax(result); + } + } + + /// + protected override void OnDecrement() + { + if (!HandleNullSpin()) + { + var result = DecrementValue(Value.Value, Increment.Value); + Value = CoerceValueMinMax(result); + } + } + + /// + protected override void OnMinimumChanged(T? oldValue, T? newValue) + { + base.OnMinimumChanged(oldValue, newValue); + + if (Value.HasValue && ClipValueToMinMax) + { + Value = CoerceValueMinMax(Value.Value); + } + } + + /// + protected override void OnMaximumChanged(T? oldValue, T? newValue) + { + base.OnMaximumChanged(oldValue, newValue); + + if (Value.HasValue && ClipValueToMinMax) + { + Value = CoerceValueMinMax(Value.Value); + } + } + + /// + protected override T? ConvertTextToValue(string text) + { + T? result = null; + + if (string.IsNullOrEmpty(text)) + { + return result; + } + + // Since the conversion from Value to text using a FormartString may not be parsable, + // we verify that the already existing text is not the exact same value. + var currentValueText = ConvertValueToText(); + if (Equals(currentValueText, text)) + { + return Value; + } + + result = ConvertTextToValueCore(currentValueText, text); + + if (ClipValueToMinMax) + { + return GetClippedMinMaxValue(result); + } + + ValidateDefaultMinMax(result); + + return result; + } + + /// + protected override string ConvertValueToText() + { + if (Value == null) + { + return string.Empty; + } + + //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. + if (FormatString.Contains("{0")) + { + return string.Format(CultureInfo, FormatString, Value.Value); + } + + return Value.Value.ToString(FormatString, CultureInfo); + } + + /// + protected override void SetValidSpinDirection() + { + var validDirections = ValidSpinDirections.None; + + // Null increment always prevents spin. + if (Increment != null && !IsReadOnly) + { + if (IsLowerThan(Value, Maximum) || !Value.HasValue || !Maximum.HasValue) + { + validDirections = validDirections | ValidSpinDirections.Increase; + } + + if (IsGreaterThan(Value, Minimum) || !Value.HasValue || !Minimum.HasValue) + { + validDirections = validDirections | ValidSpinDirections.Decrease; + } + } + + if (Spinner != null) + { + Spinner.ValidSpinDirection = validDirections; + } + } + + /// + /// Checks if provided value is within allowed values. + /// + /// The alowed values. + /// The value to check. + protected void TestInputSpecialValue(AllowedSpecialValues allowedValues, AllowedSpecialValues valueToCompare) + { + if ((allowedValues & valueToCompare) != valueToCompare) + { + switch (valueToCompare) + { + case AllowedSpecialValues.NaN: + throw new InvalidDataException("Value to parse shouldn't be NaN."); + case AllowedSpecialValues.PositiveInfinity: + throw new InvalidDataException("Value to parse shouldn't be Positive Infinity."); + case AllowedSpecialValues.NegativeInfinity: + throw new InvalidDataException("Value to parse shouldn't be Negative Infinity."); + } + } + } + + protected static void UpdateMetadata(Type type, T? increment, T? minimun, T? maximum) + { + IncrementProperty.OverrideDefaultValue(type, increment); + MinimumProperty.OverrideDefaultValue(type, minimun); + MaximumProperty.OverrideDefaultValue(type, maximum); + } + + protected abstract T IncrementValue(T value, T increment); + + protected abstract T DecrementValue(T value, T increment); + + private bool IsLowerThan(T? value1, T? value2) + { + if (value1 == null || value2 == null) + { + return false; + } + return _fromLowerThan(value1.Value, value2.Value); + } + + private bool IsGreaterThan(T? value1, T? value2) + { + if (value1 == null || value2 == null) + { + return false; + } + return _fromGreaterThan(value1.Value, value2.Value); + } + + private bool HandleNullSpin() + { + if (!Value.HasValue) + { + var forcedValue = DefaultValue ?? default(T); + Value = CoerceValueMinMax(forcedValue); + return true; + } + else if (!Increment.HasValue) + { + return true; + } + return false; + } + + internal bool IsValid(T? value) + { + return !IsLowerThan(value, Minimum) && !IsGreaterThan(value, Maximum); + } + + private T? CoerceValueMinMax(T value) + { + if (IsLowerThan(value, Minimum)) + { + return Minimum; + } + else if (IsGreaterThan(value, Maximum)) + { + return Maximum; + } + else + { + return value; + } + } + + private bool IsPercent(string stringToTest) + { + var PIndex = stringToTest.IndexOf("P", StringComparison.Ordinal); + if (PIndex >= 0) + { + //stringToTest contains a "P" between 2 "'", it's considered as text, not percent + var isText = stringToTest.Substring(0, PIndex).Contains("'") + && stringToTest.Substring(PIndex, FormatString.Length - PIndex).Contains("'"); + + return !isText; + } + return false; + } + + private T? ConvertTextToValueCore(string currentValueText, string text) + { + T? result; + + if (IsPercent(FormatString)) + { + result = _fromDecimal(ParsePercent(text, CultureInfo)); + } + else + { + // Problem while converting new text + if (!_fromText(text, ParsingNumberStyle, CultureInfo, out T outputValue)) + { + var shouldThrow = true; + + // Check if CurrentValueText is also failing => it also contains special characters. ex : 90° + if (!_fromText(currentValueText, ParsingNumberStyle, CultureInfo, out T _)) + { + // extract non-digit characters + var currentValueTextSpecialCharacters = currentValueText.Where(c => !char.IsDigit(c)); + var textSpecialCharacters = text.Where(c => !char.IsDigit(c)); + // same non-digit characters on currentValueText and new text => remove them on new Text to parse it again. + if (currentValueTextSpecialCharacters.Except(textSpecialCharacters).ToList().Count == 0) + { + foreach (var character in textSpecialCharacters) + { + text = text.Replace(character.ToString(), string.Empty); + } + // if without the special characters, parsing is good, do not throw + if (_fromText(text, ParsingNumberStyle, CultureInfo, out outputValue)) + { + shouldThrow = false; + } + } + } + + if (shouldThrow) + { + throw new InvalidDataException("Input string was not in a correct format."); + } + } + result = outputValue; + } + return result; + } + + private T? GetClippedMinMaxValue(T? result) + { + if (IsGreaterThan(result, Maximum)) + { + return Maximum; + } + else if (IsLowerThan(result, Minimum)) + { + return Minimum; + } + return result; + } + + private void ValidateDefaultMinMax(T? value) + { + // DefaultValue is always accepted. + if (Equals(value, DefaultValue)) + { + return; + } + + if (IsLowerThan(value, Minimum)) + { + throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum)); + } + else if (IsGreaterThan(value, Maximum)) + { + throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum)); + } + } + } +} \ No newline at end of file From c1ed2b3b280b4341139796b2c7ec558f888293fa Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 15:14:53 +0300 Subject: [PATCH 24/54] Added concrete implementations for various numeric types --- .../NumericUpDown/AllowedSpecialValues.cs | 15 +++ .../NumericUpDown/ByteUpDown.cs | 24 ++++ .../NumericUpDown/CommonNumericUpDown.cs | 2 +- .../NumericUpDown/DecimalUpDown.cs | 24 ++++ .../NumericUpDown/DoubleUpDown.cs | 109 ++++++++++++++++++ .../NumericUpDown/IntegerUpDown.cs | 24 ++++ .../NumericUpDown/LongUpDown.cs | 24 ++++ .../NumericUpDown/ShortUpDown.cs | 24 ++++ .../NumericUpDown/SingleUpDown.cs | 106 +++++++++++++++++ 9 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/Avalonia.Controls/NumericUpDown/AllowedSpecialValues.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/ByteUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/DecimalUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/DoubleUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/IntegerUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/LongUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/ShortUpDown.cs create mode 100644 src/Avalonia.Controls/NumericUpDown/SingleUpDown.cs diff --git a/src/Avalonia.Controls/NumericUpDown/AllowedSpecialValues.cs b/src/Avalonia.Controls/NumericUpDown/AllowedSpecialValues.cs new file mode 100644 index 0000000000..a4ec5ecdaf --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/AllowedSpecialValues.cs @@ -0,0 +1,15 @@ +using System; + +namespace Avalonia.Controls +{ + [Flags] + public enum AllowedSpecialValues + { + None = 0, + NaN = 1, + PositiveInfinity = 2, + NegativeInfinity = 4, + AnyInfinity = PositiveInfinity | NegativeInfinity, + Any = NaN | AnyInfinity + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/ByteUpDown.cs b/src/Avalonia.Controls/NumericUpDown/ByteUpDown.cs new file mode 100644 index 0000000000..835abae773 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/ByteUpDown.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Controls +{ + /// + public class ByteUpDown : CommonNumericUpDown + { + /// + /// Initializes static members of the class. + /// + static ByteUpDown() => UpdateMetadata(typeof(ByteUpDown), 1, byte.MinValue, byte.MaxValue); + + /// + /// Initializes new instance of the class. + /// + public ByteUpDown() : base(byte.TryParse, decimal.ToByte, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + protected override byte IncrementValue(byte value, byte increment) => (byte)(value + increment); + + /// + protected override byte DecrementValue(byte value, byte increment) => (byte)(value - increment); + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs index 1ce2508b4a..eed792d9dd 100644 --- a/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs +++ b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs @@ -6,7 +6,7 @@ using System.Linq; namespace Avalonia.Controls { /// - /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. + /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing nullable numeric values. /// public abstract class CommonNumericUpDown : NumericUpDown where T : struct, IFormattable, IComparable { diff --git a/src/Avalonia.Controls/NumericUpDown/DecimalUpDown.cs b/src/Avalonia.Controls/NumericUpDown/DecimalUpDown.cs new file mode 100644 index 0000000000..10cfa537d7 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/DecimalUpDown.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Controls +{ + /// + public class DecimalUpDown : CommonNumericUpDown + { + /// + /// Initializes static members of the class. + /// + static DecimalUpDown() => UpdateMetadata(typeof(DecimalUpDown), 1m, decimal.MinValue, decimal.MaxValue); + + /// + /// Initializes new instance of the class. + /// + public DecimalUpDown() : base(decimal.TryParse, d => d, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + protected override decimal IncrementValue(decimal value, decimal increment) => value + increment; + + /// + protected override decimal DecrementValue(decimal value, decimal increment) => value - increment; + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/DoubleUpDown.cs b/src/Avalonia.Controls/NumericUpDown/DoubleUpDown.cs new file mode 100644 index 0000000000..edee1247fb --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/DoubleUpDown.cs @@ -0,0 +1,109 @@ +using System; + +namespace Avalonia.Controls +{ + /// + public class DoubleUpDown : CommonNumericUpDown + { + /// + /// Defines the property. + /// + public static readonly DirectProperty AllowInputSpecialValuesProperty = + AvaloniaProperty.RegisterDirect(nameof(AllowInputSpecialValues), + updown => updown.AllowInputSpecialValues, (updown, v) => updown.AllowInputSpecialValues = v); + + private AllowedSpecialValues _allowInputSpecialValues; + + /// + /// Initializes static members of the class. + /// + static DoubleUpDown() => UpdateMetadata(typeof(DoubleUpDown), 1d, double.NegativeInfinity, double.PositiveInfinity); + + /// + /// Initializes new instance of the class. + /// + public DoubleUpDown() : base(double.TryParse, decimal.ToDouble, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + /// Gets or sets a value representing the special values the user is allowed to input, such as "Infinity", "-Infinity" and "NaN" values. + /// + public AllowedSpecialValues AllowInputSpecialValues + { + get { return _allowInputSpecialValues; } + set { SetAndRaise(AllowInputSpecialValuesProperty, ref _allowInputSpecialValues, value); } + } + + /// + protected override double IncrementValue(double value, double increment) => value + increment; + + /// + protected override double DecrementValue(double value, double increment) => value - increment; + + /// + protected override double? OnCoerceIncrement(double? baseValue) + { + if (baseValue.HasValue && double.IsNaN(baseValue.Value)) + { + throw new ArgumentException("NaN is invalid for Increment."); + } + return base.OnCoerceIncrement(baseValue); + } + + /// + protected override double? OnCoerceMaximum(double? baseValue) + { + if (baseValue.HasValue && double.IsNaN(baseValue.Value)) + { + throw new ArgumentException("NaN is invalid for Maximum."); + } + return base.OnCoerceMaximum(baseValue); + } + + /// + protected override double? OnCoerceMinimum(double? baseValue) + { + if (baseValue.HasValue && double.IsNaN(baseValue.Value)) + { + throw new ArgumentException("NaN is invalid for Minimum."); + } + return base.OnCoerceMinimum(baseValue); + } + + /// + protected override void SetValidSpinDirection() + { + if (Value.HasValue && double.IsInfinity(Value.Value) && (Spinner != null)) + { + Spinner.ValidSpinDirection = ValidSpinDirections.None; + } + else + { + base.SetValidSpinDirection(); + } + } + + /// + protected override double? ConvertTextToValue(string text) + { + var result = base.ConvertTextToValue(text); + if (result != null) + { + if (double.IsNaN(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.NaN); + } + else if (double.IsPositiveInfinity(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.PositiveInfinity); + } + else if (double.IsNegativeInfinity(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.NegativeInfinity); + } + } + return result; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/IntegerUpDown.cs b/src/Avalonia.Controls/NumericUpDown/IntegerUpDown.cs new file mode 100644 index 0000000000..7f9e05762c --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/IntegerUpDown.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Controls +{ + /// + public class IntegerUpDown : CommonNumericUpDown + { + /// + /// Initializes static members of the class. + /// + static IntegerUpDown() => UpdateMetadata(typeof(IntegerUpDown), 1, int.MinValue, int.MaxValue); + + /// + /// Initializes new instance of the class. + /// + public IntegerUpDown() : base(int.TryParse, decimal.ToInt32, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + protected override int IncrementValue(int value, int increment) => value + increment; + + /// + protected override int DecrementValue(int value, int increment) => value - increment; + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/LongUpDown.cs b/src/Avalonia.Controls/NumericUpDown/LongUpDown.cs new file mode 100644 index 0000000000..d116df0ad2 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/LongUpDown.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Controls +{ + /// + public class LongUpDown : CommonNumericUpDown + { + /// + /// Initializes static members of the class. + /// + static LongUpDown() => UpdateMetadata(typeof(LongUpDown), 1L, long.MinValue, long.MaxValue); + + /// + /// Initializes new instance of the class. + /// + public LongUpDown() : base(long.TryParse, decimal.ToInt64, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + protected override long IncrementValue(long value, long increment) => value + increment; + + /// + protected override long DecrementValue(long value, long increment) => value - increment; + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/ShortUpDown.cs b/src/Avalonia.Controls/NumericUpDown/ShortUpDown.cs new file mode 100644 index 0000000000..cdba5b9b64 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/ShortUpDown.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Controls +{ + /// + public class ShortUpDown : CommonNumericUpDown + { + /// + /// Initializes static members of the class. + /// + static ShortUpDown() => UpdateMetadata(typeof(ShortUpDown), 1, short.MinValue, short.MaxValue); + + /// + /// Initializes new instance of the class. + /// + public ShortUpDown() : base(short.TryParse, decimal.ToInt16, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + protected override short IncrementValue(short value, short increment) => (short)(value + increment); + + /// + protected override short DecrementValue(short value, short increment) => (short)(value - increment); + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/SingleUpDown.cs b/src/Avalonia.Controls/NumericUpDown/SingleUpDown.cs new file mode 100644 index 0000000000..cc3da078bf --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/SingleUpDown.cs @@ -0,0 +1,106 @@ +using System; + +namespace Avalonia.Controls +{ + /// + public class SingleUpDown : CommonNumericUpDown + { + /// + /// Defines the property. + /// + public static readonly DirectProperty AllowInputSpecialValuesProperty = + AvaloniaProperty.RegisterDirect(nameof(AllowInputSpecialValues), + updown => updown.AllowInputSpecialValues, (updown, v) => updown.AllowInputSpecialValues = v); + + private AllowedSpecialValues _allowInputSpecialValues; + + /// + /// Initializes static members of the class. + /// + static SingleUpDown() => UpdateMetadata(typeof(SingleUpDown), 1f, float.NegativeInfinity, float.PositiveInfinity); + + /// + /// Initializes new instance of the class. + /// + public SingleUpDown() : base(float.TryParse, decimal.ToSingle, (v1, v2) => v1 < v2, (v1, v2) => v1 > v2) + { + } + + /// + /// Gets or sets a value representing the special values the user is allowed to input, such as "Infinity", "-Infinity" and "NaN" values. + /// + public AllowedSpecialValues AllowInputSpecialValues + { + get { return _allowInputSpecialValues; } + set { SetAndRaise(AllowInputSpecialValuesProperty, ref _allowInputSpecialValues, value); } + } + + /// + protected override float? OnCoerceIncrement(float? baseValue) + { + if (baseValue.HasValue && float.IsNaN(baseValue.Value)) + throw new ArgumentException("NaN is invalid for Increment."); + + return base.OnCoerceIncrement(baseValue); + } + + /// + protected override float? OnCoerceMaximum(float? baseValue) + { + if (baseValue.HasValue && float.IsNaN(baseValue.Value)) + throw new ArgumentException("NaN is invalid for Maximum."); + + return base.OnCoerceMaximum(baseValue); + } + + /// + protected override float? OnCoerceMinimum(float? baseValue) + { + if (baseValue.HasValue && float.IsNaN(baseValue.Value)) + throw new ArgumentException("NaN is invalid for Minimum."); + + return base.OnCoerceMinimum(baseValue); + } + + /// + protected override float IncrementValue(float value, float increment) => value + increment; + + /// + protected override float DecrementValue(float value, float increment) => value - increment; + + /// + protected override void SetValidSpinDirection() + { + if (Value.HasValue && float.IsInfinity(Value.Value) && (Spinner != null)) + { + Spinner.ValidSpinDirection = ValidSpinDirections.None; + } + else + { + base.SetValidSpinDirection(); + } + } + + /// + protected override float? ConvertTextToValue(string text) + { + var result = base.ConvertTextToValue(text); + if (result != null) + { + if (float.IsNaN(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.NaN); + } + else if (float.IsPositiveInfinity(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.PositiveInfinity); + } + else if (float.IsNegativeInfinity(result.Value)) + { + TestInputSpecialValue(AllowInputSpecialValues, AllowedSpecialValues.NegativeInfinity); + } + } + return result; + } + } +} \ No newline at end of file From 1aa4d8749f5c1bc9b6c64b748835f6f6e18de54f Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 15:15:43 +0300 Subject: [PATCH 25/54] Added default template for NumericUpDown classes. --- src/Avalonia.Themes.Default/DefaultTheme.xaml | 1 + .../NumericUpDown.xaml | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/Avalonia.Themes.Default/NumericUpDown.xaml diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 7c567c1835..aa1a3c6385 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -43,4 +43,5 @@ + diff --git a/src/Avalonia.Themes.Default/NumericUpDown.xaml b/src/Avalonia.Themes.Default/NumericUpDown.xaml new file mode 100644 index 0000000000..e2ab6bf149 --- /dev/null +++ b/src/Avalonia.Themes.Default/NumericUpDown.xaml @@ -0,0 +1,41 @@ + + + \ No newline at end of file From 89cfa644ae2ff3fac454a9c27e1148aaef9dd68d Mon Sep 17 00:00:00 2001 From: dzhelnin Date: Tue, 20 Mar 2018 15:17:42 +0300 Subject: [PATCH 26/54] Added NumbersPage to ControlCatalog. --- samples/ControlCatalog/ControlCatalog.csproj | 6 + samples/ControlCatalog/MainView.xaml | 1 + samples/ControlCatalog/Pages/NumbersPage.xaml | 96 +++++++++++++++ .../ControlCatalog/Pages/NumbersPage.xaml.cs | 111 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 samples/ControlCatalog/Pages/NumbersPage.xaml create mode 100644 samples/ControlCatalog/Pages/NumbersPage.xaml.cs diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index a3d7a0cdce..c3f14b045d 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -78,6 +78,9 @@ Designer + + Designer + Designer @@ -169,6 +172,9 @@ ButtonSpinnerPage.xaml + + + NumbersPage.xaml diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 142d0d42b1..f8976401e7 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -19,6 +19,7 @@ + diff --git a/samples/ControlCatalog/Pages/NumbersPage.xaml b/samples/ControlCatalog/Pages/NumbersPage.xaml new file mode 100644 index 0000000000..fa1990b472 --- /dev/null +++ b/samples/ControlCatalog/Pages/NumbersPage.xaml @@ -0,0 +1,96 @@ + + + Numeric up-down controls + Numeric up-down controls provide a TextBox with button spinners that allow incrementing and decrementing numeric values by using the spinner buttons, keyboard up/down arrows, or mouse wheel. + The following controls are available to support various native numeric types: + ByteUpDown, ShortUpDown, IntegerUpDown, LongUpDown, SingleUpDown, DoubleUpDown, DecimalUpDown. + + Features: + + + ShowButtonSpinner: + + + IsReadOnly: + + + AllowSpin: + + + ClipValueToMinMax: + + + DisplayDefaultValueOnEmptyText: + + + + FormatString: + + + + + + + + + + + + + ButtonSpinnerLocation: + + + CultureInfo: + + + Watermark: + + + Text: + + + + Minimum: + + + Maximum: + + + Increment: + + + Value: + + + DefaultValue: + + + + + + DoubleUpDown and SingleUpDown support the AllowInputSpecialValues property + + AllowInputSpecialValues: + + + + + Usage of DoubleUpDown: + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog/Pages/NumbersPage.xaml.cs b/samples/ControlCatalog/Pages/NumbersPage.xaml.cs new file mode 100644 index 0000000000..a68bdc3bcf --- /dev/null +++ b/samples/ControlCatalog/Pages/NumbersPage.xaml.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Markup.Xaml; +using ReactiveUI; + +namespace ControlCatalog.Pages +{ + public class NumbersPage : UserControl + { + public NumbersPage() + { + this.InitializeComponent(); + var viewModel = new NumbersPageViewModel(); + DataContext = viewModel; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + } + + public class NumbersPageViewModel : ReactiveObject + { + private IList _formats; + private FormatObject _selectedFormat; + private IList _spinnerLocations; + private IList _allowedSpecialValues; + + public NumbersPageViewModel() + { + SelectedFormat = Formats.FirstOrDefault(); + } + + public IList Formats + { + get + { + return _formats ?? (_formats = new List() + { + new FormatObject() {Name = "Currency", Value = "C2"}, + new FormatObject() {Name = "Fixed point", Value = "F2"}, + new FormatObject() {Name = "General", Value = "G"}, + new FormatObject() {Name = "Number", Value = "N"}, + new FormatObject() {Name = "Percent", Value = "P"}, + new FormatObject() {Name = "Degrees", Value = "{0:N2} °"}, + }); + } + } + + public IList SpinnerLocations + { + get + { + if (_spinnerLocations == null) + { + _spinnerLocations = new List(); + foreach (Location value in Enum.GetValues(typeof(Location))) + { + _spinnerLocations.Add(value); + } + } + return _spinnerLocations ; + } + } + + public IList Cultures { get; } = new List() + { + new CultureInfo("en-US"), + new CultureInfo("en-GB"), + new CultureInfo("fr-FR"), + new CultureInfo("ar-DZ"), + new CultureInfo("zh-CN"), + new CultureInfo("cs-CZ") + }; + + public IList AllowedSpecialValues + { + get + { + if (_allowedSpecialValues == null) + { + _allowedSpecialValues = new List(); + foreach (AllowedSpecialValues value in Enum.GetValues(typeof(AllowedSpecialValues))) + { + _allowedSpecialValues.Add(value); + } + } + return _allowedSpecialValues; + } + } + + public FormatObject SelectedFormat + { + get { return _selectedFormat; } + set { this.RaiseAndSetIfChanged(ref _selectedFormat, value); } + } + } + + public class FormatObject + { + public string Value { get; set; } + public string Name { get; set; } + } +} From 27a666467cc36d860790f8e3e9e04bebe78818a9 Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Thu, 22 Mar 2018 11:31:54 +0100 Subject: [PATCH 27/54] Initial --- src/Avalonia.Controls/Border.cs | 333 ++++++++++++++++-- .../Presenters/ContentPresenter.cs | 153 ++++---- .../Primitives/TemplatedControl.cs | 4 +- .../Accents/BaseLight.xaml | 5 +- src/Avalonia.Visuals/CornerRadius.cs | 97 +++++ src/Avalonia.Visuals/Thickness.cs | 7 +- .../Avalonia.Markup.Xaml.csproj | 1 + .../Converters/CornerRadiusTypeConverter.cs | 19 + .../AvaloniaDefaultTypeConverters.cs | 1 + .../Avalonia.Direct2D1/Media/GeometryImpl.cs | 11 +- .../Styling/ApplyStyling.cs | 2 +- .../BorderTests.cs | 2 +- .../Controls/BorderTests.cs | 26 +- .../Media/VisualBrushTests.cs | 2 +- .../Avalonia.RenderTests/Shapes/PathTests.cs | 2 +- .../Avalonia.Styling.UnitTests/StyleTests.cs | 6 +- .../CornerRadiusTests.cs | 43 +++ .../ThicknessTests.cs | 4 +- 18 files changed, 582 insertions(+), 136 deletions(-) create mode 100644 src/Avalonia.Visuals/CornerRadius.cs create mode 100644 src/Markup/Avalonia.Markup.Xaml/Converters/CornerRadiusTypeConverter.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/CornerRadiusTests.cs diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 002c5ea3f2..4ddf81565a 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -1,6 +1,8 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System; +using Avalonia; using Avalonia.Media; namespace Avalonia.Controls @@ -25,14 +27,16 @@ namespace Avalonia.Controls /// /// Defines the property. /// - public static readonly StyledProperty BorderThicknessProperty = - AvaloniaProperty.Register(nameof(BorderThickness)); + public static readonly StyledProperty BorderThicknessProperty = + AvaloniaProperty.Register(nameof(BorderThickness)); /// /// Defines the property. /// - public static readonly StyledProperty CornerRadiusProperty = - AvaloniaProperty.Register(nameof(CornerRadius)); + public static readonly StyledProperty CornerRadiusProperty = + AvaloniaProperty.Register(nameof(CornerRadius)); + + private readonly BorderRenderer _borderRenderer = new BorderRenderer(); /// /// Initializes static members of the class. @@ -63,7 +67,7 @@ namespace Avalonia.Controls /// /// Gets or sets the thickness of the border. /// - public double BorderThickness + public Thickness BorderThickness { get { return GetValue(BorderThicknessProperty); } set { SetValue(BorderThicknessProperty, value); } @@ -72,7 +76,7 @@ namespace Avalonia.Controls /// /// Gets or sets the radius of the border rounded corners. /// - public float CornerRadius + public CornerRadius CornerRadius { get { return GetValue(CornerRadiusProperty); } set { SetValue(CornerRadiusProperty, value); } @@ -84,21 +88,7 @@ namespace Avalonia.Controls /// The drawing context. public override void Render(DrawingContext context) { - var background = Background; - var borderBrush = BorderBrush; - var borderThickness = BorderThickness; - var cornerRadius = CornerRadius; - var rect = new Rect(Bounds.Size).Deflate(BorderThickness); - - if (background != null) - { - context.FillRectangle(background, rect, cornerRadius); - } - - if (borderBrush != null && borderThickness > 0) - { - context.DrawRectangle(new Pen(borderBrush, borderThickness), rect, cornerRadius); - } + _borderRenderer.Render(context, Bounds.Size, BorderThickness, CornerRadius, Background, BorderBrush); } /// @@ -120,10 +110,12 @@ namespace Avalonia.Controls { if (Child != null) { - var padding = Padding + new Thickness(BorderThickness); + var padding = Padding + BorderThickness; Child.Arrange(new Rect(finalSize).Deflate(padding)); } + _borderRenderer.Update(finalSize, BorderThickness, CornerRadius); + return finalSize; } @@ -131,18 +123,307 @@ namespace Avalonia.Controls Size availableSize, IControl child, Thickness padding, - double borderThickness) + Thickness borderThickness) { - padding += new Thickness(borderThickness); + padding += borderThickness; if (child != null) { child.Measure(availableSize.Deflate(padding)); return child.DesiredSize.Inflate(padding); } - else + + return new Size(padding.Left + padding.Right, padding.Bottom + padding.Top); + } + + + internal class BorderRenderer + { + private bool _useComplexRendering; + private StreamGeometry _backgroundGeometryCache; + private StreamGeometry _borderGeometryCache; + + public void Update(Size finalSize, Thickness borderThickness, CornerRadius cornerRadius) + { + if (borderThickness.IsUniform && cornerRadius.IsUniform) + { + _backgroundGeometryCache = null; + _borderGeometryCache = null; + _useComplexRendering = false; + } + else + { + _useComplexRendering = true; + + var boundRect = new Rect(finalSize); + var innerRect = boundRect.Deflate(borderThickness); + var innerRadii = new Radii(cornerRadius, borderThickness, false); + + StreamGeometry backgroundGeometry = null; + + // calculate border / background rendering geometry + if (!innerRect.Width.Equals(0) && !innerRect.Height.Equals(0)) + { + backgroundGeometry = new StreamGeometry(); + + using (var ctx = backgroundGeometry.Open()) + { + GenerateGeometry(ctx, innerRect, innerRadii); + } + + _backgroundGeometryCache = backgroundGeometry; + } + else + { + _backgroundGeometryCache = null; + } + + if (!boundRect.Width.Equals(0) && !boundRect.Height.Equals(0)) + { + var outerRadii = new Radii(cornerRadius, borderThickness, true); + var borderGeometry = new StreamGeometry(); + + using (var ctx = borderGeometry.Open()) + { + GenerateGeometry(ctx, boundRect, outerRadii); + + if (backgroundGeometry != null) + { + GenerateGeometry(ctx, innerRect, innerRadii); + } + } + + _borderGeometryCache = borderGeometry; + } + else + { + _borderGeometryCache = null; + } + } + } + + public void Render(DrawingContext context, Size size, Thickness borders, CornerRadius radii, IBrush background, IBrush borderBrush) { - return new Size(padding.Left + padding.Right, padding.Bottom + padding.Top); + if (_useComplexRendering) + { + IBrush brush; + var borderGeometry = _borderGeometryCache; + if (borderGeometry != null && (brush = borderBrush) != null) + { + context.DrawGeometry(brush, null, borderGeometry); + } + + var backgroundGeometry = _backgroundGeometryCache; + if (backgroundGeometry != null && (brush = background) != null) + { + context.DrawGeometry(brush, null, backgroundGeometry); + } + } + else + { + var borderThickness = borders.Left; + var cornerRadius = (float)radii.TopLeft; + var rect = new Rect(size); + + if (background != null) + { + context.FillRectangle(background, rect.Deflate(borders), cornerRadius); + } + + if (borderBrush != null && borderThickness > 0) + { + context.DrawRectangle(new Pen(borderBrush, borderThickness), rect.Deflate(borderThickness), cornerRadius); + } + } + } + + private static void GenerateGeometry(StreamGeometryContext ctx, Rect rect, Radii radii) + { + // + // Compute the coordinates of the key points + // + + var topLeft = new Point(radii.LeftTop, 0); + var topRight = new Point(rect.Width - radii.RightTop, 0); + var rightTop = new Point(rect.Width, radii.TopRight); + var rightBottom = new Point(rect.Width, rect.Height - radii.BottomRight); + var bottomRight = new Point(rect.Width - radii.RightBottom, rect.Height); + var bottomLeft = new Point(radii.LeftBottom, rect.Height); + var leftBottom = new Point(0, rect.Height - radii.BottomLeft); + var leftTop = new Point(0, radii.TopLeft); + + // + // Check keypoints for overlap and resolve by partitioning radii according to + // the percentage of each one. + // + + // Top edge is handled here + if (topLeft.X > topRight.X) + { + var x = radii.LeftTop / (radii.LeftTop + radii.RightTop) * rect.Width; + topLeft += new Point(x, 0); + topRight += new Point(x, 0); + } + + // Right edge + if (rightTop.Y > rightBottom.Y) + { + var y = radii.TopRight / (radii.TopRight + radii.BottomRight) * rect.Height; + rightTop += new Point(0, y); + rightBottom += new Point(0, y); + } + + // Bottom edge + if (bottomRight.X < bottomLeft.X) + { + var x = radii.LeftBottom / (radii.LeftBottom + radii.RightBottom) * rect.Width; + bottomRight += new Point(x, 0); + bottomLeft += new Point(x, 0); + } + + // Left edge + if (leftBottom.Y < leftTop.Y) + { + var y = radii.TopLeft / (radii.TopLeft + radii.BottomLeft) * rect.Height; + leftBottom += new Point(0, y); + leftTop += new Point(0, y); + } + + // + // Add on offsets + // + + var offset = new Vector(rect.TopLeft.X, rect.TopLeft.Y); + topLeft += offset; + topRight += offset; + rightTop += offset; + rightBottom += offset; + bottomRight += offset; + bottomLeft += offset; + leftBottom += offset; + leftTop += offset; + + // + // Create the border geometry + // + ctx.BeginFigure(topLeft, true); + + // Top + ctx.LineTo(topRight); + + // TopRight + var radiusX = rect.TopRight.X - topRight.X; + var radiusY = rightTop.Y - rect.TopRight.Y; + if (!radiusX.Equals(0) || !radiusY.Equals(0)) + { + ctx.ArcTo(rightTop, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + // Right + ctx.LineTo(rightBottom); + + // BottomRight + radiusX = rect.BottomRight.X - bottomRight.X; + radiusY = rect.BottomRight.Y - rightBottom.Y; + if (!radiusX.Equals(0) || !radiusY.Equals(0)) + { + ctx.ArcTo(bottomRight, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + // Bottom + ctx.LineTo(bottomLeft); + + // BottomLeft + radiusX = bottomLeft.X - rect.BottomLeft.X; + radiusY = rect.BottomLeft.Y - leftBottom.Y; + if (!radiusX.Equals(0) || !radiusY.Equals(0)) + { + ctx.ArcTo(leftBottom, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + // Left + ctx.LineTo(leftTop); + + // TopLeft + radiusX = topLeft.X - rect.TopLeft.X; + radiusY = leftTop.Y - rect.TopLeft.Y; + if (!radiusX.Equals(0) || !radiusY.Equals(0)) + { + ctx.ArcTo(topLeft, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + ctx.EndFigure(true); + } + + private struct Radii + { + internal Radii(CornerRadius radii, Thickness borders, bool outer) + { + var left = 0.5 * borders.Left; + var top = 0.5 * borders.Top; + var right = 0.5 * borders.Right; + var bottom = 0.5 * borders.Bottom; + + if (outer) + { + if (radii.TopLeft.Equals(0)) + { + LeftTop = TopLeft = 0.0; + } + else + { + LeftTop = radii.TopLeft + left; + TopLeft = radii.TopLeft + top; + } + if (radii.TopRight.Equals(0)) + { + TopRight = RightTop = 0.0; + } + else + { + TopRight = radii.TopRight + top; + RightTop = radii.TopRight + right; + } + if (radii.BottomRight.Equals(0)) + { + RightBottom = BottomRight = 0.0; + } + else + { + RightBottom = radii.BottomRight + right; + BottomRight = radii.BottomRight + bottom; + } + if (radii.BottomLeft.Equals(0)) + { + BottomLeft = LeftBottom = 0.0; + } + else + { + BottomLeft = radii.BottomLeft + bottom; + LeftBottom = radii.BottomLeft + left; + } + } + else + { + LeftTop = Math.Max(0.0, radii.TopLeft - left); + TopLeft = Math.Max(0.0, radii.TopLeft - top); + TopRight = Math.Max(0.0, radii.TopRight - top); + RightTop = Math.Max(0.0, radii.TopRight - right); + RightBottom = Math.Max(0.0, radii.BottomRight - right); + BottomRight = Math.Max(0.0, radii.BottomRight - bottom); + BottomLeft = Math.Max(0.0, radii.BottomLeft - bottom); + LeftBottom = Math.Max(0.0, radii.BottomLeft - left); + } + } + + internal readonly double LeftTop; + internal readonly double TopLeft; + internal readonly double TopRight; + internal readonly double RightTop; + internal readonly double RightBottom; + internal readonly double BottomRight; + internal readonly double BottomLeft; + internal readonly double LeftBottom; } } } diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index d0a438cc2b..2d623dcbf7 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -31,7 +31,7 @@ namespace Avalonia.Controls.Presenters /// /// Defines the property. /// - public static readonly StyledProperty BorderThicknessProperty = + public static readonly StyledProperty BorderThicknessProperty = Border.BorderThicknessProperty.AddOwner(); /// @@ -57,7 +57,7 @@ namespace Avalonia.Controls.Presenters /// /// Defines the property. /// - public static readonly StyledProperty CornerRadiusProperty = + public static readonly StyledProperty CornerRadiusProperty = Border.CornerRadiusProperty.AddOwner(); /// @@ -81,6 +81,7 @@ namespace Avalonia.Controls.Presenters private IControl _child; private bool _createdChild; private IDataTemplate _dataTemplate; + private readonly Border.BorderRenderer _borderRenderer = new Border.BorderRenderer(); /// /// Initializes static members of the class. @@ -120,7 +121,7 @@ namespace Avalonia.Controls.Presenters /// /// Gets or sets the thickness of the border. /// - public double BorderThickness + public Thickness BorderThickness { get { return GetValue(BorderThicknessProperty); } set { SetValue(BorderThicknessProperty, value); } @@ -157,7 +158,7 @@ namespace Avalonia.Controls.Presenters /// /// Gets or sets the radius of the border rounded corners. /// - public float CornerRadius + public CornerRadius CornerRadius { get { return GetValue(CornerRadiusProperty); } set { SetValue(CornerRadiusProperty, value); } @@ -221,7 +222,7 @@ namespace Avalonia.Controls.Presenters { var content = Content; var oldChild = Child; - var newChild = CreateChild(); + var newChild = CreateChild(); // Remove the old child if we're not recycling it. if (oldChild != null && newChild != oldChild) @@ -277,21 +278,7 @@ namespace Avalonia.Controls.Presenters /// public override void Render(DrawingContext context) { - var background = Background; - var borderBrush = BorderBrush; - var borderThickness = BorderThickness; - var cornerRadius = CornerRadius; - var rect = new Rect(Bounds.Size).Deflate(BorderThickness); - - if (background != null) - { - context.FillRectangle(background, rect, cornerRadius); - } - - if (borderBrush != null && borderThickness > 0) - { - context.DrawRectangle(new Pen(borderBrush, borderThickness), rect, cornerRadius); - } + _borderRenderer.Render(context, Bounds.Size, BorderThickness, CornerRadius, Background, BorderBrush); } /// @@ -344,7 +331,11 @@ namespace Avalonia.Controls.Presenters /// protected override Size ArrangeOverride(Size finalSize) { - return ArrangeOverrideImpl(finalSize, new Vector()); + finalSize = ArrangeOverrideImpl(finalSize, new Vector()); + + _borderRenderer.Update(finalSize, BorderThickness, CornerRadius); + + return finalSize; } /// @@ -372,74 +363,74 @@ namespace Avalonia.Controls.Presenters internal Size ArrangeOverrideImpl(Size finalSize, Vector offset) { - if (Child != null) - { - var padding = Padding; - var borderThickness = BorderThickness; - var horizontalContentAlignment = HorizontalContentAlignment; - var verticalContentAlignment = VerticalContentAlignment; - var useLayoutRounding = UseLayoutRounding; - var availableSizeMinusMargins = new Size( - Math.Max(0, finalSize.Width - padding.Left - padding.Right - borderThickness), - Math.Max(0, finalSize.Height - padding.Top - padding.Bottom - borderThickness)); - var size = availableSizeMinusMargins; - var scale = GetLayoutScale(); - var originX = offset.X + padding.Left + borderThickness; - var originY = offset.Y + padding.Top + borderThickness; - - if (horizontalContentAlignment != HorizontalAlignment.Stretch) - { - size = size.WithWidth(Math.Min(size.Width, DesiredSize.Width - padding.Left - padding.Right)); - } + if (Child == null) return finalSize; - if (verticalContentAlignment != VerticalAlignment.Stretch) - { - size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); - } + var padding = Padding; + var borderThickness = BorderThickness; + var horizontalContentAlignment = HorizontalContentAlignment; + var verticalContentAlignment = VerticalContentAlignment; + var useLayoutRounding = UseLayoutRounding; + //Not sure about this part + var availableSizeMinusMargins = new Size( + Math.Max(0, finalSize.Width - padding.Left - padding.Right - borderThickness.Left - borderThickness.Right), + Math.Max(0, finalSize.Height - padding.Top - padding.Bottom - borderThickness.Top - borderThickness.Bottom)); + var size = availableSizeMinusMargins; + var scale = GetLayoutScale(); + var originX = offset.X + padding.Left + borderThickness.Left; + var originY = offset.Y + padding.Top + borderThickness.Top; + + if (horizontalContentAlignment != HorizontalAlignment.Stretch) + { + size = size.WithWidth(Math.Min(size.Width, DesiredSize.Width - padding.Left - padding.Right)); + } - size = LayoutHelper.ApplyLayoutConstraints(Child, size); + if (verticalContentAlignment != VerticalAlignment.Stretch) + { + size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); + } - if (useLayoutRounding) - { - size = new Size( - Math.Ceiling(size.Width * scale) / scale, - Math.Ceiling(size.Height * scale) / scale); - availableSizeMinusMargins = new Size( - Math.Ceiling(availableSizeMinusMargins.Width * scale) / scale, - Math.Ceiling(availableSizeMinusMargins.Height * scale) / scale); - } + size = LayoutHelper.ApplyLayoutConstraints(Child, size); - switch (horizontalContentAlignment) - { - case HorizontalAlignment.Center: - case HorizontalAlignment.Stretch: - originX += (availableSizeMinusMargins.Width - size.Width) / 2; - break; - case HorizontalAlignment.Right: - originX += availableSizeMinusMargins.Width - size.Width; - break; - } + if (useLayoutRounding) + { + size = new Size( + Math.Ceiling(size.Width * scale) / scale, + Math.Ceiling(size.Height * scale) / scale); + availableSizeMinusMargins = new Size( + Math.Ceiling(availableSizeMinusMargins.Width * scale) / scale, + Math.Ceiling(availableSizeMinusMargins.Height * scale) / scale); + } - switch (verticalContentAlignment) - { - case VerticalAlignment.Center: - case VerticalAlignment.Stretch: - originY += (availableSizeMinusMargins.Height - size.Height) / 2; - break; - case VerticalAlignment.Bottom: - originY += availableSizeMinusMargins.Height - size.Height; - break; - } + switch (horizontalContentAlignment) + { + case HorizontalAlignment.Center: + case HorizontalAlignment.Stretch: + originX += (availableSizeMinusMargins.Width - size.Width) / 2; + break; + case HorizontalAlignment.Right: + originX += availableSizeMinusMargins.Width - size.Width; + break; + } - if (useLayoutRounding) - { - originX = Math.Floor(originX * scale) / scale; - originY = Math.Floor(originY * scale) / scale; - } + switch (verticalContentAlignment) + { + case VerticalAlignment.Center: + case VerticalAlignment.Stretch: + originY += (availableSizeMinusMargins.Height - size.Height) / 2; + break; + case VerticalAlignment.Bottom: + originY += availableSizeMinusMargins.Height - size.Height; + break; + } - Child.Arrange(new Rect(originX, originY, size.Width, size.Height)); + if (useLayoutRounding) + { + originX = Math.Floor(originX * scale) / scale; + originY = Math.Floor(originY * scale) / scale; } + Child.Arrange(new Rect(originX, originY, size.Width, size.Height)); + return finalSize; } @@ -447,7 +438,7 @@ namespace Avalonia.Controls.Presenters { var result = (VisualRoot as ILayoutRoot)?.LayoutScaling ?? 1.0; - if (result == 0 || double.IsNaN(result) || double.IsInfinity(result)) + if (result.Equals(0) || double.IsNaN(result) || double.IsInfinity(result)) { throw new Exception($"Invalid LayoutScaling returned from {VisualRoot.GetType()}"); } diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index 6deef7c7b9..77735f3f12 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -32,7 +32,7 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly StyledProperty BorderThicknessProperty = + public static readonly StyledProperty BorderThicknessProperty = Border.BorderThicknessProperty.AddOwner(); /// @@ -132,7 +132,7 @@ namespace Avalonia.Controls.Primitives /// /// Gets or sets the thickness of the control's border. /// - public double BorderThickness + public Thickness BorderThickness { get { return GetValue(BorderThicknessProperty); } set { SetValue(BorderThicknessProperty, value); } diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index cb86598a42..ebb14579b4 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -1,6 +1,7 @@ + + \ No newline at end of file From 253ff568952e1b122f3f0efac98ac7bfc7e6e704 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 3 Apr 2018 23:05:08 +0200 Subject: [PATCH 37/54] Added a couple of Border CornerRadius render tests. --- .../Controls/BorderTests.cs | 40 ++++++++++++++++++ ...order_NonUniform_CornerRadius.expected.png | Bin 0 -> 1084 bytes .../Border_Uniform_CornerRadius.expected.png | Bin 0 -> 1307 bytes ...order_NonUniform_CornerRadius.expected.png | Bin 0 -> 1084 bytes .../Border_Uniform_CornerRadius.expected.png | Bin 0 -> 1307 bytes 5 files changed, 40 insertions(+) create mode 100644 tests/TestFiles/Direct2D1/Controls/Border/Border_NonUniform_CornerRadius.expected.png create mode 100644 tests/TestFiles/Direct2D1/Controls/Border/Border_Uniform_CornerRadius.expected.png create mode 100644 tests/TestFiles/Skia/Controls/Border/Border_NonUniform_CornerRadius.expected.png create mode 100644 tests/TestFiles/Skia/Controls/Border/Border_Uniform_CornerRadius.expected.png diff --git a/tests/Avalonia.RenderTests/Controls/BorderTests.cs b/tests/Avalonia.RenderTests/Controls/BorderTests.cs index 411870785a..7d2e40c3b4 100644 --- a/tests/Avalonia.RenderTests/Controls/BorderTests.cs +++ b/tests/Avalonia.RenderTests/Controls/BorderTests.cs @@ -58,6 +58,46 @@ namespace Avalonia.Direct2D1.RenderTests.Controls CompareImages(); } + [Fact] + public async Task Border_Uniform_CornerRadius() + { + Decorator target = new Decorator + { + Padding = new Thickness(8), + Width = 200, + Height = 200, + Child = new Border + { + BorderBrush = Brushes.Black, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(16), + } + }; + + await RenderToFile(target); + CompareImages(); + } + + [Fact] + public async Task Border_NonUniform_CornerRadius() + { + Decorator target = new Decorator + { + Padding = new Thickness(8), + Width = 200, + Height = 200, + Child = new Border + { + BorderBrush = Brushes.Black, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(16, 4, 7, 10), + } + }; + + await RenderToFile(target); + CompareImages(); + } + [Fact] public async Task Border_Fill() { diff --git a/tests/TestFiles/Direct2D1/Controls/Border/Border_NonUniform_CornerRadius.expected.png b/tests/TestFiles/Direct2D1/Controls/Border/Border_NonUniform_CornerRadius.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..9deb45aaeb067d61c2774ba50214b80950db4763 GIT binary patch literal 1084 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZk(Ggg`I)DSV@O5Z+gl4y$Fj;C`&gb7V;~_W*3-?lIB~&(gBuhgdf65q+>kcy zFDG|cqNB@^1qRKIvk#^P1k6YXh={mRciuvLnjBwM+}`K+&a=z$z32R{`~3I3;`g=s zK-+-8@584={vi3R2S%cGWq;ar}0VDS<|Y_PmlQ1e!A`~zB5x*!lLXa z^SsyJHZQl2ZTgppI+9=KViu_n{ARllV%WZ&}#Avt0v)<(u@+@ElGWrXO7;?o;X+RGiAep>L{u~`nE{)c>gThy|7 zquPWTUHSd`?hhpE&wg6-Y=4oZ>gs#VYc}5GJ}4Mj8oR#oNYiFj0Sy+WR)+-w93o7O zP6`1WEL@Eailc%986ZbzIsA+33!kn$r`NRc&n}tuy6zUz*-SqRE$_wj6_{OP`Si1> zCf)MTy}dhR`2F4gpW@!kzWMm(@?xg4cV{ElJ+Jn!zjfgD<~o7O$!)EhC;LuCZu^bdM?T)xx&DFOa^qH)qVj3+ z6KZWw=e}Bd^I7W0!Po-SKLhYx<_un`9H~XjCpM_+=b9as&74}mdK II;Vst0D%^uoB#j- literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Direct2D1/Controls/Border/Border_Uniform_CornerRadius.expected.png b/tests/TestFiles/Direct2D1/Controls/Border/Border_Uniform_CornerRadius.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..a4bfa75eb8fad329e993f4dde010964fa5572837 GIT binary patch literal 1307 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl{2>gnPbQW5v|*4n<%bcwda6{{SS1UOilm>m>6yPB7Dc=0V*v<}E;X?!AJ z!N6k5o5|!UTqSUVi-ogvf`Y)8>KDI+&e-G}x@Ek1+4ZS=A~zg4)A9XWoc(VRpk)jU z2RKWsEZ03fGb1~>PNF#T%+J&!xoh9@&a6(2>#MYTc5}uY`!K!!N~>poXY?PFd6>e! zx%=ixjGwQnNI-kzKB=HBz&Gwz=0lk5Bwn1AdE zzvou|<1#npnY%Y9%k|6*H@=9~Pg`NwM{Cfh$aIfFa-fyK$a`DMn|vtsw!J_}Ik z`*Z2c-#HD*Z|=?5xqj!D`>SKZ&iePo7crSVxHd!bWbd3C?RVnORP)W3$vOK+rPBIY zlHZzKyS1lEWpgg>QLp^-vR?a?@zXyKY$OYu&xNIyKfkk!*SB z+S%BB`(*gHcq{x|=ly4^OycWjdi5WtB~EuJIxDt*x^m9lJ5NLBop^TCYu)u4?dg-1 zcF&o6F0X#`p9MT~RIJ~A)tesiVZW+?1`AWG!vX;g5vE2bg#Zo~u0{vNQ9-%}$ZnQT zd6x4c`!^U2SmJ^R4%|9_v%e$Tv&d;LExp5^!*XiIJ1d|Lik&Bue6*ZFV! z_viim<#n-r8$yfQ=lA}66uc%jR&MgEPo9r5#iFO)og41F`hKvz)MWWTPcHvYsNS^u z=ELLj|83u7BUNpzw*NsK(7Zdclke7o68D?KTMc*aJgL8Hs_+Iz``($=v+LhqeQWsB z)z0%qQ%%tsi+9hDB+ifzpBZyKR)YDsXWohDw#OG9GrTJ?`Ss2}pKK&6ERJ0dw8*MF z|1sfAP1ID`yT4PfIu>o7tj@Ig(wT0+;I2K z?b4stPfS1MfBa0gZE|1MrskVRL+=&OiR;Vuyl8&>kA>Rw=$(9LKK?e`ux$_L`S5F> v*WRi5XmEe+uiP_P`6n2`1&rUa;N|~VPtJY8w`!Hxb&!y!tDnm{r-UW|rRW{c literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/Border/Border_NonUniform_CornerRadius.expected.png b/tests/TestFiles/Skia/Controls/Border/Border_NonUniform_CornerRadius.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..9deb45aaeb067d61c2774ba50214b80950db4763 GIT binary patch literal 1084 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZk(Ggg`I)DSV@O5Z+gl4y$Fj;C`&gb7V;~_W*3-?lIB~&(gBuhgdf65q+>kcy zFDG|cqNB@^1qRKIvk#^P1k6YXh={mRciuvLnjBwM+}`K+&a=z$z32R{`~3I3;`g=s zK-+-8@584={vi3R2S%cGWq;ar}0VDS<|Y_PmlQ1e!A`~zB5x*!lLXa z^SsyJHZQl2ZTgppI+9=KViu_n{ARllV%WZ&}#Avt0v)<(u@+@ElGWrXO7;?o;X+RGiAep>L{u~`nE{)c>gThy|7 zquPWTUHSd`?hhpE&wg6-Y=4oZ>gs#VYc}5GJ}4Mj8oR#oNYiFj0Sy+WR)+-w93o7O zP6`1WEL@Eailc%986ZbzIsA+33!kn$r`NRc&n}tuy6zUz*-SqRE$_wj6_{OP`Si1> zCf)MTy}dhR`2F4gpW@!kzWMm(@?xg4cV{ElJ+Jn!zjfgD<~o7O$!)EhC;LuCZu^bdM?T)xx&DFOa^qH)qVj3+ z6KZWw=e}Bd^I7W0!Po-SKLhYx<_un`9H~XjCpM_+=b9as&74}mdK II;Vst0D%^uoB#j- literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Controls/Border/Border_Uniform_CornerRadius.expected.png b/tests/TestFiles/Skia/Controls/Border/Border_Uniform_CornerRadius.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..a4bfa75eb8fad329e993f4dde010964fa5572837 GIT binary patch literal 1307 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl{2>gnPbQW5v|*4n<%bcwda6{{SS1UOilm>m>6yPB7Dc=0V*v<}E;X?!AJ z!N6k5o5|!UTqSUVi-ogvf`Y)8>KDI+&e-G}x@Ek1+4ZS=A~zg4)A9XWoc(VRpk)jU z2RKWsEZ03fGb1~>PNF#T%+J&!xoh9@&a6(2>#MYTc5}uY`!K!!N~>poXY?PFd6>e! zx%=ixjGwQnNI-kzKB=HBz&Gwz=0lk5Bwn1AdE zzvou|<1#npnY%Y9%k|6*H@=9~Pg`NwM{Cfh$aIfFa-fyK$a`DMn|vtsw!J_}Ik z`*Z2c-#HD*Z|=?5xqj!D`>SKZ&iePo7crSVxHd!bWbd3C?RVnORP)W3$vOK+rPBIY zlHZzKyS1lEWpgg>QLp^-vR?a?@zXyKY$OYu&xNIyKfkk!*SB z+S%BB`(*gHcq{x|=ly4^OycWjdi5WtB~EuJIxDt*x^m9lJ5NLBop^TCYu)u4?dg-1 zcF&o6F0X#`p9MT~RIJ~A)tesiVZW+?1`AWG!vX;g5vE2bg#Zo~u0{vNQ9-%}$ZnQT zd6x4c`!^U2SmJ^R4%|9_v%e$Tv&d;LExp5^!*XiIJ1d|Lik&Bue6*ZFV! z_viim<#n-r8$yfQ=lA}66uc%jR&MgEPo9r5#iFO)og41F`hKvz)MWWTPcHvYsNS^u z=ELLj|83u7BUNpzw*NsK(7Zdclke7o68D?KTMc*aJgL8Hs_+Iz``($=v+LhqeQWsB z)z0%qQ%%tsi+9hDB+ifzpBZyKR)YDsXWohDw#OG9GrTJ?`Ss2}pKK&6ERJ0dw8*MF z|1sfAP1ID`yT4PfIu>o7tj@Ig(wT0+;I2K z?b4stPfS1MfBa0gZE|1MrskVRL+=&OiR;Vuyl8&>kA>Rw=$(9LKK?e`ux$_L`S5F> v*WRi5XmEe+uiP_P`6n2`1&rUa;N|~VPtJY8w`!Hxb&!y!tDnm{r-UW|rRW{c literal 0 HcmV?d00001 From f63b85f677dbdb07b24be03df303db18bc38fd64 Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Fri, 6 Apr 2018 11:25:42 +0200 Subject: [PATCH 38/54] Remove comment --- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index cc6c6f364f..478c68a963 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -371,7 +371,6 @@ namespace Avalonia.Controls.Presenters var horizontalContentAlignment = HorizontalContentAlignment; var verticalContentAlignment = VerticalContentAlignment; var useLayoutRounding = UseLayoutRounding; - //Not sure about this part var availableSizeMinusMargins = new Size( Math.Max(0, finalSize.Width - padding.Left - padding.Right - borderThickness.Left - borderThickness.Right), Math.Max(0, finalSize.Height - padding.Top - padding.Bottom - borderThickness.Top - borderThickness.Bottom)); From a139ce0116ce9ce9be33efea37960f3a906eb295 Mon Sep 17 00:00:00 2001 From: David Maas Date: Mon, 9 Apr 2018 03:05:34 -0500 Subject: [PATCH 39/54] Fixed Direct3DInteropSample. --- .../Direct3DInteropSample/Direct3DInteropSample.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj b/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj index 4271d05f91..e0f3e92c74 100644 --- a/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj +++ b/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj @@ -17,7 +17,9 @@ - + + PreserveNewest + From 71c25cae2d46b4f774f0038d6582fe6a076543cf Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 10 Apr 2018 20:46:54 +0200 Subject: [PATCH 40/54] Allow null pen in GeometryImpl. The `pen` parameter to `GeometryImpl.GetRenderBounds` and `StrokeContains` can be null. Don't try to dereference this null! --- src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs index 8f11d1463b..3e89dcc9b7 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -27,7 +27,7 @@ namespace Avalonia.Direct2D1.Media public Rect GetRenderBounds(Avalonia.Media.Pen pen) { var factory = AvaloniaLocator.Current.GetService(); - return Geometry.GetWidenedBounds((float)pen.Thickness).ToAvalonia(); + return Geometry.GetWidenedBounds((float)(pen?.Thickness ?? 0)).ToAvalonia(); } /// @@ -51,7 +51,7 @@ namespace Avalonia.Direct2D1.Media /// public bool StrokeContains(Avalonia.Media.Pen pen, Point point) { - return Geometry.StrokeContainsPoint(point.ToSharpDX(), (float)pen.Thickness); + return Geometry.StrokeContainsPoint(point.ToSharpDX(), (float)(pen?.Thickness ?? 0)); } public ITransformedGeometryImpl WithTransform(Matrix transform) From fef1422fdbab2e29bf04d1f7188b2f697f09c68d Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 10 Apr 2018 21:04:49 +0200 Subject: [PATCH 41/54] Removed unused variable. --- src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs index f057d191b6..120ab71ead 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -24,7 +24,6 @@ namespace Avalonia.Direct2D1.Media /// public Rect GetRenderBounds(Avalonia.Media.Pen pen) { - var factory = AvaloniaLocator.Current.GetService(); return Geometry.GetWidenedBounds((float)(pen?.Thickness ?? 0)).ToAvalonia(); } From fedae81cb884e3fc9b595c73e633ad44a4757575 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Wed, 11 Apr 2018 12:08:33 +0100 Subject: [PATCH 42/54] partial fix for 1436. --- src/Avalonia.Controls/Border.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 06ad8a4837..2dc42bd923 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -43,7 +43,8 @@ namespace Avalonia.Controls /// static Border() { - AffectsRender(BackgroundProperty, BorderBrushProperty); + AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty); + AffectsMeasure(BorderThicknessProperty); } /// From f412c696f068314a5d608b7ddd438cc268aa168c Mon Sep 17 00:00:00 2001 From: ReeJK Date: Thu, 12 Apr 2018 23:29:13 +0300 Subject: [PATCH 43/54] use Source in Binding --- .../Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs index c6705cbb4b..6e8fe1e4c0 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -43,6 +43,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions Mode = Mode, Path = pathInfo.Path, Priority = Priority, + Source = Source, RelativeSource = pathInfo.RelativeSource ?? RelativeSource, DefaultAnchor = new WeakReference(GetDefaultAnchor((ITypeDescriptorContext)serviceProvider)) }; From 272939464eaa6341b718ec8621967df29bc2e826 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 12 Apr 2018 23:28:08 +0200 Subject: [PATCH 44/54] Need to press a button to click it! Only raise a `Button` click event on pointer release when the button has been pressed first! --- src/Avalonia.Controls/Button.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs index 4922761a84..fa69d72d67 100644 --- a/src/Avalonia.Controls/Button.cs +++ b/src/Avalonia.Controls/Button.cs @@ -245,7 +245,7 @@ namespace Avalonia.Controls { base.OnPointerReleased(e); - if (e.MouseButton == MouseButton.Left) + if (IsPressed && e.MouseButton == MouseButton.Left) { e.Device.Capture(null); IsPressed = false; From ef44f2b9083b81289889da46cc2f84159b94b6bd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 12 Apr 2018 23:28:21 +0200 Subject: [PATCH 45/54] Handle the click that opens a DropDown. --- src/Avalonia.Controls/DropDown.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Controls/DropDown.cs b/src/Avalonia.Controls/DropDown.cs index a7ea2da4a4..932179028e 100644 --- a/src/Avalonia.Controls/DropDown.cs +++ b/src/Avalonia.Controls/DropDown.cs @@ -164,6 +164,7 @@ namespace Avalonia.Controls else { IsDropDownOpen = !IsDropDownOpen; + e.Handled = true; } } base.OnPointerPressed(e); From 27680c35e9355abe3512890e40d36cd6eebb1a28 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Thu, 12 Apr 2018 23:25:26 +0100 Subject: [PATCH 46/54] [Border] CornerRadius affects render. --- src/Avalonia.Controls/Border.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 2dc42bd923..d90f292183 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -43,7 +43,7 @@ namespace Avalonia.Controls /// static Border() { - AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty); + AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty); AffectsMeasure(BorderThicknessProperty); } From 653372009aa77e6e05d84ac885cd48106790cc89 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Thu, 12 Apr 2018 23:26:06 +0100 Subject: [PATCH 47/54] [ContentPresenter] BorderThickness affects render and measure. --- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 6acbc047ae..d2fb838657 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -92,6 +92,9 @@ namespace Avalonia.Controls.Presenters ContentProperty.Changed.AddClassHandler(x => x.ContentChanged); ContentTemplateProperty.Changed.AddClassHandler(x => x.ContentChanged); TemplatedParentProperty.Changed.AddClassHandler(x => x.TemplatedParentChanged); + + AffectsRender(BorderThicknessProperty); + AffectsMeasure(BorderThicknessProperty); } /// From 2d1ab0a106f180f154ad5d680320f60950eaf19f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 13 Apr 2018 14:36:46 +0200 Subject: [PATCH 48/54] Only update clip bounds when ClipToBounds = true. Fixes #1436 --- .../Rendering/SceneGraph/SceneBuilder.cs | 4 +- .../Rendering/SceneGraph/SceneBuilderTests.cs | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/SceneBuilder.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/SceneBuilder.cs index 6005ee8b8f..799380cb85 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/SceneBuilder.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/SceneBuilder.cs @@ -167,7 +167,9 @@ namespace Avalonia.Rendering.SceneGraph using (context.PushPostTransform(m)) using (context.PushTransformContainer()) { - var clipBounds = bounds.TransformToAABB(contextImpl.Transform).Intersect(clip); + var clipBounds = clipToBounds ? + bounds.TransformToAABB(contextImpl.Transform).Intersect(clip) : + clip; forceRecurse = forceRecurse || node.ClipBounds != clipBounds || diff --git a/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs b/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs index 2ada7bdbba..df4584518e 100644 --- a/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Rendering/SceneGraph/SceneBuilderTests.cs @@ -83,6 +83,7 @@ namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph Margin = new Thickness(10, 20, 30, 40), Child = canvas = new Canvas { + ClipToBounds = true, Background = Brushes.AliceBlue, } } @@ -129,6 +130,7 @@ namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph (border = new Border { Background = Brushes.AliceBlue, + ClipToBounds = true, Width = 100, Height = 100, [Canvas.LeftProperty] = 50, @@ -173,6 +175,7 @@ namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph (border = new Border { Background = Brushes.AliceBlue, + ClipToBounds = true, Width = 100, Height = 100, [Canvas.LeftProperty] = 50, @@ -254,6 +257,7 @@ namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph Margin = new Thickness(24, 26), Child = target = new Border { + ClipToBounds = true, Margin = new Thickness(26, 24), Width = 100, Height = 100, @@ -515,6 +519,50 @@ namespace Avalonia.Visuals.UnitTests.Rendering.SceneGraph } } + [Fact] + public void Should_Update_ClipBounds_For_Negative_Margin() + { + using (TestApplication()) + { + Decorator decorator; + Border border; + var tree = new TestRoot + { + Width = 100, + Height = 100, + Child = decorator = new Decorator + { + Margin = new Thickness(0, 10, 0, 0), + Child = border = new Border + { + Background = Brushes.Red, + ClipToBounds = true, + Margin = new Thickness(0, -5, 0, 0), + } + } + }; + + var layout = AvaloniaLocator.Current.GetService(); + layout.ExecuteInitialLayoutPass(tree); + + var scene = new Scene(tree); + var sceneBuilder = new SceneBuilder(); + sceneBuilder.UpdateAll(scene); + + var borderNode = scene.FindNode(border); + Assert.Equal(new Rect(0, 5, 100, 95), borderNode.ClipBounds); + + border.Margin = new Thickness(0, -8, 0, 0); + layout.ExecuteLayoutPass(); + + scene = scene.CloneScene(); + sceneBuilder.Update(scene, border); + + borderNode = scene.FindNode(border); + Assert.Equal(new Rect(0, 2, 100, 98), borderNode.ClipBounds); + } + } + [Fact] public void Should_Update_Descendent_Tranform_When_Margin_Changed() { From 67003577a0dc9c7aac2326c527f001d075ef9823 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 13 Apr 2018 13:40:56 +0100 Subject: [PATCH 49/54] Add failing unit tests for border and content presenter. --- .../BorderTests.cs | 23 +++++++++++++++++++ .../ContentPresenterTests_Layout.cs | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index 3660a7b4ca..4f58e5e780 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -20,5 +20,28 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Size(20, 20), target.DesiredSize); } + + [Fact] + public void Child_Should_Arrange_With_Zero_Height_Width_If_Padding_Greater_Than_Child_Size() + { + Border content; + + var target = new Border + { + Padding = new Thickness(6), + MaxHeight = 12, + MaxWidth = 12, + Content = content = new Border + { + Height = 0, + Width = 0 + } + }; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Rect(0, 0, 0, 0), content.Bounds); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs index b3c617c4ab..d88716279a 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs @@ -210,5 +210,28 @@ namespace Avalonia.Controls.UnitTests.Presenters Assert.Equal(new Rect(84, 0, 16, 16), content.Bounds); } + + [Fact] + public void Child_Arrange_With_Zero_Height_When_Padding_Height_Greater_Than_Child_Height() + { + Border content; + var target = new ContentPresenter + { + Padding = 32, + MaxHeight = 32, + MaxWidth = 32, + Content = content = new Border + { + MinWidth = 16, + MinHeight = 16, + }, + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Rect(0, 0, 0, 0), content.Bounds); + } } } \ No newline at end of file From a98d289af8abcf50e3b29bdd589b92a6e2ccd72a Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 13 Apr 2018 13:42:36 +0100 Subject: [PATCH 50/54] Add arrange fix for border and content presenter. --- src/Avalonia.Controls/Border.cs | 3 ++- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 06ad8a4837..6bceba3401 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -111,7 +111,8 @@ namespace Avalonia.Controls if (Child != null) { var padding = Padding + BorderThickness; - Child.Arrange(new Rect(finalSize).Deflate(padding)); + var arrangeRect = new Rect(finalSize).Deflate(padding); + Child.Arrange(new Rect(arrangeRect.X, arrangeRect.Y, Math.Max(0, arrangeRect.Width), Math.Max(0, arrangeRect.Height))); } _borderRenderHelper.Update(finalSize, BorderThickness, CornerRadius); diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 6acbc047ae..1c5883d980 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -386,7 +386,7 @@ namespace Avalonia.Controls.Presenters if (verticalContentAlignment != VerticalAlignment.Stretch) { - size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); + size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); } if (useLayoutRounding) @@ -425,7 +425,7 @@ namespace Avalonia.Controls.Presenters originY = Math.Floor(originY * scale) / scale; } - Child.Arrange(new Rect(originX, originY, size.Width, size.Height)); + Child.Arrange(new Rect(originX, originY, Math.Max(0, size.Width), Math.Max(0, size.Height))); return finalSize; } From 2c9f3ede54cf7aca8c2fdd8b1e1af437c81aa805 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 13 Apr 2018 14:12:51 +0100 Subject: [PATCH 51/54] fix tests and and fixes. --- src/Avalonia.Controls/Border.cs | 5 ++--- tests/Avalonia.Controls.UnitTests/BorderTests.cs | 7 +++---- .../Presenters/ContentPresenterTests_Layout.cs | 12 +++++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index 6bceba3401..e6846978dc 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -110,9 +110,8 @@ namespace Avalonia.Controls { if (Child != null) { - var padding = Padding + BorderThickness; - var arrangeRect = new Rect(finalSize).Deflate(padding); - Child.Arrange(new Rect(arrangeRect.X, arrangeRect.Y, Math.Max(0, arrangeRect.Width), Math.Max(0, arrangeRect.Height))); + var padding = Padding + BorderThickness; + Child.Arrange(new Rect(finalSize).Deflate(padding)); } _borderRenderHelper.Update(finalSize, BorderThickness, CornerRadius); diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index 4f58e5e780..9d6c838bc3 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -31,17 +31,16 @@ namespace Avalonia.Controls.UnitTests Padding = new Thickness(6), MaxHeight = 12, MaxWidth = 12, - Content = content = new Border + Child = content = new Border { Height = 0, Width = 0 } }; - - target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); - Assert.Equal(new Rect(0, 0, 0, 0), content.Bounds); + Assert.Equal(new Rect(6, 6, 0, 0), content.Bounds); } } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs index d88716279a..2c1074aa9a 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests_Layout.cs @@ -217,21 +217,23 @@ namespace Avalonia.Controls.UnitTests.Presenters Border content; var target = new ContentPresenter { - Padding = 32, + Padding = new Thickness(32), MaxHeight = 32, MaxWidth = 32, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, Content = content = new Border { - MinWidth = 16, - MinHeight = 16, + Height = 0, + Width = 0, }, }; target.UpdateChild(); - target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); - Assert.Equal(new Rect(0, 0, 0, 0), content.Bounds); + Assert.Equal(new Rect(48, 48, 0, 0), content.Bounds); } } } \ No newline at end of file From 8d07861e5976fa1930d2da40271031019b242d05 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 13 Apr 2018 14:14:24 +0100 Subject: [PATCH 52/54] white space. --- src/Avalonia.Controls/Border.cs | 4 ++-- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 4 ++-- tests/Avalonia.Controls.UnitTests/BorderTests.cs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index e6846978dc..93a6c203c9 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -110,11 +110,11 @@ namespace Avalonia.Controls { if (Child != null) { - var padding = Padding + BorderThickness; + var padding = Padding + BorderThickness; Child.Arrange(new Rect(finalSize).Deflate(padding)); } - _borderRenderHelper.Update(finalSize, BorderThickness, CornerRadius); + _borderRenderHelper.Update(finalSize, BorderThickness, CornerRadius); return finalSize; } diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 1c5883d980..ce8fe52c22 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -383,10 +383,10 @@ namespace Avalonia.Controls.Presenters { size = size.WithWidth(Math.Min(size.Width, DesiredSize.Width - padding.Left - padding.Right)); } - + if (verticalContentAlignment != VerticalAlignment.Stretch) { - size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); + size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); } if (useLayoutRounding) diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index 9d6c838bc3..9a6a041ec7 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -33,11 +33,11 @@ namespace Avalonia.Controls.UnitTests MaxWidth = 12, Child = content = new Border { - Height = 0, - Width = 0 + Height = 0, + Width = 0 } }; - + target.Arrange(new Rect(0, 0, 100, 100)); Assert.Equal(new Rect(6, 6, 0, 0), content.Bounds); From 4ca13f2e3f7bde9e2735063318701365fb8362c4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 13 Apr 2018 15:25:52 +0200 Subject: [PATCH 53/54] Added additional invalidation properties... ...to `ContentPresenter` --- src/Avalonia.Controls/Presenters/ContentPresenter.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index d2fb838657..408e777ef8 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -89,12 +89,11 @@ namespace Avalonia.Controls.Presenters /// static ContentPresenter() { + AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty); + AffectsMeasure(BorderThicknessProperty); ContentProperty.Changed.AddClassHandler(x => x.ContentChanged); ContentTemplateProperty.Changed.AddClassHandler(x => x.ContentChanged); TemplatedParentProperty.Changed.AddClassHandler(x => x.TemplatedParentChanged); - - AffectsRender(BorderThicknessProperty); - AffectsMeasure(BorderThicknessProperty); } /// From 195c2bb57a696d7e8d600d43b6b17a13cd620863 Mon Sep 17 00:00:00 2001 From: factormystic Date: Fri, 13 Apr 2018 17:47:56 -0400 Subject: [PATCH 54/54] Fix broken quickstart link --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index eeee39dabe..96cfde3eb2 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,7 @@ https://ci.appveyor.com/project/AvaloniaUI/Avalonia/branch/master/artifacts ## Documentation -As mentioned above, Avalonia is still in alpha and as such there's not much documentation yet. You can take a look at the [getting started page](http://avaloniaui.net/guides/quickstart) for an overview of how to get started but probably the best thing to do for now is to already know a little bit about WPF/Silverlight/UWP/XAML and ask questions in our [Gitter room](https://gitter.im/AvaloniaUI/Avalonia). +As mentioned above, Avalonia is still in alpha and as such there's not much documentation yet. You can take a look at the [getting started page](http://avaloniaui.net/docs/quickstart/) for an overview of how to get started but probably the best thing to do for now is to already know a little bit about WPF/Silverlight/UWP/XAML and ask questions in our [Gitter room](https://gitter.im/AvaloniaUI/Avalonia). There's also a high-level [architecture document](http://avaloniaui.net/architecture/project-structure) that is currently a little bit out of date, and I've also started writing blog posts on Avalonia at http://grokys.github.io/.