Browse Source

Give tree rows a filled focus box and keyboard menu focus

The Simple theme drew keyboard focus as a dotted, fill-less adorner that vanished outright once a context menu took focus, and right-click versus keyboard invocation marked the target inconsistently. Render focus as a selection-coloured fill with a dotted darker-blue border via a FocusAdornerTemplate, and reuse the same visual as a ContextTargetVisual template part for the right-click target. A keyboard-invoked menu (Shift+F10 / Apps) now marks the focused row and restores its focus and adorner on close, since Avalonia's ContextMenu never restores focus itself.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
3e8cd0b360
  1. 101
      ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs
  2. 10
      ILSpy/App.axaml
  3. 10
      ILSpy/AssemblyTree/AssemblyListPane.axaml
  4. 41
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  5. 40
      ILSpy/Controls/TreeView/SharpTreeView.axaml

101
ILSpy.Tests/ContextMenus/KeyboardContextMenuFocusTests.cs

@ -0,0 +1,101 @@ @@ -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;
/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class KeyboardContextMenuFocusTests
{
[AvaloniaTest]
public async Task Keyboard_Invoked_Menu_Returns_Focus_To_The_Row_On_Close()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
var node = vm.AssemblyTreeModel.Root!.Children.OfType<AssemblyTreeNode>().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<global::ILSpy.Controls.TreeView.SharpTreeViewItem>().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");
}
}

10
ILSpy/App.axaml

@ -58,7 +58,10 @@ @@ -58,7 +58,10 @@
<SolidColorBrush x:Key="ILSpy.PaneBackground" Color="White" />
<SolidColorBrush x:Key="ILSpy.TreeAutoloadedForeground" Color="SteelBlue" />
<SolidColorBrush x:Key="ILSpy.TreeNonPublicForeground" Color="Gray" />
<SolidColorBrush x:Key="ILSpy.ContextTargetRowBackground" Color="#2E007ACC" />
<!-- Tree keyboard-focus / context-menu-target visual: a translucent selection-blue
fill with a darker-blue dotted border (see SharpTreeView.axaml FocusVisual). -->
<SolidColorBrush x:Key="ILSpy.TreeFocusFill" Color="#33007ACC" />
<SolidColorBrush x:Key="ILSpy.TreeFocusBorder" Color="#005A9E" />
<SolidColorBrush x:Key="ILSpy.ChromeBorder" Color="#FFC8CDD3" />
<SolidColorBrush x:Key="ILSpy.SecondaryForeground" Color="Gray" />
<SolidColorBrush x:Key="ILSpy.MenuBackground" Color="White" />
@ -109,7 +112,10 @@ @@ -109,7 +112,10 @@
<SolidColorBrush x:Key="ILSpy.PaneBackground" Color="#1E1E1E" />
<SolidColorBrush x:Key="ILSpy.TreeAutoloadedForeground" Color="#6FA8DC" />
<SolidColorBrush x:Key="ILSpy.TreeNonPublicForeground" Color="#9AA0A6" />
<SolidColorBrush x:Key="ILSpy.ContextTargetRowBackground" Color="#33007ACC" />
<!-- On dark a darker shade would vanish, so the border is a brighter accent blue;
the fill stays a translucent selection blue. -->
<SolidColorBrush x:Key="ILSpy.TreeFocusFill" Color="#40007ACC" />
<SolidColorBrush x:Key="ILSpy.TreeFocusBorder" Color="#4A90D9" />
<SolidColorBrush x:Key="ILSpy.ChromeBorder" Color="#3F3F46" />
<SolidColorBrush x:Key="ILSpy.SecondaryForeground" Color="#A0A0A0" />
<SolidColorBrush x:Key="ILSpy.MenuBackground" Color="#2D2D30" />

10
ILSpy/AssemblyTree/AssemblyListPane.axaml

