瀏覽代碼

Properly implement keyboard selection.

Steven Kirk 2 年之前
父節點
當前提交
06bfa29b12

+ 5 - 3
src/Avalonia.Controls/ListBox.cs

@@ -128,8 +128,11 @@ namespace Avalonia.Controls
         protected override void OnKeyDown(KeyEventArgs e)
         {
             var hotkeys = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
+            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);

+ 19 - 3
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;
                 }
             }

+ 4 - 2
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)

+ 141 - 0
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<string>((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<Panel>(() => new VirtualizingStackPanel 
+                {
+                    Orientation = Orientation.Horizontal 
+                }),
+                ItemTemplate = new FuncDataTemplate<string>((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<string>((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()
         {

+ 105 - 13
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<int> SelectedContainers(SelectingItemsControl target)