diff --git a/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs b/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs new file mode 100644 index 000000000..48c838f62 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// A context menu raised from the keyboard (Shift+F10 / Apps key) must not strand keyboard focus: +/// closing the popup otherwise drops focus (and its focus adorner), leaving the tree un-navigable +/// until the user clicks back in. The pane captures the pre-menu focus and restores it on close. +/// +[TestFixture] +public class KeyboardContextMenuFocusTests +{ + [AvaloniaTest] + public async Task Keyboard_Invoked_Menu_Returns_Focus_To_The_Row_On_Close() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var pane = await window.WaitForComponent(); + var grid = await pane.WaitForComponent(); + + var node = vm.AssemblyTreeModel.Root!.Children.OfType().First(); + vm.AssemblyTreeModel.SelectNode(node); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + grid.UpdateLayout(); + await Task.Delay(25); + } + + var row = grid.GetVisualDescendants() + .OfType().First(); + row.Focus(NavigationMethod.Tab); + Dispatcher.UIThread.RunJobs(); + + var focusManager = TopLevel.GetTopLevel(window)!.FocusManager!; + (focusManager.GetFocusedElement() == row).Should().BeTrue("the row must hold focus before invoking the menu"); + + // Keyboard invocation raises ContextRequested with no pointer position (the Shift+F10 / Apps path). + row.RaiseEvent(new ContextRequestedEventArgs()); + for (int i = 0; i < 6; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + grid.ContextMenu!.IsOpen.Should().BeTrue("the keyboard gesture must open the tree context menu"); + row.Classes.Should().Contain("contextTarget", + "a keyboard-invoked menu must show the transient target highlight on the selected row, like the mouse path"); + + window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null); + for (int i = 0; i < 6; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + + (focusManager.GetFocusedElement() == row).Should().BeTrue( + "closing a keyboard-invoked context menu must return focus to the row, not strand it"); + row.Classes.Should().NotContain("contextTarget", + "the transient highlight must clear once the menu closes"); + } +} diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index 7511ba4d7..58978acb1 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -58,7 +58,10 @@ - + + + @@ -109,7 +112,10 @@ - + + + diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml b/ILSpy/AssemblyTree/AssemblyListPane.axaml index e24621816..9f14ceb44 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml @@ -10,10 +10,12 @@ - diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 8e8fc8bc5..8e7265ce7 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -48,6 +48,13 @@ namespace ILSpy.AssemblyTree SharpTreeViewItem? contextTargetItem; SharpTreeViewItem? contextMenuOpenItem; SharpTreeNode? contextMenuTargetNode; + // For a keyboard-invoked menu (Shift+F10 / Apps), the row to re-focus when the menu closes + // (closing the popup otherwise drops the keyboard focus and its focus adorner). Null for + // pointer-invoked menus. + SharpTreeViewItem? focusToRestoreAfterMenu; + // Whether the last ContextRequested came from the keyboard (no pointer position). The keyboard + // path carries no target row, so the menu adopts the selected row (see OnContextMenuOpening). + bool lastContextRequestWasKeyboard; public AssemblyListPane() { @@ -123,6 +130,7 @@ namespace ILSpy.AssemblyTree var menu = new ContextMenu(); menu.Opening += OnContextMenuOpening; menu.Closed += (_, _) => { + RestoreFocusAfterKeyboardMenu(); if (!ReferenceEquals(contextTargetItem, contextMenuOpenItem)) return; contextMenuTargetNode = null; @@ -135,10 +143,28 @@ namespace ILSpy.AssemblyTree { if (sender is not ContextMenu menu) return; + // A keyboard-invoked menu (Shift+F10 / Apps) carries no pointer position, so OnTreeContextRequested + // set no transient target. Adopt the keyboard-FOCUSED row (which may differ from the selection + // after Ctrl+Arrow) as the target: opening the popup steals focus and drops the row's focus + // adorner, so we mark that row with the same context-target highlight the mouse gives the + // right-clicked row, and restore its focus + adorner on close (Avalonia's ContextMenu does not). + // Captured here, before the popup opens and takes focus (Opening fires ahead of it), and before + // contextMenuOpenItem is latched so the Closed handler still clears the highlight. + var focusedRow = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as SharpTreeViewItem; + if (lastContextRequestWasKeyboard && focusedRow?.Node != null) + { + contextMenuTargetNode = focusedRow.Node; + SetContextTargetItem(focusedRow); + focusToRestoreAfterMenu = focusedRow; + } contextMenuOpenItem = contextTargetItem; var built = BuildContextMenuForCurrentState(contextMenuEntries); if (built == null) { + // Menu won't open (so Closed won't fire) -- undo the transient target + captured focus. + focusToRestoreAfterMenu = null; + contextMenuTargetNode = null; + SetContextTargetItem(null); e.Cancel = true; return; } @@ -150,6 +176,17 @@ namespace ILSpy.AssemblyTree } } + void RestoreFocusAfterKeyboardMenu() + { + if (focusToRestoreAfterMenu is not { } toFocus) + return; + focusToRestoreAfterMenu = null; + // Re-focus with a keyboard navigation method so the focus visual (the adorner) comes back, + // not just the logical focus. Posted so it runs after the popup has fully torn down. + global::Avalonia.Threading.Dispatcher.UIThread.Post( + () => toFocus.Focus(NavigationMethod.Tab)); + } + internal ContextMenu? BuildContextMenuForCurrentState(IReadOnlyList entries) => ContextMenuProvider.Build(entries, CreateContextMenuContext()); @@ -195,7 +232,9 @@ namespace ILSpy.AssemblyTree void OnTreeContextRequested(object? sender, ContextRequestedEventArgs e) { SharpTreeViewItem? item = null; - if (e.TryGetPosition(Tree, out var pos) && Tree.InputHitTest(pos) is Visual hit) + // Keyboard invocation (Shift+F10 / Apps) raises ContextRequested with no pointer position. + lastContextRequestWasKeyboard = !e.TryGetPosition(Tree, out var pos); + if (!lastContextRequestWasKeyboard && Tree.InputHitTest(pos) is Visual hit) item = hit.FindAncestorOfType(includeSelf: true); contextMenuTargetNode = item?.Node; SetContextTargetItem(item?.Node != null ? item : null); diff --git a/ILSpy/Controls/TreeView/SharpTreeView.axaml b/ILSpy/Controls/TreeView/SharpTreeView.axaml index f5d630e24..8331cc239 100644 --- a/ILSpy/Controls/TreeView/SharpTreeView.axaml +++ b/ILSpy/Controls/TreeView/SharpTreeView.axaml @@ -50,6 +50,46 @@ + + + + + + + + + + + + + + + +