@ -10,10 +10,12 @@ @@ -10,10 +10,12 @@
<UserControl.Styles>
<!-- Thunderbird-style context-menu target highlight: a right-click marks its target row
with this class WITHOUT moving the selection, so the menu's target is visible while the
real selection (and the decompiled document) stays put. Distinct from the solid-accent
selection highlight: a faint accent fill. Applied/cleared from SetContextTargetItem. -->
<Style Selector="tv|SharpTreeViewItem.contextTarget">
<Setter Property="Background" Value="{DynamicResource ILSpy.ContextTargetRowBackground}" />
real selection (and the decompiled document) stays put. Reveals the ContextTargetVisual
rectangle (selection-blue fill + dotted border), matching the keyboard-focus adorner so
the menu's target reads identically to a focused row. Applied/cleared from
SetContextTargetItem. -->
<Style Selector="tv|SharpTreeViewItem.contextTarget /template/ Rectangle#ContextTargetVisual">
<Setter Property="IsVisible" Value="True" />
</Style>
<!-- Dim auto-loaded assemblies / non-public API, but never let the dim colour bleed
through a selected row's highlight (mirrors the old DataGridRow:not(:selected) rule). -->

41
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -48,6 +48,13 @@ namespace ILSpy.AssemblyTree @@ -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 @@ -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 @@ -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 @@ -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<IContextMenuEntryExport> entries)
=> ContextMenuProvider.Build(entries, CreateContextMenuContext());
@ -195,7 +232,9 @@ namespace ILSpy.AssemblyTree @@ -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<SharpTreeViewItem>(includeSelf: true);
contextMenuTargetNode = item?.Node;
SetContextTargetItem(item?.Node != null ? item : null);

40
ILSpy/Controls/TreeView/SharpTreeView.axaml

@ -50,6 +50,46 @@ @@ -50,6 +50,46 @@
<Setter Property="Margin" Value="0" />
<Setter Property="MinHeight" Value="20" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<!-- Replace the framework's default focus adorner (a dotted, fill-less rectangle) with one that
fills the row in the selection colour and keeps the dotted, darker-blue border. Rendered on
the adorner layer on keyboard focus only, exactly where the default appeared. -->
<Setter Property="FocusAdorner">
<FocusAdornerTemplate>
<Rectangle Fill="{DynamicResource ILSpy.TreeFocusFill}"
Stroke="{DynamicResource ILSpy.TreeFocusBorder}"
StrokeThickness="1" StrokeDashArray="1,2" Margin="1" />
</FocusAdornerTemplate>
</Setter>
<!-- Override the item template (the Simple theme's is a bare ContentPresenter) to add the
ContextTargetVisual overlay as a sibling template part. It cannot live in the ContentTemplate:
a descendant style selector does not cross the ContentPresenter into its templated content, so
a .contextTarget rule there never reaches it. As a template part, the .contextTarget /template/
rule below does reach it. The ContentPresenter keeps its name so the inherited :selected /
:pointerover background rules still match. -->
<Setter Property="Template">
<ControlTemplate>
<Panel>
<ContentPresenter Name="PART_ContentPresenter"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
CornerRadius="{TemplateBinding CornerRadius}" />
<!-- Right-click context-menu target visual: the same selection-coloured fill and
dotted, darker-blue border as the keyboard-focus adorner, so a right-clicked row
reads identically to a focused one. Hidden until the .contextTarget rule flips it;
drawn over the content (translucent) like the adorner; never hit-testable. -->
<Rectangle Name="ContextTargetVisual" IsVisible="False" IsHitTestVisible="False"
Fill="{DynamicResource ILSpy.TreeFocusFill}"
Stroke="{DynamicResource ILSpy.TreeFocusBorder}"
StrokeThickness="1" StrokeDashArray="1,2" Margin="1" />
</Panel>
</ControlTemplate>
</Setter>
<Setter Property="ContentTemplate">
<DataTemplate x:CompileBindings="False">
<Panel Background="Transparent" ToolTip.Tip="{Binding ToolTip}">

Loading…
Cancel
Save