diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index 2fa4a02fa2..ffdd32f95c 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -179,6 +179,26 @@ namespace Avalonia.Controls } } + /// + /// Collapse the specified all descendent s. + /// + /// The item to collapse. + public void CollapseSubTree(TreeViewItem item) + { + item.IsExpanded = false; + + if (item.Presenter?.Panel != null) + { + foreach (var child in item.Presenter.Panel.Children) + { + if (child is TreeViewItem treeViewItem) + { + CollapseSubTree(treeViewItem); + } + } + } + } + /// /// Selects all items in the . /// diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 9bfcf5adfa..18245bd682 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Generators; using Avalonia.Controls.Metadata; @@ -166,30 +168,94 @@ namespace Avalonia.Controls { if (!e.Handled) { - switch (e.Key) + Func? handler = + e.Key switch + { + Key.Left => ApplyToItemOrRecursivelyIfCtrl(FocusAwareCollapseItem, e.KeyModifiers), + Key.Right => ApplyToItemOrRecursivelyIfCtrl(ExpandItem, e.KeyModifiers), + Key.Enter or Key.Space => ApplyToItemOrRecursivelyIfCtrl(IsExpanded ? CollapseItem : ExpandItem, e.KeyModifiers), + + // do not handle CTRL with numpad keys + Key.Subtract => FocusAwareCollapseItem, + Key.Add => ExpandItem, + Key.Divide => ApplyToSubtree(CollapseItem), + Key.Multiply => ApplyToSubtree(ExpandItem), + _ => null, + }; + + if (handler is not null) + { + e.Handled = handler(this); + } + + // NOTE: these local functions do not use the TreeView.Expand/CollapseSubtree + // function because we want to know if any items were in fact expanded to set the + // event handled status. Also the handling here avoids a potential infinite recursion/stack overflow. + static Func ApplyToSubtree(Func f) + { + // Calling toList enumerates all items before applying functions. This avoids a + // potential infinite loop if there is an infinite tree (the control catalog is + // lazily infinite). But also means a lazily loaded tree will not be expanded completely. + return t => SubTree(t) + .ToList() + .Select(treeViewItem => f(treeViewItem)) + .Aggregate(false, (p, c) => p || c); + } + + static Func ApplyToItemOrRecursivelyIfCtrl(Func f, KeyModifiers keyModifiers) + { + if (keyModifiers.HasAllFlags(KeyModifiers.Control)) + { + return ApplyToSubtree(f); + } + + return f; + } + + static bool ExpandItem(TreeViewItem treeViewItem) + { + if (treeViewItem.ItemCount > 0 && !treeViewItem.IsExpanded) + { + treeViewItem.IsExpanded = true; + return true; + } + + return false; + } + + static bool CollapseItem(TreeViewItem treeViewItem) { - case Key.Right: - if (Items != null && Items.Cast().Any() && !IsExpanded) + if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded) + { + treeViewItem.IsExpanded = false; + return true; + } + + return false; + } + + static bool FocusAwareCollapseItem(TreeViewItem treeViewItem) + { + if (treeViewItem.ItemCount > 0 && treeViewItem.IsExpanded) + { + if (treeViewItem.IsFocused) { - IsExpanded = true; - e.Handled = true; + treeViewItem.IsExpanded = false; } - break; - - case Key.Left: - if (Items is not null && Items.Cast().Any() && IsExpanded) + else { - if (IsFocused) - { - IsExpanded = false; - } - else - { - FocusManager.Instance?.Focus(this, NavigationMethod.Directional); - } - e.Handled = true; + FocusManager.Instance?.Focus(treeViewItem, NavigationMethod.Directional); } - break; + + return true; + } + + return false; + } + + static IEnumerable SubTree(TreeViewItem treeViewItem) + { + return new[] { treeViewItem }.Concat(treeViewItem.LogicalChildren.OfType().SelectMany(child => SubTree(child))); } } @@ -198,8 +264,19 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { + if (_header is InputElement previousInputMethod) + { + previousInputMethod.DoubleTapped -= HeaderDoubleTapped; + } + _header = e.NameScope.Find("PART_Header"); _templateApplied = true; + + if (_header is InputElement im) + { + im.DoubleTapped += HeaderDoubleTapped; + } + if (_deferredBringIntoViewFlag) { _deferredBringIntoViewFlag = false; @@ -220,6 +297,15 @@ namespace Avalonia.Controls return logical != null ? result : @default; } + private void HeaderDoubleTapped(object? sender, TappedEventArgs e) + { + if (ItemCount > 0) + { + IsExpanded = !IsExpanded; + e.Handled = true; + } + } + private void OnParentChanged(AvaloniaPropertyChangedEventArgs e) { if (!((ILogical)this).IsAttachedToLogicalTree && e.NewValue is null) diff --git a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml index 9171791a0f..0bed388ca4 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml @@ -75,6 +75,7 @@ MinHeight="{TemplateBinding MinHeight}" TemplatedControl.IsTemplateFocusTarget="True"> 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + + [Fact] + public void Enter_Key_Should_Expand_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.True(container.IsExpanded); + } + } + + [Fact] + public void Enter_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.True(container.IsExpanded); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Space_Key_Should_Collapse_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.False(container.IsExpanded); + } + } + + [Fact] + public void Space_plus_Ctrl_Key_Should_Collapse_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.True(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.False(container.IsExpanded); + + AssertEachItemWithChildrenIsCollapsed(item); + + void AssertEachItemWithChildrenIsCollapsed(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + + [Fact] + public void Space_Key_Should_Expand_TreeViewItem() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + }); + + Assert.True(container.IsExpanded); + } + } + + [Fact] + public void Space_plus_Ctrl_Key_Should_Expand_TreeViewItem_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); // NOTE this line + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + Assert.False(container.IsExpanded); + var header = container.Header as Interactive; + Assert.NotNull(header); + + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Enter, + KeyModifiers = KeyModifiers.Control, + }); + + Assert.True(container.IsExpanded); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Numpad_Star_Should_Expand_All_Children_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + CollapseAll(target); + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Multiply, + }); + + AssertEachItemWithChildrenIsExpanded(item); + + void AssertEachItemWithChildrenIsExpanded(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.True(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsExpanded(c); + } + } + else + { + Assert.False(container.IsExpanded); + } + } + } + } + + [Fact] + public void Numpad_Slash_Should_Collapse_All_Children_Recursively() + { + using (Application()) + { + var tree = CreateTestTreeData(); + var target = new TreeView + { + Template = CreateTreeViewTemplate(), + Items = tree, + }; + + var visualRoot = new TestRoot(); + visualRoot.Child = target; + + CreateNodeDataTemplate(target); + ApplyTemplates(target); + ExpandAll(target); + + var item = tree[0]; + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(item); + + Assert.NotNull(container); + container.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Divide, + }); + + AssertEachItemWithChildrenIsCollapsed(item); + + void AssertEachItemWithChildrenIsCollapsed(Node node) + { + var container = (TreeViewItem)target.ItemContainerGenerator.Index.ContainerFromItem(node); + Assert.NotNull(container); + if (node.Children?.Count > 0) + { + Assert.False(container.IsExpanded); + foreach (var c in node.Children) + { + AssertEachItemWithChildrenIsCollapsed(c); + } + } + else + { + Assert.True(container.IsExpanded); + } + } + } + } + [Fact] public void Setting_SelectedItem_Should_Set_Container_Selected() { @@ -1313,10 +1894,14 @@ namespace Avalonia.Controls.UnitTests { Children = { - new ContentPresenter + new Border { - Name = "PART_HeaderPresenter", - [~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty], + Name = "PART_Header", + Child = new ContentPresenter + { + Name = "PART_HeaderPresenter", + [~ContentPresenter.ContentProperty] = parent[~TreeViewItem.HeaderProperty], + }.RegisterInNameScope(scope) }.RegisterInNameScope(scope), new ItemsPresenter { @@ -1335,6 +1920,14 @@ namespace Avalonia.Controls.UnitTests } } + private void CollapseAll(TreeView tree) + { + foreach (var i in tree.ItemContainerGenerator.Containers) + { + tree.CollapseSubTree((TreeViewItem)i.ContainerControl); + } + } + private List ExtractItemHeader(TreeView tree, int level) { return ExtractItemContent(tree.Presenter.Panel, 0, level) diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs index c9e4274d15..d63327239b 100644 --- a/tests/Avalonia.UnitTests/MouseTestHelper.cs +++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs @@ -65,6 +65,7 @@ namespace Avalonia.UnitTests } public void Move(Interactive target, in Point position, KeyModifiers modifiers = default) => Move(target, target, position, modifiers); + public void Move(Interactive target, Interactive source, in Point position, KeyModifiers modifiers = default) { target.RaiseEvent(new PointerEventArgs(InputElement.PointerMovedEvent, source, _pointer, (Visual)target, position, @@ -98,13 +99,26 @@ namespace Avalonia.UnitTests public void Click(Interactive target, MouseButton button = MouseButton.Left, Point position = default, KeyModifiers modifiers = default) => Click(target, target, button, position, modifiers); + public void Click(Interactive target, Interactive source, MouseButton button = MouseButton.Left, Point position = default, KeyModifiers modifiers = default) { Down(target, source, button, position, modifiers); Up(target, source, button, position, modifiers); } - + + public void DoubleClick(Interactive target, MouseButton button = MouseButton.Left, Point position = default, + KeyModifiers modifiers = default) + => DoubleClick(target, target, button, position, modifiers); + + public void DoubleClick(Interactive target, Interactive source, MouseButton button = MouseButton.Left, + Point position = default, KeyModifiers modifiers = default) + { + Down(target, source, button, position, modifiers, clickCount: 1); + Up(target, source, button, position, modifiers); + Down(target, source, button, position, modifiers, clickCount: 2); + } + public void Enter(Interactive target) { target.RaiseEvent(new PointerEventArgs(InputElement.PointerEnteredEvent, target, _pointer, (Visual)target, default,