Browse Source

Share tree keyboard nav via TreeKeyboardController; add type-ahead

Extract the standard tree keyboard gestures -- Left/Right expand-collapse + parent/child nav, numpad +/-/*, and new type-ahead incremental search -- out of AssemblyListPane into a reusable TreeKeyboardController that drives any hierarchical DataGrid. The model implements ITreeKeyboardTarget (PrimarySelectedNode + SelectNode) so each tree keeps its own select-and-reveal behaviour; expansion goes through the grid's IHierarchicalModel. Both the assembly tree and the analyzer tree now attach one, so they share consistent keyboard behaviour. Delete (unload) and Ctrl+R (analyze) stay assembly-specific in the pane.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
afdb778160
  1. 33
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 4
      ILSpy/Analyzers/AnalyzerTreeView.axaml.cs
  3. 13
      ILSpy/Analyzers/AnalyzerTreeViewModel.cs
  4. 73
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  5. 5
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  6. 192
      ILSpy/Controls/TreeKeyboardController.cs

33
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -921,6 +921,39 @@ public class AssemblyTreeTests @@ -921,6 +921,39 @@ public class AssemblyTreeTests
await Waiters.WaitForAsync(() => assembly.IsExpanded, description: "Numpad * expands");
}
[AvaloniaTest]
public async Task Type_Ahead_Jumps_To_The_Node_Matching_The_Typed_Text()
{
// Typing a name jumps the selection to the visible node whose text matches.
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
var target = model.FindNode<AssemblyTreeNode>("System.Linq");
var targetText = target.Text?.ToString()!;
// Start the selection on a different assembly so the jump is observable.
var other = model.FindNode<AssemblyTreeNode>(typeof(object).Assembly.GetName().Name!);
model.SelectNode(other);
await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, other));
foreach (char ch in targetText)
{
grid.RaiseEvent(new global::Avalonia.Input.TextInputEventArgs {
RoutedEvent = global::Avalonia.Input.InputElement.TextInputEvent,
Text = ch.ToString(),
Source = grid,
});
}
Dispatcher.UIThread.RunJobs();
await Waiters.WaitForAsync(
() => (model.SelectedItem as SharpTreeNode)?.Text?.ToString() == targetText,
description: "type-ahead must select the node whose text matches what was typed");
}
[AvaloniaTest]
public async Task Clear_Assembly_List_Command_Empties_The_Active_List()
{

4
ILSpy/Analyzers/AnalyzerTreeView.axaml.cs

@ -40,10 +40,14 @@ namespace ILSpy.Analyzers @@ -40,10 +40,14 @@ namespace ILSpy.Analyzers
AnalyzerTreeViewModel? boundModel;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
// Shared tree keyboard gestures (Left/Right, numpad, type-ahead) -- same as the assembly tree.
readonly ILSpy.Controls.TreeKeyboardController treeKeyboard;
public AnalyzerTreeView()
{
InitializeComponent();
TreeGrid.DoubleTapped += OnTreeGridDoubleTapped;
treeKeyboard = new ILSpy.Controls.TreeKeyboardController(TreeGrid);
var registry = TryGetContextMenuRegistry();
AttachContextMenu(registry?.Entries ?? Array.Empty<IContextMenuEntryExport>());
}

13
ILSpy/Analyzers/AnalyzerTreeViewModel.cs

@ -33,10 +33,21 @@ namespace ILSpy.Analyzers @@ -33,10 +33,21 @@ namespace ILSpy.Analyzers
[Export]
[ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 0, IsVisibleByDefault = false)]
[Shared]
public class AnalyzerTreeViewModel : ToolPaneModel
public class AnalyzerTreeViewModel : ToolPaneModel, ILSpy.Controls.ITreeKeyboardTarget
{
public const string PaneContentId = "Analyzer";
// ITreeKeyboardTarget: lets the shared TreeKeyboardController drive the analyzer tree's
// keyboard navigation, the same as the assembly tree.
SharpTreeNode? ILSpy.Controls.ITreeKeyboardTarget.PrimarySelectedNode
=> SelectedItems.Count > 0 ? SelectedItems[^1] : null;
void ILSpy.Controls.ITreeKeyboardTarget.SelectNode(SharpTreeNode? node)
{
if (node != null)
SyncSelection(node);
}
public AnalyzerTreeViewModel()
{
Id = PaneContentId;

73
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -47,6 +47,9 @@ namespace ILSpy.AssemblyTree @@ -47,6 +47,9 @@ namespace ILSpy.AssemblyTree
// selection into the DataGrid.
bool syncingSelection;
// Shared tree keyboard gestures (Left/Right, numpad, type-ahead); kept alive for the view.
readonly ILSpy.Controls.TreeKeyboardController treeKeyboard;
// A plain left-click on one row of a multi-selection: ProDataGrid keeps the whole
// selection on press (so the user can drag every selected row together), so the
// collapse-to-the-clicked-row has to happen on release if it turned out to be a click,
@ -104,6 +107,9 @@ namespace ILSpy.AssemblyTree @@ -104,6 +107,9 @@ namespace ILSpy.AssemblyTree
TreeGrid.LoadingRow += OnFirstRowLoaded;
TreeGrid.DoubleTapped += OnTreeGridDoubleTapped;
TreeGrid.KeyDown += OnTreeGridKeyDown;
// Standard tree keyboard gestures (Left/Right, numpad +/-/*, type-ahead) shared with
// the analyzer tree. Held in a field so it isn't collected.
treeKeyboard = new ILSpy.Controls.TreeKeyboardController(TreeGrid);
// Bubble + handledEventsToo: ProDataGrid's row-level pointer handlers mark
// PointerPressed handled before bubble reaches our subscription, so we have to
// opt into "see handled events too" to react.
@ -310,55 +316,8 @@ namespace ILSpy.AssemblyTree @@ -310,55 +316,8 @@ namespace ILSpy.AssemblyTree
Dispatcher.UIThread.Post(() => ReselectAfterDelete(reselectIndex), DispatcherPriority.Background);
return;
}
if (e.Key is Key.Left or Key.Right && e.KeyModifiers == KeyModifiers.None
&& model.SelectedItem is { } current
&& TreeGrid.HierarchicalModel is IHierarchicalModel hmNav
&& hmNav.FindNode(current) is { } currentNode)
{
if (e.Key == Key.Left)
{
// Collapse if open; otherwise step out to the parent (unless it's the hidden root).
if (currentNode.IsExpanded)
hmNav.Collapse(currentNode);
else if (current.Parent is { } parent && hmNav.FindNode(parent) is not null)
model.SelectNode(parent);
else
return;
}
else
{
// Expand if closed and has children; otherwise step into the first child.
if (!currentNode.IsExpanded && !currentNode.IsLeaf)
hmNav.Expand(currentNode);
else if (currentNode.IsExpanded && currentNode.Children.Count > 0)
model.SelectNode(currentNode.Children[0].Item as SharpTreeNode);
else
return;
}
e.Handled = true;
return;
}
if (e.Key is Key.Add or Key.Subtract or Key.Multiply && e.KeyModifiers == KeyModifiers.None
&& model.SelectedItem is { } expandTarget
&& TreeGrid.HierarchicalModel is IHierarchicalModel hmExpand
&& hmExpand.FindNode(expandTarget) is { } expandNode)
{
switch (e.Key)
{
case Key.Add:
if (!expandNode.IsLeaf)
hmExpand.Expand(expandNode);
break;
case Key.Subtract:
hmExpand.Collapse(expandNode);
break;
case Key.Multiply:
ExpandRecursively(hmExpand, expandNode);
break;
}
e.Handled = true;
return;
}
// Left/Right expand-collapse + parent/child nav, numpad +/-/*, and type-ahead search are
// handled by the shared TreeKeyboardController (created in the constructor).
if (e.Key == Key.R && e.KeyModifiers == KeyModifiers.Control)
{
var members = model.SelectedItems.OfType<IMemberTreeNode>()
@ -376,22 +335,6 @@ namespace ILSpy.AssemblyTree @@ -376,22 +335,6 @@ namespace ILSpy.AssemblyTree
}
}
// Numpad-* recursive expand. Expands the node, then recurses into children that opt in via
// SharpTreeNode.CanExpandRecursively -- which is false for lazy-loading nodes, so this stays
// bounded (it won't try to materialise a whole assembly's members). Mirrors WPF SharpTreeView.
static void ExpandRecursively(IHierarchicalModel hm, HierarchicalNode node)
{
if (node.IsLeaf)
return;
hm.Expand(node);
foreach (var child in node.Children)
{
if (child.Item is SharpTreeNode { CanExpandRecursively: false })
continue;
ExpandRecursively(hm, child);
}
}
// Flattened (visible) index of a tree node, or -1 if not currently realized/visible.
int FlattenedIndexOf(SharpTreeNode node)
{

5
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -49,8 +49,11 @@ namespace ILSpy.AssemblyTree @@ -49,8 +49,11 @@ namespace ILSpy.AssemblyTree
[Export]
[ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Left, Order = 0)]
[Shared]
public partial class AssemblyTreeModel : ToolPaneModel
public partial class AssemblyTreeModel : ToolPaneModel, ILSpy.Controls.ITreeKeyboardTarget
{
// ITreeKeyboardTarget: the primary (last) selected node drives TreeKeyboardController.
SharpTreeNode? ILSpy.Controls.ITreeKeyboardTarget.PrimarySelectedNode => SelectedItem;
public const string PaneContentId = "AssemblyTree";
readonly SettingsService settingsService;

192
ILSpy/Controls/TreeKeyboardController.cs

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
// 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;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using ICSharpCode.ILSpyX.TreeView;
namespace ILSpy.Controls
{
/// <summary>
/// A tree whose keyboard gestures the <see cref="TreeKeyboardController"/> drives. The
/// controller reads the focused node and asks the model to move the selection, so each tree
/// keeps its own select-and-reveal behaviour (decompile, navigate, scroll-into-view).
/// </summary>
public interface ITreeKeyboardTarget
{
SharpTreeNode? PrimarySelectedNode { get; }
void SelectNode(SharpTreeNode? node);
}
/// <summary>
/// Adds the standard tree keyboard gestures to a hierarchical <see cref="DataGrid"/>:
/// Left/Right collapse-expand + parent/child navigation, Numpad +/-/* (including recursive
/// expand), and type-ahead incremental search. Expansion is driven through the grid's
/// <see cref="IHierarchicalModel"/>; selection moves delegate to the
/// <see cref="ITreeKeyboardTarget"/>. Attach one per tree (assembly tree + analyzer tree)
/// for consistent behaviour.
/// </summary>
public sealed class TreeKeyboardController
{
readonly DataGrid grid;
readonly DispatcherTimer searchResetTimer;
string searchBuffer = string.Empty;
public TreeKeyboardController(DataGrid grid)
{
this.grid = grid ?? throw new ArgumentNullException(nameof(grid));
searchResetTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
searchResetTimer.Tick += (_, _) => { searchResetTimer.Stop(); searchBuffer = string.Empty; };
grid.AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Bubble);
grid.AddHandler(InputElement.TextInputEvent, OnTextInput, RoutingStrategies.Bubble);
}
// The model is the grid's DataContext (the tree's UserControl sets it). Resolved per
// gesture so the controller can be created before the DataContext is assigned.
ITreeKeyboardTarget? Target => grid.DataContext as ITreeKeyboardTarget;
IHierarchicalModel? Model => grid.HierarchicalModel as IHierarchicalModel;
void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyModifiers != KeyModifiers.None)
return;
if (Target is not { } target
|| target.PrimarySelectedNode is not { } current
|| Model is not { } hm
|| hm.FindNode(current) is not { } node)
return;
switch (e.Key)
{
case Key.Left:
// Collapse if open; else step out to the parent (Parent is null on a root row).
if (node.IsExpanded)
hm.Collapse(node);
else if (node.Parent?.Item is SharpTreeNode parent)
target.SelectNode(parent);
else
return;
break;
case Key.Right:
// Expand if closed and not a leaf; else step into the first child.
if (!node.IsExpanded && !node.IsLeaf)
hm.Expand(node);
else if (node.IsExpanded && node.Children.Count > 0 && node.Children[0].Item is SharpTreeNode child)
target.SelectNode(child);
else
return;
break;
case Key.Add:
if (node.IsLeaf)
return;
hm.Expand(node);
break;
case Key.Subtract:
hm.Collapse(node);
break;
case Key.Multiply:
ExpandRecursively(hm, node);
break;
default:
return;
}
e.Handled = true;
}
void OnTextInput(object? sender, TextInputEventArgs e)
{
var text = e.Text;
if (string.IsNullOrEmpty(text) || char.IsControl(text[0]))
return;
if (Target is not { } target || Model is not { } hm)
return;
var flattened = hm.Flattened;
if (flattened.Count == 0)
return;
searchBuffer += text;
searchResetTimer.Stop();
searchResetTimer.Start();
// Anchor the search at the current selection. A fresh single keystroke advances past it
// (so repeating a letter cycles through matches); accumulating a longer prefix re-matches
// from the current row so a settled selection that still matches stays put.
int anchor = IndexOf(flattened, target.PrimarySelectedNode);
int from = searchBuffer.Length <= 1 ? anchor + 1 : anchor;
var match = FindPrefixMatch(flattened, searchBuffer, from);
if (match?.Item is SharpTreeNode node)
{
target.SelectNode(node);
e.Handled = true;
}
}
static int IndexOf(IReadOnlyList<HierarchicalNode> flattened, SharpTreeNode? node)
{
if (node is null)
return -1;
for (int i = 0; i < flattened.Count; i++)
{
if (ReferenceEquals(flattened[i].Item, node))
return i;
}
return -1;
}
static HierarchicalNode? FindPrefixMatch(IReadOnlyList<HierarchicalNode> flattened, string prefix, int from)
{
if (from < 0)
from = 0;
for (int k = 0; k < flattened.Count; k++)
{
var candidate = flattened[(from + k) % flattened.Count];
if (candidate.Item is SharpTreeNode stn
&& stn.Text?.ToString() is { } textValue
&& textValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
// Numpad-* recursive expand. Recurses only into children that opt in via
// SharpTreeNode.CanExpandRecursively (false for lazy-loading nodes), so it stays bounded.
static void ExpandRecursively(IHierarchicalModel hm, HierarchicalNode node)
{
if (node.IsLeaf)
return;
hm.Expand(node);
foreach (var child in node.Children)
{
if (child.Item is SharpTreeNode { CanExpandRecursively: false })
continue;
ExpandRecursively(hm, child);
}
}
}
}
Loading…
Cancel
Save