From 06bfa29b12c8ffbaaeac94b350d0e564bbb3934b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 19 May 2023 13:13:47 +0200 Subject: [PATCH] Properly implement keyboard selection. --- src/Avalonia.Controls/ListBox.cs | 8 +- .../Primitives/SelectingItemsControl.cs | 22 ++- .../VirtualizingStackPanel.cs | 6 +- .../ListBoxTests.cs | 141 ++++++++++++++++++ .../ListBoxTests_Multiple.cs | 118 +++++++++++++-- 5 files changed, 274 insertions(+), 21 deletions(-) diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index b2cc1977ea..f3f0b135b8 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -128,8 +128,11 @@ namespace Avalonia.Controls protected override void OnKeyDown(KeyEventArgs e) { var hotkeys = AvaloniaLocator.Current.GetService(); + var ctrl = hotkeys is not null && e.KeyModifiers.HasAllFlags(hotkeys.CommandModifiers); - if (e.Key.ToNavigationDirection() is { } direction) + if (!ctrl && + e.Key.ToNavigationDirection() is { } direction && + direction.IsDirectional()) { e.Handled |= MoveSelection( direction, @@ -144,12 +147,11 @@ namespace Avalonia.Controls } else if (e.Key == Key.Space || e.Key == Key.Enter) { - var toggle = hotkeys is not null && e.KeyModifiers.HasAllFlags(hotkeys.CommandModifiers); e.Handled |= UpdateSelectionFromEventSource( e.Source, true, e.KeyModifiers.HasFlag(KeyModifiers.Shift), - toggle); + ctrl); } base.OnKeyDown(e); diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 59ea8c2e9d..64ecb4321e 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -704,7 +704,8 @@ namespace Avalonia.Controls.Primitives bool wrap = false, bool rangeModifier = false) { - var from = ContainerFromIndex(Selection.AnchorIndex); + var from = GetContainerFromEventSource(FocusManager.Instance?.Current) ?? + ContainerFromIndex(Selection.AnchorIndex); return MoveSelection(from, direction, wrap, rangeModifier); } @@ -722,14 +723,29 @@ namespace Avalonia.Controls.Primitives bool wrap = false, bool rangeModifier = false) { - if (Presenter?.Panel is INavigableContainer container && - GetNextControl(container, direction, from, wrap) is Control next) + if (Presenter?.Panel is not INavigableContainer container) + return false; + + if (from is null) + { + direction = direction switch + { + NavigationDirection.Down => NavigationDirection.First, + NavigationDirection.Up => NavigationDirection.Last, + NavigationDirection.Right => NavigationDirection.First, + NavigationDirection.Left => NavigationDirection.Last, + _ => direction, + }; + } + + if (GetNextControl(container, direction, from, wrap) is Control next) { var index = IndexFromContainer(next); if (index != -1) { UpdateSelection(index, true, rangeModifier); + next.Focus(); return true; } } diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 7ec1808e63..2e5113d341 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -265,12 +265,14 @@ namespace Avalonia.Controls protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) { var count = Items.Count; + var fromControl = from as Control; - if (count == 0 || from is not Control fromControl) + if (count == 0 || + fromControl is null && direction is not NavigationDirection.First or NavigationDirection.Last) return null; var horiz = Orientation == Orientation.Horizontal; - var fromIndex = from != null ? IndexFromContainer(fromControl) : -1; + var fromIndex = fromControl != null ? IndexFromContainer(fromControl) : -1; var toIndex = fromIndex; switch (direction) diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index 98e5017fa9..0e957f9360 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -12,6 +12,7 @@ using Avalonia.Data; using Avalonia.Input; using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.Markup.Xaml.Templates; using Avalonia.Styling; using Avalonia.UnitTests; using Avalonia.VisualTree; @@ -752,6 +753,146 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(Enumerable.Range(0, 10).Select(x => $"Item{10 - x}"), realized); } + [Fact] + public void Arrow_Keys_Should_Move_Selection_Vertical() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray(); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = items, + ItemTemplate = new FuncDataTemplate((x, _) => new TextBlock { Height = 10 }), + SelectedIndex = 0, + }; + + Prepare(target); + + RaiseKeyEvent(target, Key.Down); + Assert.Equal(1, target.SelectedIndex); + + RaiseKeyEvent(target, Key.Down); + Assert.Equal(2, target.SelectedIndex); + + RaiseKeyEvent(target, Key.Up); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Arrow_Keys_Should_Move_Selection_Horizontal() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray(); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = items, + ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel + { + Orientation = Orientation.Horizontal + }), + ItemTemplate = new FuncDataTemplate((x, _) => new TextBlock { Height = 10 }), + SelectedIndex = 0, + }; + + Prepare(target); + + RaiseKeyEvent(target, Key.Right); + Assert.Equal(1, target.SelectedIndex); + + RaiseKeyEvent(target, Key.Right); + Assert.Equal(2, target.SelectedIndex); + + RaiseKeyEvent(target, Key.Left); + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Arrow_Keys_Should_Focus_Selection() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var items = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToArray(); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = items, + ItemTemplate = new FuncDataTemplate((x, _) => new TextBlock { Height = 10 }), + SelectedIndex = 0, + }; + + Prepare(target); + + RaiseKeyEvent(target, Key.Down); + Assert.True(target.ContainerFromIndex(1).IsFocused); + + RaiseKeyEvent(target, Key.Down); + Assert.True(target.ContainerFromIndex(2).IsFocused); + + RaiseKeyEvent(target, Key.Up); + Assert.True(target.ContainerFromIndex(1).IsFocused); + } + + [Fact] + public void Down_Key_Selecting_From_No_Selection_And_No_Focus_Selects_From_Start() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + Width = 100, + Height = 100, + }; + + Prepare(target); + + RaiseKeyEvent(target, Key.Down); + + Assert.Equal(0, target.SelectedIndex); + } + + [Fact] + public void Down_Key_Selecting_From_No_Selection_Selects_From_Focus() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + Width = 100, + Height = 100, + }; + + Prepare(target); + + target.ContainerFromIndex(1)!.Focus(); + RaiseKeyEvent(target, Key.Down); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Ctrl_Down_Key_Moves_Focus_But_Not_Selection() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = ListBoxTemplate(), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + Width = 100, + Height = 100, + SelectedIndex = 0, + }; + + Prepare(target); + + target.ContainerFromIndex(0)!.Focus(); + RaiseKeyEvent(target, Key.Down, KeyModifiers.Control); + + Assert.Equal(0, target.SelectedIndex); + Assert.True(target.ContainerFromIndex(1).IsFocused); + } + [Fact] public void WrapSelection_Should_Wrap() { diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs index 1b1671a69c..ed19d1dc97 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs @@ -479,6 +479,104 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, target.SelectedItems.Count); } } + + [Fact] + public void Shift_Arrow_Key_Selects_Range() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + Width = 100, + Height = 100, + SelectedIndex = 0, + }; + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar", }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(1).IsFocused); + + RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar", "Baz", }, target.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(2).IsFocused); + + RaiseKeyEvent(target, Key.Up, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar", }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(1).IsFocused); + } + + [Fact] + public void Shift_Down_Key_Selecting_Selects_Range_End_From_Focus() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = new[] { "Foo", "Bar", "Baz" }, + SelectionMode = SelectionMode.Multiple, + Width = 100, + Height = 100, + SelectedIndex = 0, + }; + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.ContainerFromIndex(1)!.Focus(); + RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(2).IsFocused); + } + + [Fact] + public void Shift_Down_Key_Selecting_Selects_Range_End_From_Focus_Moved_With_Ctrl_Key() + { + using var app = UnitTestApplication.Start(TestServices.RealFocus); + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = new[] { "Foo", "Bar", "Baz", "Qux" }, + SelectionMode = SelectionMode.Multiple, + Width = 100, + Height = 100, + SelectedIndex = 0, + }; + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(1).IsFocused); + + RaiseKeyEvent(target, Key.Down, KeyModifiers.Control); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(2).IsFocused); + + RaiseKeyEvent(target, Key.Down, KeyModifiers.Shift); + + Assert.Equal(new[] { "Foo", "Bar", "Baz", "Qux" }, target.SelectedItems); + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedContainers(target)); + Assert.True(target.ContainerFromIndex(3).IsFocused); + } + private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope) { return new ScrollViewer @@ -501,20 +599,14 @@ namespace Avalonia.Controls.UnitTests }.RegisterInNameScope(scope); } - private static void ApplyTemplate(ListBox target) + private static void RaiseKeyEvent(Control target, Key key, KeyModifiers inputModifiers = 0) { - // 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. - ((Control)target.Presenter).ApplyTemplate(); + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + KeyModifiers = inputModifiers, + Key = key + }); } private static IEnumerable SelectedContainers(SelectingItemsControl target)