From c37144b59633f8f52f917a77f5bceb7c0e678647 Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 13:03:26 +0300 Subject: [PATCH 1/6] add failing unit tests for #4306 --- .../ListBoxTests_Multiple.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs new file mode 100644 index 0000000000..7c7cdd08db --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs @@ -0,0 +1,126 @@ +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class ListBoxTests_Multiple + { + [Fact] + public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Shift + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Item_With_Ctrl_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Selected_Item_With_Ctrl_And_Arrow_Key_Should_Remove_From_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItems.Add("Foo"); + target.SelectedItems.Add("Bar"); + + target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Bar" }, target.SelectedItems); + } + + private Control CreateListBoxTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollViewer + { + Template = new FuncControlTemplate(CreateScrollViewerTemplate), + Content = new ItemsPresenter + { + Name = "PART_ItemsPresenter", + [~ItemsPresenter.ItemsProperty] = parent.GetObservable(ItemsControl.ItemsProperty).ToBinding(), + }.RegisterInNameScope(scope) + }; + } + + private Control CreateScrollViewerTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.ContentProperty] = + parent.GetObservable(ContentControl.ContentProperty).ToBinding(), + }.RegisterInNameScope(scope); + } + + private void ApplyTemplate(ListBox target) + { + // Apply the template to the ListBox itself. + target.ApplyTemplate(); + + // Then to its inner ScrollViewer. + var scrollViewer = (ScrollViewer)target.GetVisualChildren().Single(); + scrollViewer.ApplyTemplate(); + + // Then make the ScrollViewer create its child. + ((ContentPresenter)scrollViewer.Presenter).UpdateChild(); + + // Now the ItemsPresenter should be reigstered, so apply its template. + target.Presenter.ApplyTemplate(); + } + } +} From c08d88afcff791837d28b0a1dd73105da0d1cc6e Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 12:47:26 +0300 Subject: [PATCH 2/6] pass key modifiers to focus --- src/Avalonia.Controls/ItemsControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 6e0ad66699..1aa7945901 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -295,7 +295,7 @@ namespace Avalonia.Controls if (next != null) { - focus.Focus(next, NavigationMethod.Directional); + focus.Focus(next, NavigationMethod.Directional, e.KeyModifiers); e.Handled = true; } From e5c373acd06489b0e715282158488712259723eb Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 12:48:12 +0300 Subject: [PATCH 3/6] pass ctrl key state to selection logic in listbox --- src/Avalonia.Controls/ListBox.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 3c21cd2c38..a085bfb6bc 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -136,7 +136,8 @@ namespace Avalonia.Controls e.Handled = UpdateSelectionFromEventSource( e.Source, true, - (e.KeyModifiers & KeyModifiers.Shift) != 0); + (e.KeyModifiers & KeyModifiers.Shift) != 0, + (e.KeyModifiers & KeyModifiers.Control) != 0); } } From a91d6322112c679e96dc8968280bf62832139b9e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 16:33:09 +0200 Subject: [PATCH 4/6] Move ListBox page view model to its own file. Also remove `SelectedItem` and `SelectedItems` in favor of `SelectionModel`. --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 3 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 68 +------------------ .../ViewModels/ListBoxPageViewModel.cs | 65 ++++++++++++++++++ 3 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index f4d81418ac..edf3d41bf5 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -11,8 +11,7 @@ Spacing="16"> (Enumerable.Range(1, 10000).Select(i => GenerateItem())); - SelectedItems = new ObservableCollection(); - - AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); - - RemoveItemCommand = ReactiveCommand.Create(() => - { - while (SelectedItems.Count > 0) - { - Items.Remove(SelectedItems[0]); - } - }); - - SelectRandomItemCommand = ReactiveCommand.Create(() => - { - var random = new Random(); - - SelectedItem = Items[random.Next(Items.Count - 1)]; - }); - } - - public ObservableCollection Items { get; } - - private string _selectedItem; - - public string SelectedItem - { - get { return _selectedItem; } - set { this.RaiseAndSetIfChanged(ref _selectedItem, value); } - } - - - public ObservableCollection SelectedItems { get; } - - public ReactiveCommand AddItemCommand { get; } - - public ReactiveCommand RemoveItemCommand { get; } - - public ReactiveCommand SelectRandomItemCommand { get; } - - public SelectionMode SelectionMode - { - get => _selectionMode; - set - { - SelectedItems.Clear(); - this.RaiseAndSetIfChanged(ref _selectionMode, value); - } - } - - private string GenerateItem() => $"Item {_counter++.ToString()}"; - } } } diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs new file mode 100644 index 0000000000..6bdb5c0103 --- /dev/null +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using Avalonia.Controls; +using ReactiveUI; + +namespace ControlCatalog.ViewModels +{ + public class ListBoxPageViewModel : ReactiveObject + { + private int _counter; + private SelectionMode _selectionMode; + + public ListBoxPageViewModel() + { + Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); + Selection = new SelectionModel(); + Selection.Select(1); + + AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); + + RemoveItemCommand = ReactiveCommand.Create(() => + { + while (Selection.SelectedItems.Count > 0) + { + Items.Remove((string)Selection.SelectedItems.First()); + } + }); + + SelectRandomItemCommand = ReactiveCommand.Create(() => + { + var random = new Random(); + + using (Selection.Update()) + { + Selection.ClearSelection(); + Selection.Select(random.Next(Items.Count - 1)); + } + }); + } + + public ObservableCollection Items { get; } + + public SelectionModel Selection { get; } + + public ReactiveCommand AddItemCommand { get; } + + public ReactiveCommand RemoveItemCommand { get; } + + public ReactiveCommand SelectRandomItemCommand { get; } + + public SelectionMode SelectionMode + { + get => _selectionMode; + set + { + Selection.ClearSelection(); + this.RaiseAndSetIfChanged(ref _selectionMode, value); + } + } + + private string GenerateItem() => $"Item {_counter++.ToString()}"; + } +} From d2af884aedf68f4534e2afa20e4c282cc4e7eba2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 17:13:02 +0200 Subject: [PATCH 5/6] Added a failing test for #4272. --- .../Utils/SelectedItemsSyncTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 3ab5950974..4aa7e24aa7 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -208,6 +208,20 @@ namespace Avalonia.Controls.UnitTests.Utils target.SetItems(new[] { "foo", "bar", "baz" })); } + [Fact] + public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source() + { + var model = new SelectionModel(); + var target = new SelectedItemsSync(model); + var items = new AvaloniaList { "foo", "bar", "baz" }; + var selectedItems = new AvaloniaList { "bar" }; + + target.SetItems(selectedItems); + model.Source = items; + + Assert.Equal(new IndexPath(1), model.SelectedIndex); + } + private static SelectedItemsSync CreateTarget( IEnumerable items = null) { From 0f9ac73b4fc43dde5384e7eb4825a87e0266446f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 17:13:18 +0200 Subject: [PATCH 6/6] Handle uninitialized Source in SelectedItemsSync. If the `SelectionModel` passed to `SelectedItemsSync` has not yet had a `Source` assigned, set a flag to write the selected items to the `SelectionModel` when `Source` is set. Fixes #4272 --- .../Utils/SelectedItemsSync.cs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index c127771990..91cef9fe64 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; using Avalonia.Collections; @@ -16,6 +17,7 @@ namespace Avalonia.Controls.Utils private IList? _items; private bool _updatingItems; private bool _updatingModel; + private bool _initializeOnSourceAssignment; public SelectedItemsSync(ISelectionModel model) { @@ -63,10 +65,18 @@ namespace Avalonia.Controls.Utils _updatingModel = true; _items = items; - using (Model.Update()) + if (Model.Source is object) { - Model.ClearSelection(); - Add(items); + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + } + else if (!_initializeOnSourceAssignment) + { + Model.PropertyChanged += SelectionModelPropertyChanged; + _initializeOnSourceAssignment = true; } if (_items is INotifyCollectionChanged incc2) @@ -86,9 +96,11 @@ namespace Avalonia.Controls.Utils if (_items != null) { + Model.PropertyChanged -= SelectionModelPropertyChanged; Model.SelectionChanged -= SelectionModelSelectionChanged; Model = model; Model.SelectionChanged += SelectionModelSelectionChanged; + _initializeOnSourceAssignment = false; try { @@ -175,6 +187,25 @@ namespace Avalonia.Controls.Utils } } + private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_initializeOnSourceAssignment && + _items != null && + e.PropertyName == nameof(SelectionModel.Source)) + { + try + { + _updatingModel = true; + Add(_items); + _initializeOnSourceAssignment = false; + } + finally + { + _updatingModel = false; + } + } + } + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) { if (_updatingModel)