From 0e83446cb630de57f99a30c960c4dad554e3c822 Mon Sep 17 00:00:00 2001 From: Dong Bin Date: Tue, 10 Jun 2025 12:09:06 +0800 Subject: [PATCH 1/3] feat: add new event. --- src/Ursa/Controls/NavMenu/NavMenu.cs | 28 +++++++++++++++++++ .../NavMenu/SelectionChangingEventArgs.cs | 24 ++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs diff --git a/src/Ursa/Controls/NavMenu/NavMenu.cs b/src/Ursa/Controls/NavMenu/NavMenu.cs index ec00428..dabec9a 100644 --- a/src/Ursa/Controls/NavMenu/NavMenu.cs +++ b/src/Ursa/Controls/NavMenu/NavMenu.cs @@ -189,6 +189,16 @@ public class NavMenu : ItemsControl, ICustomKeyboardNavigation add => AddHandler(SelectionChangedEvent, value); remove => RemoveHandler(SelectionChangedEvent, value); } + + + public static readonly RoutedEvent SelectionChangingEvent = + RoutedEvent.Register(nameof(SelectionChanging), RoutingStrategies.Bubble); + + public event EventHandler SelectionChanging + { + add => AddHandler(SelectionChangingEvent, value); + remove => RemoveHandler(SelectionChangingEvent, value); + } protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) { @@ -486,4 +496,22 @@ public class NavMenu : ItemsControl, ICustomKeyboardNavigation return null; } + + internal bool CanChangeSelection(NavMenuItem item) + { + object? newSelection = null; + if (item.DataContext is not null && item.DataContext != DataContext) + newSelection = item.DataContext; + else + newSelection = item; + var args = new SelectionChangingEventArgs( + SelectionChangingEvent, + new[] { SelectedItem }, + new[] { newSelection }) + { + Source = this, + }; + RaiseEvent(args); + return args.CanSelect; + } } \ No newline at end of file diff --git a/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs b/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs new file mode 100644 index 0000000..3b5c95b --- /dev/null +++ b/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs @@ -0,0 +1,24 @@ +using System.Collections; +using Avalonia.Interactivity; + +namespace Ursa.Controls; + +public class SelectionChangingEventArgs: RoutedEventArgs +{ + /// Gets the items that were added to the selection. + public IList AddedItems { get; } + + /// Gets the items that were removed from the selection. + public IList RemovedItems { get; } + + /// + /// Gets or sets a value indicating whether the selection can be changed. If set to false, the selection will not change. + /// + public bool CanSelect { get; set; } = true; + + public SelectionChangingEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems): base(routedEvent) + { + RemovedItems = removedItems; + AddedItems = addedItems; + } +} \ No newline at end of file From fb9f03d63be1738358234b1a6923e86614721f53 Mon Sep 17 00:00:00 2001 From: Dong Bin Date: Thu, 12 Jun 2025 19:30:14 +0800 Subject: [PATCH 2/3] feat: stop changing selection after checking canselect. revert focus change in this case. --- src/Ursa/Controls/NavMenu/NavMenu.cs | 14 ++++++++++---- src/Ursa/Controls/NavMenu/NavMenuItem.cs | 4 ++++ .../Controls/NavMenu/SelectionChangingEventArgs.cs | 10 +++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Ursa/Controls/NavMenu/NavMenu.cs b/src/Ursa/Controls/NavMenu/NavMenu.cs index dabec9a..bbe036d 100644 --- a/src/Ursa/Controls/NavMenu/NavMenu.cs +++ b/src/Ursa/Controls/NavMenu/NavMenu.cs @@ -191,10 +191,10 @@ public class NavMenu : ItemsControl, ICustomKeyboardNavigation } - public static readonly RoutedEvent SelectionChangingEvent = - RoutedEvent.Register(nameof(SelectionChanging), RoutingStrategies.Bubble); + public static readonly RoutedEvent SelectionChangingEvent = + RoutedEvent.Register(nameof(SelectionChanging), RoutingStrategies.Bubble); - public event EventHandler SelectionChanging + public event EventHandler SelectionChanging { add => AddHandler(SelectionChangingEvent, value); remove => RemoveHandler(SelectionChangingEvent, value); @@ -512,6 +512,12 @@ public class NavMenu : ItemsControl, ICustomKeyboardNavigation Source = this, }; RaiseEvent(args); - return args.CanSelect; + var result = args.CanSelect; + if (result == false) + { + var container = GetContainerForItem(SelectedItem); + container?.Focus(NavigationMethod.Directional); + } + return result; } } \ No newline at end of file diff --git a/src/Ursa/Controls/NavMenu/NavMenuItem.cs b/src/Ursa/Controls/NavMenu/NavMenuItem.cs index 1ba56fb..378cf6c 100644 --- a/src/Ursa/Controls/NavMenu/NavMenuItem.cs +++ b/src/Ursa/Controls/NavMenu/NavMenuItem.cs @@ -381,6 +381,10 @@ public class NavMenuItem : HeaderedItemsControl internal void SelectItem(NavMenuItem item) { + if (item == this && RootMenu?.CanChangeSelection(item) != true) + { + return; + } SetCurrentValue(IsSelectedProperty, item == this); SetCurrentValue(IsHighlightedProperty, true); diff --git a/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs b/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs index 3b5c95b..0e8e448 100644 --- a/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs +++ b/src/Ursa/Controls/NavMenu/SelectionChangingEventArgs.cs @@ -6,19 +6,19 @@ namespace Ursa.Controls; public class SelectionChangingEventArgs: RoutedEventArgs { /// Gets the items that were added to the selection. - public IList AddedItems { get; } + public IList NewItems { get; } /// Gets the items that were removed from the selection. - public IList RemovedItems { get; } + public IList OldItems { get; } /// /// Gets or sets a value indicating whether the selection can be changed. If set to false, the selection will not change. /// public bool CanSelect { get; set; } = true; - public SelectionChangingEventArgs(RoutedEvent routedEvent, IList removedItems, IList addedItems): base(routedEvent) + public SelectionChangingEventArgs(RoutedEvent routedEvent, IList oldItems, IList newItems): base(routedEvent) { - RemovedItems = removedItems; - AddedItems = addedItems; + OldItems = oldItems; + NewItems = newItems; } } \ No newline at end of file From cbf88a1aea541d7237cd25a4d85646d050426537 Mon Sep 17 00:00:00 2001 From: rabbitism Date: Fri, 11 Jul 2025 11:26:05 +0800 Subject: [PATCH 3/3] test: add headless test for canselect handling. --- .../NavMenuTests/CanSelectTests/Test.cs | 103 ++++++++++++++++++ .../CanSelectTests/TestView1.axaml | 13 +++ .../CanSelectTests/TestView1.axaml.cs | 23 ++++ .../CanSelectTests/TestView2.axaml | 12 ++ .../CanSelectTests/TestView2.axaml.cs | 43 ++++++++ 5 files changed, 194 insertions(+) create mode 100644 tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/Test.cs create mode 100644 tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml create mode 100644 tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml.cs create mode 100644 tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml create mode 100644 tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml.cs diff --git a/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/Test.cs b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/Test.cs new file mode 100644 index 0000000..f4a956d --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/Test.cs @@ -0,0 +1,103 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.NavMenuTests.CanSelectTests; + +public class Test +{ + [AvaloniaFact] + public void CanSelect_Blocks_Selection_Change_Inline_Xaml() + { + Window window = new Window + { + Width = 400, + Height = 400, + }; + + var view = new TestView1(); + + window.Content = view; + + window.Show(); + + var menu = view.FindControl("Menu"); + var item1 = view.FindControl("MenuItem1"); + var item2 = view.FindControl("MenuItem2"); + var item3 = view.FindControl("MenuItem3"); + + Assert.NotNull(menu); + Assert.NotNull(item1); + Assert.NotNull(item2); + Assert.NotNull(item3); + + var point1 = item1.TranslatePoint(new Point(0, 0), window); + var point2 = item2.TranslatePoint(new Point(0, 0), window); + var point3 = item3.TranslatePoint(new Point(0, 0), window); + Assert.NotNull(point1); + Assert.NotNull(point2); + Assert.NotNull(point3); + + window.MouseDown(new Point(point1.Value.X+10, point1.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point1.Value.X+10, point1.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item1, menu.SelectedItem); + + window.MouseDown(new Point(point2.Value.X+10, point2.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point2.Value.X+10, point2.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item1, menu.SelectedItem); // Should not change selection due to CanSelect being false + + window.MouseDown(new Point(point3.Value.X+10, point3.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point3.Value.X+10, point3.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item3, menu.SelectedItem); // Should change selection to item3 + + } + + [AvaloniaFact] + public void CanSelect_Blocks_Selection_Change_Inline_Code() + { + Window window = new Window + { + Width = 400, + Height = 400, + }; + + var view = new TestView2(); + + window.Content = view; + + window.Show(); + + var menu = view.FindControl("Menu"); + var items = menu.GetLogicalDescendants().OfType().ToList(); + var item1 = items[0]; + var item2 = items[1]; + var item3 = items[2]; + + Assert.NotNull(menu); + Assert.NotNull(item1); + Assert.NotNull(item2); + Assert.NotNull(item3); + + var point1 = item1.TranslatePoint(new Point(0, 0), window); + var point2 = item2.TranslatePoint(new Point(0, 0), window); + var point3 = item3.TranslatePoint(new Point(0, 0), window); + Assert.NotNull(point1); + Assert.NotNull(point2); + Assert.NotNull(point3); + + window.MouseDown(new Point(point1.Value.X+10, point1.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point1.Value.X+10, point1.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item1.DataContext, menu.SelectedItem); + + window.MouseDown(new Point(point2.Value.X+10, point2.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point2.Value.X+10, point2.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item1.DataContext, menu.SelectedItem); // Should not change selection due to CanSelect being false + + window.MouseDown(new Point(point3.Value.X+10, point3.Value.Y+10), Avalonia.Input.MouseButton.Left); + window.MouseUp(new Point(point3.Value.X+10, point3.Value.Y+10), Avalonia.Input.MouseButton.Left); + Assert.Equal(item3.DataContext, menu.SelectedItem); // Should change selection to item3 + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml new file mode 100644 index 0000000..187ddc0 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml.cs b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml.cs new file mode 100644 index 0000000..25b61b6 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView1.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.NavMenuTests.CanSelectTests; + +public partial class TestView1 : UserControl +{ + public TestView1() + { + InitializeComponent(); + } + + private void Menu_OnSelectionChanging(object? sender, SelectionChangingEventArgs e) + { + var newItem = e.NewItems; + if (newItem is [NavMenuItem { Name: "MenuItem2" }]) + { + e.CanSelect = false; // Prevent selection change for MenuItem2 + } + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml new file mode 100644 index 0000000..d38a0de --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml @@ -0,0 +1,12 @@ + + + diff --git a/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml.cs b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml.cs new file mode 100644 index 0000000..54d5bbb --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/NavMenuTests/CanSelectTests/TestView2.axaml.cs @@ -0,0 +1,43 @@ +using System.Collections.ObjectModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using CommunityToolkit.Mvvm.ComponentModel; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.NavMenuTests.CanSelectTests; + +public partial class TestView2 : UserControl +{ + public TestView2() + { + InitializeComponent(); + this.DataContext = new TestView2ViewModel(); + } + + private void NavMenu_OnSelectionChanging(object? sender, SelectionChangingEventArgs e) + { + if (e.NewItems is [MenuItemViewModel item]) + { + if (item.Text.Contains("2")) + { + e.CanSelect = false; + } + } + } +} + +public partial class TestView2ViewModel +{ + public ObservableCollection MenuItems { get; } = new() + { + new MenuItemViewModel { Text = "Menu Item 1" }, + new MenuItemViewModel { Text = "Menu Item 2" }, + new MenuItemViewModel { Text = "Menu Item 3" } + }; +} + +public partial class MenuItemViewModel: ObservableObject +{ + [ObservableProperty] private string? _text; +} \ No newline at end of file