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/. diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index a3d7a0cdce..862de9d320 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -41,6 +41,9 @@ Designer + + Designer + Designer @@ -78,6 +81,9 @@ Designer + + Designer + Designer @@ -113,6 +119,9 @@ BorderPage.xaml + + AutoCompleteBoxPage.xaml + ButtonPage.xaml @@ -169,6 +178,9 @@ ButtonSpinnerPage.xaml + + + NumericUpDownPage.xaml diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 142d0d42b1..a0e0df450b 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -5,10 +5,11 @@ + - + @@ -19,6 +20,7 @@ + @@ -26,4 +28,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..943fadf100 --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml @@ -0,0 +1,59 @@ + + + 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..6f3b8361cd --- /dev/null +++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs @@ -0,0 +1,143 @@ +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; +using System.Threading; +using System.Threading.Tasks; + +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; + + var asyncBox = this.FindControl("AsyncBox"); + asyncBox.AsyncPopulator = PopulateAsync; + } + private IEnumerable GetAllAutoCompleteBox() + { + return + this.GetLogicalDescendants() + .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/samples/ControlCatalog/Pages/NumericUpDownPage.xaml b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml new file mode 100644 index 0000000000..a5c911f47d --- /dev/null +++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml @@ -0,0 +1,80 @@ + + + Numeric up-down control + Numeric up-down control provides a TextBox with button spinners that allow incrementing and decrementing numeric values by using the spinner buttons, keyboard up/down arrows, or mouse wheel. + + Features: + + + ShowButtonSpinner: + + + IsReadOnly: + + + AllowSpin: + + + ClipValueToMinMax: + + + + + FormatString: + + + + + + + + + + + + + ButtonSpinnerLocation: + + + CultureInfo: + + + Watermark: + + + Text: + + + + Minimum: + + + Maximum: + + + Increment: + + + Value: + + + + + + + Usage of NumericUpDown: + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs new file mode 100644 index 0000000000..92da64d87e --- /dev/null +++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs @@ -0,0 +1,94 @@ +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 NumericUpDownPage : UserControl + { + public NumericUpDownPage() + { + 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; + + 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 FormatObject SelectedFormat + { + get { return _selectedFormat; } + set { this.RaiseAndSetIfChanged(ref _selectedFormat, value); } + } + } + + public class FormatObject + { + public string Value { get; set; } + public string Name { get; set; } + } +} 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 + 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 +} diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs new file mode 100644 index 0000000000..8e801d606b --- /dev/null +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -0,0 +1,2726 @@ +// (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 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.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + /// + /// 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 : CancelEventArgs + { + /// + /// 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 Func>> _asyncPopulator; + private CancellationTokenSource _populationCancellationTokenSource; + + 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); + + 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); + + 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); } + } + + 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 + /// 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(CancelEventArgs e) + { + DropDownOpening?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownOpened(EventArgs e) + { + DropDownOpened?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// that contains the event data. + protected virtual void OnDropDownClosing(CancelEventArgs e) + { + DropDownClosing?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// + /// which contains the event data. + protected virtual void OnDropDownClosed(EventArgs e) + { + DropDownClosed?.Invoke(this, e); + } + + /// + /// Raises the + /// + /// event. + /// + /// A + /// that contains the event data. + protected virtual void OnTextChanged(RoutedEventArgs e) + { + TextChanged?.Invoke(this, e); + } + + /// + /// Begin closing the drop-down. + /// + /// The original value. + private void ClosingDropDown(bool oldValue) + { + var args = new CancelEventArgs(); + OnDropDownClosing(args); + + if (args.Cancel) + { + _ignorePropertyChange = true; + 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 CancelEventArgs(); + + // 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; + + if(TryPopulateAsync(SearchText)) + { + return; + } + + // The Populated event enables advanced, custom filtering. The + // client needs to directly update the ItemsSource collection or + // call the Populate method on the control to continue the + // display process if Cancel is set to true. + PopulatingEventArgs populating = new PopulatingEventArgs(SearchText); + OnPopulating(populating); + if (!populating.Cancel) + { + PopulateComplete(); + } + } + private bool TryPopulateAsync(string searchText) + { + _populationCancellationTokenSource?.Cancel(false); + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + + if(_asyncPopulator == null) + { + return false; + } + + _populationCancellationTokenSource = new CancellationTokenSource(); + var task = PopulateAsync(searchText, _populationCancellationTokenSource.Token); + if (task.Status == TaskStatus.Created) + task.Start(); + + return true; + } + private async Task PopulateAsync(string searchText, CancellationToken cancellationToken) + { + + try + { + IEnumerable result = await _asyncPopulator.Invoke(searchText, cancellationToken); + var resultList = result.ToList(); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + if (!cancellationToken.IsCancellationRequested) + { + Items = resultList; + PopulateComplete(); + } + }); + } + catch (TaskCanceledException) + { } + finally + { + _populationCancellationTokenSource?.Dispose(); + _populationCancellationTokenSource = null; + } + + } + + /// + /// Private method that directly opens the popup, checks the expander + /// button, and then fires the Opened event. + /// + private void OpenDropDown() + { + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = true; + } + _popupHasOpened = true; + OnDropDownOpened(EventArgs.Empty); + } + + /// + /// Private method that directly closes the popup, flips the Checked + /// value, and then fires the Closed event. + /// + private void CloseDropDown() + { + if (_popupHasOpened) + { + if (SelectionAdapter != null) + { + SelectionAdapter.SelectedItem = null; + } + if (DropDownPopup != null) + { + DropDownPopup.IsOpen = false; + } + OnDropDownClosed(EventArgs.Empty); + } + } + + /// + /// Formats an Item for text comparisons based on Converter + /// and ConverterCulture properties. + /// + /// The object to format. + /// A value indicating whether to clear + /// the data context after the lookup is performed. + /// Formatted Value. + private string FormatValue(object value, bool clearDataContext) + { + string result = FormatValue(value); + if(clearDataContext && _valueBindingEvaluator != null) + { + _valueBindingEvaluator.ClearDataContext(); + } + + return result; + } + + /// + /// Converts the specified object to a string by using the + /// and + /// values + /// of the binding object specified by the + /// + /// property. + /// + /// The object to format as a string. + /// The string representation of the specified object. + /// + /// Override this method to provide a custom string conversion. + /// + protected virtual string FormatValue(object value) + { + if (_valueBindingEvaluator != null) + { + return _valueBindingEvaluator.GetDynamicValue(value) ?? String.Empty; + } + + 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/Border.cs b/src/Avalonia.Controls/Border.cs index 002c5ea3f2..8acb3603c9 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 Avalonia; +using Avalonia.Controls.Utils; using Avalonia.Media; namespace Avalonia.Controls @@ -8,7 +10,7 @@ namespace Avalonia.Controls /// /// A control which decorates a child with a border and background. /// - public class Border : Decorator + public partial class Border : Decorator { /// /// Defines the property. @@ -25,21 +27,24 @@ 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 BorderRenderHelper _borderRenderHelper = new BorderRenderHelper(); /// /// Initializes static members of the class. /// static Border() { - AffectsRender(BackgroundProperty, BorderBrushProperty); + AffectsRender(BackgroundProperty, BorderBrushProperty, BorderThicknessProperty, CornerRadiusProperty); + AffectsMeasure(BorderThicknessProperty); } /// @@ -63,7 +68,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 +77,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 +89,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); - } + _borderRenderHelper.Render(context, Bounds.Size, BorderThickness, CornerRadius, Background, BorderBrush); } /// @@ -120,10 +111,12 @@ namespace Avalonia.Controls { if (Child != null) { - var padding = Padding + new Thickness(BorderThickness); + var padding = Padding + BorderThickness; Child.Arrange(new Rect(finalSize).Deflate(padding)); } + _borderRenderHelper.Update(finalSize, BorderThickness, CornerRadius); + return finalSize; } @@ -131,19 +124,17 @@ 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); - } + + return new Size(padding.Left + padding.Right, padding.Bottom + padding.Top); } } } \ No newline at end of file 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; 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. /// 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); diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index dadd3b910b..96f6fb59b0 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -93,6 +93,7 @@ namespace Avalonia.Controls static MenuItem() { SelectableMixin.Attach(IsSelectedProperty); + CommandProperty.Changed.Subscribe(CommandChanged); FocusableProperty.OverrideDefaultValue(true); IconProperty.Changed.AddClassHandler(x => x.IconChanged); ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); @@ -424,6 +425,40 @@ namespace Avalonia.Controls } } + /// + /// Called when the property changes. + /// + /// The event args. + private static void CommandChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is MenuItem menuItem) + { + if (e.OldValue is ICommand oldCommand) + { + oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged; + } + + if (e.NewValue is ICommand newCommand) + { + newCommand.CanExecuteChanged += menuItem.CanExecuteChanged; + } + + menuItem.CanExecuteChanged(menuItem, EventArgs.Empty); + } + } + + /// + /// Called when the event fires. + /// + /// The event sender. + /// The event args. + private void CanExecuteChanged(object sender, EventArgs e) + { + // HACK: Just set the IsEnabled property for the moment. This needs to be changed to + // use IsEnabledCore etc. but it will do for now. + IsEnabled = Command == null || Command.CanExecute(CommandParameter); + } + /// /// Called when the property changes. /// diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs new file mode 100644 index 0000000000..59d2949b81 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -0,0 +1,998 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.Utilities; + +namespace Avalonia.Controls +{ + /// + /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. + /// + public class NumericUpDown : TemplatedControl + { + /// + /// 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 ClipValueToMinMaxProperty = + AvaloniaProperty.RegisterDirect(nameof(ClipValueToMinMax), + updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b); + + /// + /// Defines the property. + /// + public static readonly DirectProperty CultureInfoProperty = + AvaloniaProperty.RegisterDirect(nameof(CultureInfo), o => o.CultureInfo, + (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FormatStringProperty = + AvaloniaProperty.Register(nameof(FormatString), string.Empty); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IncrementProperty = + AvaloniaProperty.Register(nameof(Increment), 1.0d, validate: OnCoerceIncrement); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsReadOnly)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), double.MaxValue, validate: OnCoerceMaximum); + + /// + /// Defines the property. + /// + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), double.MinValue, validate: OnCoerceMinimum); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ParsingNumberStyleProperty = + AvaloniaProperty.RegisterDirect(nameof(ParsingNumberStyle), + updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style); + + /// + /// Defines the property. + /// + public static readonly DirectProperty TextProperty = + AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v, + defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ValueProperty = + AvaloniaProperty.RegisterDirect(nameof(Value), updown => updown.Value, + (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay); + + /// + /// Defines the property. + /// + public static readonly StyledProperty WatermarkProperty = + AvaloniaProperty.Register(nameof(Watermark)); + + private IDisposable _textBoxTextChangedSubscription; + + private double _value; + private string _text; + private bool _internalValueSet; + private bool _clipValueToMinMax; + private bool _isSyncingTextAndValueProperties; + private bool _isTextChangedFromUI; + private CultureInfo _cultureInfo; + private NumberStyles _parsingNumberStyle = NumberStyles.Any; + + /// + /// Gets the Spinner template part. + /// + private Spinner Spinner { get; set; } + + /// + /// Gets the TextBox template part. + /// + private TextBox TextBox { get; 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 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 double Increment + { + get { return GetValue(IncrementProperty); } + set { SetValue(IncrementProperty, 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 double Maximum + { + get { return GetValue(MaximumProperty); } + set { SetValue(MaximumProperty, value); } + } + + /// + /// Gets or sets the minimum allowed value. + /// + public double Minimum + { + get { return GetValue(MinimumProperty); } + set { SetValue(MinimumProperty, value); } + } + + /// + /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. + /// + public NumberStyles ParsingNumberStyle + { + get { return _parsingNumberStyle; } + set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, 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 double 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. + /// + public NumericUpDown() + { + Initialized += (sender, e) => + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + }; + } + + /// + /// Initializes static members of the class. + /// + static NumericUpDown() + { + CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged); + FormatStringProperty.Changed.Subscribe(FormatStringChanged); + IncrementProperty.Changed.Subscribe(IncrementChanged); + 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 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(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + } + + /// + /// 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(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + if (ClipValueToMinMax) + { + Value = MathUtilities.Clamp(Value, Minimum, Maximum); + } + } + + /// + /// Called when the property value changed. + /// + /// The old value. + /// The new value. + protected virtual void OnMinimumChanged(double oldValue, double newValue) + { + if (IsInitialized) + { + SetValidSpinDirection(); + } + if (ClipValueToMinMax) + { + Value = MathUtilities.Clamp(Value, Minimum, Maximum); + } + } + + /// + /// 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(double oldValue, double newValue) + { + if (!_internalValueSet && IsInitialized) + { + SyncTextAndValueProperties(false, null, true); + } + + SetValidSpinDirection(); + + RaiseValueChangedEvent(oldValue, newValue); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceIncrement(double baseValue) + { + return baseValue; + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceMaximum(double baseValue) + { + return Math.Max(baseValue, Minimum); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceMinimum(double baseValue) + { + return Math.Min(baseValue, Maximum); + } + + /// + /// Called when the property has to be coerced. + /// + /// The value. + protected virtual double OnCoerceValue(double 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(double oldValue, double newValue) + { + var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue); + RaiseEvent(e); + } + + /// + /// Converts the formatted text to a value. + /// + private double ConvertTextToValue(string text) + { + double result = 0; + + 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 MathUtilities.Clamp(result, Minimum, Maximum); + } + + ValidateMinMax(result); + + return result; + } + + /// + /// Converts the value to formatted text. + /// + /// + private string ConvertValueToText() + { + //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. + if (FormatString.Contains("{0")) + { + return string.Format(CultureInfo, FormatString, Value); + } + + return Value.ToString(FormatString, CultureInfo); + } + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Increase. + /// + private void OnIncrement() + { + var result = Value + Increment; + Value = MathUtilities.Clamp(result, Minimum, Maximum); + } + + /// + /// Called by OnSpin when the spin direction is SpinDirection.Descrease. + /// + private void OnDecrement() + { + var result = Value - Increment; + Value = MathUtilities.Clamp(result, Minimum, Maximum); + } + + /// + /// Sets the valid spin directions. + /// + private void SetValidSpinDirection() + { + var validDirections = ValidSpinDirections.None; + + // Zero increment always prevents spin. + if (Increment != 0 && !IsReadOnly) + { + if (Value < Maximum) + { + validDirections = validDirections | ValidSpinDirections.Increase; + } + + if (Value > Minimum) + { + validDirections = validDirections | ValidSpinDirections.Decrease; + } + } + + if (Spinner != null) + { + Spinner.ValidSpinDirection = validDirections; + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown 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 IncrementChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)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); + } + } + + /// + /// Called when the property value changed. + /// + /// The event args. + private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is NumericUpDown 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 NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)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 NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)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 NumericUpDown 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 NumericUpDown upDown) + { + var oldValue = (double)e.OldValue; + var newValue = (double)e.NewValue; + upDown.OnValueChanged(oldValue, newValue); + } + } + + private void SetValueInternal(double value) + { + _internalValueSet = true; + try + { + Value = value; + } + finally + { + _internalValueSet = false; + } + } + + private static double OnCoerceMaximum(NumericUpDown upDown, double value) + { + return upDown.OnCoerceMaximum(value); + } + + private static double OnCoerceMinimum(NumericUpDown upDown, double value) + { + return upDown.OnCoerceMinimum(value); + } + + private static double OnCoerceIncrement(NumericUpDown upDown, double value) + { + return upDown.OnCoerceIncrement(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); + } + } + } + + private void DoDecrement() + { + if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease) + { + OnDecrement(); + } + } + + private 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(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. + private 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)) + { + 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) + { + var keepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text); + if (!keepEmpty) + { + 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; + } + + private double ConvertTextToValueCore(string currentValueText, string text) + { + double result; + + if (IsPercent(FormatString)) + { + result = decimal.ToDouble(ParsePercent(text, CultureInfo)); + } + else + { + // Problem while converting new text + if (!double.TryParse(text, ParsingNumberStyle, CultureInfo, out var outputValue)) + { + var shouldThrow = true; + + // Check if CurrentValueText is also failing => it also contains special characters. ex : 90° + if (!double.TryParse(currentValueText, ParsingNumberStyle, CultureInfo, out var _)) + { + // 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 (double.TryParse(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 void ValidateMinMax(double value) + { + if (value < Minimum) + { + throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum)); + } + else if (value > Maximum) + { + throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum)); + } + } + + /// + /// Parse percent format text + /// + /// Text to parse. + /// The culture info. + private 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; + } + + + 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; + } + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs new file mode 100644 index 0000000000..e994ffdd15 --- /dev/null +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs @@ -0,0 +1,16 @@ +using Avalonia.Interactivity; + +namespace Avalonia.Controls +{ + public class NumericUpDownValueChangedEventArgs : RoutedEventArgs + { + public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, double oldValue, double newValue) : base(routedEvent) + { + OldValue = oldValue; + NewValue = newValue; + } + + public double OldValue { get; } + public double NewValue { get; } + } +} diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index d0a438cc2b..3cc750e20d 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -4,6 +4,7 @@ using System; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Controls.Utils; using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Media; @@ -31,7 +32,7 @@ namespace Avalonia.Controls.Presenters /// /// Defines the property. /// - public static readonly StyledProperty BorderThicknessProperty = + public static readonly StyledProperty BorderThicknessProperty = Border.BorderThicknessProperty.AddOwner(); /// @@ -57,7 +58,7 @@ namespace Avalonia.Controls.Presenters /// /// Defines the property. /// - public static readonly StyledProperty CornerRadiusProperty = + public static readonly StyledProperty CornerRadiusProperty = Border.CornerRadiusProperty.AddOwner(); /// @@ -76,17 +77,20 @@ namespace Avalonia.Controls.Presenters /// Defines the property. /// public static readonly StyledProperty PaddingProperty = - Border.PaddingProperty.AddOwner(); + Decorator.PaddingProperty.AddOwner(); private IControl _child; private bool _createdChild; private IDataTemplate _dataTemplate; + private readonly BorderRenderHelper _borderRenderer = new BorderRenderHelper(); /// /// Initializes static members of the class. /// 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); @@ -120,7 +124,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 +161,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 +225,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 +281,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 +334,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 +366,69 @@ 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)); - } - - size = LayoutHelper.ApplyLayoutConstraints(Child, size); + 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.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)); + } - 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); - } + if (verticalContentAlignment != VerticalAlignment.Stretch) + { + size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - padding.Top - padding.Bottom)); + } - 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: + 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: + 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, Math.Max(0, size.Width), Math.Max(0, size.Height))); + return finalSize; } 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.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index f73725f95a..34c6b1cfd6 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -85,6 +85,7 @@ namespace Avalonia.Controls private int _selectionEnd; private TextPresenter _presenter; private UndoRedoHelper _undoRedoHelper; + private bool _isUndoingRedoing; private bool _ignoreTextChanges; private static readonly string[] invalidCharacters = new String[1]{"\u007f"}; @@ -198,7 +199,11 @@ namespace Avalonia.Controls if (!_ignoreTextChanges) { CaretIndex = CoerceCaretIndex(CaretIndex, value?.Length ?? 0); - SetAndRaise(TextProperty, ref _text, value); + + if (SetAndRaise(TextProperty, ref _text, value) && !_isUndoingRedoing) + { + _undoRedoHelper.Clear(); + } } } } @@ -367,14 +372,30 @@ namespace Avalonia.Controls case Key.Z: if (modifiers == InputModifiers.Control) { - _undoRedoHelper.Undo(); + try + { + _isUndoingRedoing = true; + _undoRedoHelper.Undo(); + } + finally + { + _isUndoingRedoing = false; + } handled = true; } break; case Key.Y: if (modifiers == InputModifiers.Control) { - _undoRedoHelper.Redo(); + try + { + _isUndoingRedoing = true; + _undoRedoHelper.Redo(); + } + finally + { + _isUndoingRedoing = false; + } handled = true; } break; @@ -791,7 +812,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; diff --git a/src/Avalonia.Controls/Utils/BorderRenderHelper.cs b/src/Avalonia.Controls/Utils/BorderRenderHelper.cs new file mode 100644 index 0000000000..d9169e51f3 --- /dev/null +++ b/src/Avalonia.Controls/Utils/BorderRenderHelper.cs @@ -0,0 +1,279 @@ +// 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.Media; + +namespace Avalonia.Controls.Utils +{ + internal class BorderRenderHelper + { + 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 innerCoordinates = new BorderCoordinates(cornerRadius, borderThickness, false); + + StreamGeometry backgroundGeometry = null; + + if (innerRect.Width != 0 && innerRect.Height != 0) + { + backgroundGeometry = new StreamGeometry(); + + using (var ctx = backgroundGeometry.Open()) + { + CreateGeometry(ctx, innerRect, innerCoordinates); + } + + _backgroundGeometryCache = backgroundGeometry; + } + else + { + _backgroundGeometryCache = null; + } + + if (boundRect.Width != 0 && innerRect.Height != 0) + { + var outerCoordinates = new BorderCoordinates(cornerRadius, borderThickness, true); + var borderGeometry = new StreamGeometry(); + + using (var ctx = borderGeometry.Open()) + { + CreateGeometry(ctx, boundRect, outerCoordinates); + + if (backgroundGeometry != null) + { + CreateGeometry(ctx, innerRect, innerCoordinates); + } + } + + _borderGeometryCache = borderGeometry; + } + else + { + _borderGeometryCache = null; + } + } + } + + public void Render(DrawingContext context, Size size, Thickness borders, CornerRadius radii, IBrush background, IBrush borderBrush) + { + if (_useComplexRendering) + { + var backgroundGeometry = _backgroundGeometryCache; + if (backgroundGeometry != null) + { + context.DrawGeometry(background, null, backgroundGeometry); + } + + var borderGeometry = _borderGeometryCache; + if (borderGeometry != null) + { + context.DrawGeometry(borderBrush, null, borderGeometry); + } + } + 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 CreateGeometry(StreamGeometryContext context, Rect boundRect, BorderCoordinates borderCoordinates) + { + var topLeft = new Point(borderCoordinates.LeftTop, 0); + var topRight = new Point(boundRect.Width - borderCoordinates.RightTop, 0); + var rightTop = new Point(boundRect.Width, borderCoordinates.TopRight); + var rightBottom = new Point(boundRect.Width, boundRect.Height - borderCoordinates.BottomRight); + var bottomRight = new Point(boundRect.Width - borderCoordinates.RightBottom, boundRect.Height); + var bottomLeft = new Point(borderCoordinates.LeftBottom, boundRect.Height); + var leftBottom = new Point(0, boundRect.Height - borderCoordinates.BottomLeft); + var leftTop = new Point(0, borderCoordinates.TopLeft); + + + if (topLeft.X > topRight.X) + { + var scaledX = borderCoordinates.LeftTop / (borderCoordinates.LeftTop + borderCoordinates.RightTop) * boundRect.Width; + topLeft = new Point(scaledX, topLeft.Y); + topRight = new Point(scaledX, topRight.Y); + } + + if (rightTop.Y > rightBottom.Y) + { + var scaledY = borderCoordinates.TopRight / (borderCoordinates.TopRight + borderCoordinates.BottomRight) * boundRect.Height; + rightTop = new Point(rightTop.X, scaledY); + rightBottom = new Point(rightBottom.X, scaledY); + } + + if (bottomRight.X < bottomLeft.X) + { + var scaledX = borderCoordinates.LeftBottom / (borderCoordinates.LeftBottom + borderCoordinates.RightBottom) * boundRect.Width; + bottomRight = new Point(scaledX, bottomRight.Y); + bottomLeft = new Point(scaledX, bottomLeft.Y); + } + + if (leftBottom.Y < leftTop.Y) + { + var scaledY = borderCoordinates.TopLeft / (borderCoordinates.TopLeft + borderCoordinates.BottomLeft) * boundRect.Height; + leftBottom = new Point(leftBottom.X, scaledY); + leftTop = new Point(leftTop.X, scaledY); + } + + var offset = new Vector(boundRect.TopLeft.X, boundRect.TopLeft.Y); + topLeft += offset; + topRight += offset; + rightTop += offset; + rightBottom += offset; + bottomRight += offset; + bottomLeft += offset; + leftBottom += offset; + leftTop += offset; + + context.BeginFigure(topLeft, true); + + //Top + context.LineTo(topRight); + + //TopRight corner + var radiusX = boundRect.TopRight.X - topRight.X; + var radiusY = rightTop.Y - boundRect.TopRight.Y; + if (radiusX != 0 || radiusY != 0) + { + context.ArcTo(rightTop, new Size(radiusY, radiusY), 0, false, SweepDirection.Clockwise); + } + + //Right + context.LineTo(rightBottom); + + //BottomRight corner + radiusX = boundRect.BottomRight.X - bottomRight.X; + radiusY = boundRect.BottomRight.Y - rightBottom.Y; + if (radiusX != 0 || radiusY != 0) + { + context.ArcTo(bottomRight, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + //Bottom + context.LineTo(bottomLeft); + + //BottomLeft corner + radiusX = bottomLeft.X - boundRect.BottomLeft.X; + radiusY = boundRect.BottomLeft.Y - leftBottom.Y; + if (radiusX != 0 || radiusY != 0) + { + context.ArcTo(leftBottom, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + //Left + context.LineTo(leftTop); + + //TopLeft corner + radiusX = topLeft.X - boundRect.TopLeft.X; + radiusY = leftTop.Y - boundRect.TopLeft.Y; + + if (radiusX != 0 || radiusY != 0) + { + context.ArcTo(topLeft, new Size(radiusX, radiusY), 0, false, SweepDirection.Clockwise); + } + + context.EndFigure(true); + } + + private struct BorderCoordinates + { + internal BorderCoordinates(CornerRadius cornerRadius, Thickness borderThickness, bool isOuter) + { + var left = 0.5 * borderThickness.Left; + var top = 0.5 * borderThickness.Top; + var right = 0.5 * borderThickness.Right; + var bottom = 0.5 * borderThickness.Bottom; + + if (isOuter) + { + if (cornerRadius.TopLeft == 0) + { + LeftTop = TopLeft = 0.0; + } + else + { + LeftTop = cornerRadius.TopLeft + left; + TopLeft = cornerRadius.TopLeft + top; + } + if (cornerRadius.TopRight == 0) + { + TopRight = RightTop = 0; + } + else + { + TopRight = cornerRadius.TopRight + top; + RightTop = cornerRadius.TopRight + right; + } + if (cornerRadius.BottomRight == 0) + { + RightBottom = BottomRight = 0; + } + else + { + RightBottom = cornerRadius.BottomRight + right; + BottomRight = cornerRadius.BottomRight + bottom; + } + if (cornerRadius.BottomLeft == 0) + { + BottomLeft = LeftBottom = 0; + } + else + { + BottomLeft = cornerRadius.BottomLeft + bottom; + LeftBottom = cornerRadius.BottomLeft + left; + } + } + else + { + LeftTop = Math.Max(0, cornerRadius.TopLeft - left); + TopLeft = Math.Max(0, cornerRadius.TopLeft - top); + TopRight = Math.Max(0, cornerRadius.TopRight - top); + RightTop = Math.Max(0, cornerRadius.TopRight - right); + RightBottom = Math.Max(0, cornerRadius.BottomRight - right); + BottomRight = Math.Max(0, cornerRadius.BottomRight - bottom); + BottomLeft = Math.Max(0, cornerRadius.BottomLeft - bottom); + LeftBottom = Math.Max(0, cornerRadius.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/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.Controls/Utils/UndoRedoHelper.cs b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs index be4c1aa6c4..c76555e554 100644 --- a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs +++ b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs @@ -91,6 +91,11 @@ namespace Avalonia.Controls.Utils } } + public void Clear() + { + _states.Clear(); + } + bool WeakTimer.IWeakTimerSubscriber.Tick() { Snapshot(); 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 diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index cb86598a42..4c85e172ff 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -20,7 +20,7 @@ Red #10ff0000 - 2 + 2 0.5 10 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..2b9132ee56 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -23,7 +23,7 @@ - + @@ -43,4 +43,6 @@ + + diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 1cd9587297..66f226d2f6 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -133,4 +133,8 @@ + + \ No newline at end of file diff --git a/src/Avalonia.Themes.Default/NumericUpDown.xaml b/src/Avalonia.Themes.Default/NumericUpDown.xaml new file mode 100644 index 0000000000..e6325d07dc --- /dev/null +++ b/src/Avalonia.Themes.Default/NumericUpDown.xaml @@ -0,0 +1,41 @@ + + + \ No newline at end of file diff --git a/src/Avalonia.Visuals/CornerRadius.cs b/src/Avalonia.Visuals/CornerRadius.cs new file mode 100644 index 0000000000..db0ac97d49 --- /dev/null +++ b/src/Avalonia.Visuals/CornerRadius.cs @@ -0,0 +1,97 @@ +// 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.Globalization; +using System.Linq; + +namespace Avalonia +{ + public struct CornerRadius + { + public CornerRadius(double uniformRadius) + { + TopLeft = TopRight = BottomLeft = BottomRight = uniformRadius; + + } + public CornerRadius(double top, double bottom) + { + TopLeft = TopRight = top; + BottomLeft = BottomRight = bottom; + } + public CornerRadius(double topLeft, double topRight, double bottomRight, double bottomLeft) + { + TopLeft = topLeft; + TopRight = topRight; + BottomRight = bottomRight; + BottomLeft = bottomLeft; + } + + public double TopLeft { get; } + public double TopRight { get; } + public double BottomRight { get; } + public double BottomLeft { get; } + public bool IsEmpty => TopLeft.Equals(0) && IsUniform; + public bool IsUniform => TopLeft.Equals(TopRight) && BottomLeft.Equals(BottomRight) && TopRight.Equals(BottomRight); + + public override bool Equals(object obj) + { + if (obj is CornerRadius) + { + return this == (CornerRadius)obj; + } + return false; + } + + public override int GetHashCode() + { + return TopLeft.GetHashCode() ^ TopRight.GetHashCode() ^ BottomLeft.GetHashCode() ^ BottomRight.GetHashCode(); + } + + public override string ToString() + { + return $"{TopLeft},{TopRight},{BottomRight},{BottomLeft}"; + } + + public static CornerRadius Parse(string s, CultureInfo culture) + { + var parts = s.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .ToList(); + + switch (parts.Count) + { + case 1: + var uniform = double.Parse(parts[0], culture); + return new CornerRadius(uniform); + case 2: + var top = double.Parse(parts[0], culture); + var bottom = double.Parse(parts[1], culture); + return new CornerRadius(top, bottom); + case 4: + var topLeft = double.Parse(parts[0], culture); + var topRight = double.Parse(parts[1], culture); + var bottomRight = double.Parse(parts[2], culture); + var bottomLeft = double.Parse(parts[3], culture); + return new CornerRadius(topLeft, topRight, bottomRight, bottomLeft); + default: + { + throw new FormatException("Invalid CornerRadius."); + } + } + } + + public static bool operator ==(CornerRadius cr1, CornerRadius cr2) + { + return cr1.TopLeft.Equals(cr2.TopLeft) + && cr1.TopRight.Equals(cr2.TopRight) + && cr1.BottomRight.Equals(cr2.BottomRight) + && cr1.BottomLeft.Equals(cr2.BottomLeft); + } + + public static bool operator !=(CornerRadius cr1, CornerRadius cr2) + { + return !(cr1 == cr2); + } + } +} \ No newline at end of file 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. diff --git a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs index 87347d64b1..900746d05a 100644 --- a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs @@ -9,6 +9,7 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Visuals.UnitTests")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Animation")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")] [assembly: InternalsVisibleTo("Avalonia.Direct2D1.RenderTests")] [assembly: InternalsVisibleTo("Avalonia.Skia.RenderTests")] \ No newline at end of file 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/src/Avalonia.Visuals/Thickness.cs b/src/Avalonia.Visuals/Thickness.cs index dc9be7341d..ead9fd004a 100644 --- a/src/Avalonia.Visuals/Thickness.cs +++ b/src/Avalonia.Visuals/Thickness.cs @@ -90,7 +90,12 @@ namespace Avalonia /// /// Gets a value indicating whether all sides are set to 0. /// - public bool IsEmpty => Left == 0 && Top == 0 && Right == 0 && Bottom == 0; + public bool IsEmpty => Left.Equals(0) && IsUniform; + + /// + /// Gets a value indicating whether all sides are equal. + /// + public bool IsUniform => Left.Equals(Right) && Top.Equals(Bottom) && Right.Equals(Bottom); /// /// Compares two Thicknesses. diff --git a/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj b/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj index 0ce2a1a992..bd6acfdad1 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj +++ b/src/Markup/Avalonia.Markup.Xaml/Avalonia.Markup.Xaml.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Markup/Avalonia.Markup.Xaml/Converters/CornerRadiusTypeConverter.cs b/src/Markup/Avalonia.Markup.Xaml/Converters/CornerRadiusTypeConverter.cs new file mode 100644 index 0000000000..5da0efae1b --- /dev/null +++ b/src/Markup/Avalonia.Markup.Xaml/Converters/CornerRadiusTypeConverter.cs @@ -0,0 +1,19 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Avalonia.Markup.Xaml.Converters +{ + public class CornerRadiusTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return CornerRadius.Parse((string)value, culture); + } + } +} \ No newline at end of file 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)) }; diff --git a/src/Markup/Avalonia.Markup.Xaml/PortableXaml/AvaloniaDefaultTypeConverters.cs b/src/Markup/Avalonia.Markup.Xaml/PortableXaml/AvaloniaDefaultTypeConverters.cs index 1cf5b6a58e..1ae24c8a34 100644 --- a/src/Markup/Avalonia.Markup.Xaml/PortableXaml/AvaloniaDefaultTypeConverters.cs +++ b/src/Markup/Avalonia.Markup.Xaml/PortableXaml/AvaloniaDefaultTypeConverters.cs @@ -43,6 +43,7 @@ namespace Avalonia.Markup.Xaml.PortableXaml { typeof(Selector), typeof(SelectorTypeConverter)}, { typeof(SolidColorBrush), typeof(BrushTypeConverter) }, { typeof(Thickness), typeof(ThicknessTypeConverter) }, + { typeof(CornerRadius), typeof(CornerRadiusTypeConverter) }, { typeof(TimeSpan), typeof(TimeSpanTypeConverter) }, //{ typeof(Uri), typeof(Converters.UriTypeConverter) }, { typeof(Cursor), typeof(CursorTypeConverter) }, diff --git a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs index 8f11d1463b..120ab71ead 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -1,7 +1,6 @@ // 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.Platform; using SharpDX.Direct2D1; @@ -20,14 +19,12 @@ namespace Avalonia.Direct2D1.Media /// public Rect Bounds => Geometry.GetWidenedBounds(0).ToAvalonia(); - /// public Geometry Geometry { get; } /// 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 +48,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) diff --git a/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs b/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs index 33af55fdf9..d24a646f74 100644 --- a/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs +++ b/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs @@ -33,7 +33,7 @@ namespace Avalonia.Benchmarks.Styling var border = (Border)textBox.GetVisualChildren().Single(); - if (border.BorderThickness != 2) + if (border.BorderThickness != new Thickness(2)) { throw new Exception("Styles not applied."); } 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; + }); + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/BorderTests.cs b/tests/Avalonia.Controls.UnitTests/BorderTests.cs index c0d2a39ab2..9a6a041ec7 100644 --- a/tests/Avalonia.Controls.UnitTests/BorderTests.cs +++ b/tests/Avalonia.Controls.UnitTests/BorderTests.cs @@ -13,12 +13,34 @@ namespace Avalonia.Controls.UnitTests var target = new Border { Padding = new Thickness(6), - BorderThickness = 4, + BorderThickness = new Thickness(4) }; target.Measure(new Size(100, 100)); 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, + Child = content = new Border + { + Height = 0, + Width = 0 + } + }; + + target.Arrange(new Rect(0, 0, 100, 100)); + + 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 2ab02a0418..2c1074aa9a 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() { @@ -185,5 +210,30 @@ 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 = new Thickness(32), + MaxHeight = 32, + MaxWidth = 32, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Content = content = new Border + { + Height = 0, + Width = 0, + }, + }; + + target.UpdateChild(); + + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Rect(48, 48, 0, 0), content.Bounds); + } } } \ No newline at end of file diff --git a/tests/Avalonia.RenderTests/Controls/BorderTests.cs b/tests/Avalonia.RenderTests/Controls/BorderTests.cs index 3bd5a6e1cb..7d2e40c3b4 100644 --- a/tests/Avalonia.RenderTests/Controls/BorderTests.cs +++ b/tests/Avalonia.RenderTests/Controls/BorderTests.cs @@ -31,7 +31,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 1, + BorderThickness = new Thickness(1), } }; @@ -50,7 +50,47 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), + } + }; + + await RenderToFile(target); + 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), } }; @@ -87,7 +127,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new Border { Background = Brushes.Red, @@ -110,7 +150,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Padding = new Thickness(2), Child = new Border { @@ -134,7 +174,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new Border { Background = Brushes.Red, @@ -159,7 +199,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -186,7 +226,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -213,7 +253,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -240,7 +280,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -267,7 +307,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -294,7 +334,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -321,7 +361,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", @@ -348,7 +388,7 @@ namespace Avalonia.Direct2D1.RenderTests.Controls Child = new Border { BorderBrush = Brushes.Black, - BorderThickness = 2, + BorderThickness = new Thickness(2), Child = new TextBlock { Text = "Foo", diff --git a/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs b/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs index 099b022862..cfa15ae304 100644 --- a/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs +++ b/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs @@ -42,7 +42,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media new Border { BorderBrush = Brushes.Blue, - BorderThickness = 2, + BorderThickness = new Thickness(2), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Child = new TextBlock diff --git a/tests/Avalonia.RenderTests/Shapes/PathTests.cs b/tests/Avalonia.RenderTests/Shapes/PathTests.cs index fab867f428..4703daca25 100644 --- a/tests/Avalonia.RenderTests/Shapes/PathTests.cs +++ b/tests/Avalonia.RenderTests/Shapes/PathTests.cs @@ -316,7 +316,7 @@ namespace Avalonia.Direct2D1.RenderTests.Shapes Child = new Border { BorderBrush = Brushes.Red, - BorderThickness = 1, + BorderThickness = new Thickness(1), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Child = new Path diff --git a/tests/Avalonia.Styling.UnitTests/StyleTests.cs b/tests/Avalonia.Styling.UnitTests/StyleTests.cs index a7c559668b..5ef559b887 100644 --- a/tests/Avalonia.Styling.UnitTests/StyleTests.cs +++ b/tests/Avalonia.Styling.UnitTests/StyleTests.cs @@ -151,7 +151,7 @@ namespace Avalonia.Styling.UnitTests { Setters = new[] { - new Setter(Border.BorderThicknessProperty, 4), + new Setter(Border.BorderThicknessProperty, new Thickness(4)), } }; @@ -162,9 +162,9 @@ namespace Avalonia.Styling.UnitTests style.Attach(border, null); - Assert.Equal(4, border.BorderThickness); + Assert.Equal(new Thickness(4), border.BorderThickness); root.Child = null; - Assert.Equal(0, border.BorderThickness); + Assert.Equal(new Thickness(0), border.BorderThickness); } private class Class1 : Control diff --git a/tests/Avalonia.Visuals.UnitTests/CornerRadiusTests.cs b/tests/Avalonia.Visuals.UnitTests/CornerRadiusTests.cs new file mode 100644 index 0000000000..bc0bbdc867 --- /dev/null +++ b/tests/Avalonia.Visuals.UnitTests/CornerRadiusTests.cs @@ -0,0 +1,43 @@ +// 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.Globalization; +using Xunit; + +namespace Avalonia.Visuals.UnitTests +{ + public class CornerRadiusTests + { + [Fact] + public void Parse_Parses_Single_Uniform_Radius() + { + var result = CornerRadius.Parse("3.4", CultureInfo.InvariantCulture); + + Assert.Equal(new CornerRadius(3.4), result); + } + + [Fact] + public void Parse_Parses_Top_Bottom() + { + var result = CornerRadius.Parse("1.1,2.2", CultureInfo.InvariantCulture); + + Assert.Equal(new CornerRadius(1.1, 2.2), result); + } + + [Fact] + public void Parse_Parses_TopLeft_TopRight_BottomRight_BottomLeft() + { + var result = CornerRadius.Parse("1.1,2.2,3.3,4.4", CultureInfo.InvariantCulture); + + Assert.Equal(new CornerRadius(1.1, 2.2, 3.3, 4.4), result); + } + + [Fact] + public void Parse_Accepts_Spaces() + { + var result = CornerRadius.Parse("1.1 2.2 3.3 4.4", CultureInfo.InvariantCulture); + + Assert.Equal(new CornerRadius(1.1, 2.2, 3.3, 4.4), result); + } + } +} \ No newline at end of file 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() { diff --git a/tests/Avalonia.Visuals.UnitTests/ThicknessTests.cs b/tests/Avalonia.Visuals.UnitTests/ThicknessTests.cs index bd694d073a..03bf395d1e 100644 --- a/tests/Avalonia.Visuals.UnitTests/ThicknessTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/ThicknessTests.cs @@ -4,7 +4,7 @@ using System.Globalization; using Xunit; -namespace Avalonia.Visuals.UnitTests.Media +namespace Avalonia.Visuals.UnitTests { public class ThicknessTests { @@ -40,4 +40,4 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(new Thickness(1.2, 3.4, 5, 6), result); } } -} +} \ No newline at end of file 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 0000000000..9deb45aaeb Binary files /dev/null and b/tests/TestFiles/Direct2D1/Controls/Border/Border_NonUniform_CornerRadius.expected.png differ 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 0000000000..a4bfa75eb8 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Controls/Border/Border_Uniform_CornerRadius.expected.png differ 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 0000000000..9deb45aaeb Binary files /dev/null and b/tests/TestFiles/Skia/Controls/Border/Border_NonUniform_CornerRadius.expected.png differ 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 0000000000..a4bfa75eb8 Binary files /dev/null and b/tests/TestFiles/Skia/Controls/Border/Border_Uniform_CornerRadius.expected.png differ