Browse Source

Retarget assembly tree tests to SharpTreeView; close interaction gaps

The assembly/analyzer panes now host SharpTreeView, but ~20 headless tests
still queried the old ProDataGrid surface (DataGrid/DataGridRow/Hierarchical
Model/HierarchicalNode), so they could not exercise the live tree. Retarget
them to SharpTreeView/SharpTreeViewItem and the flattener (ItemsSource) the
control actually exposes, and centralise the row/selection lookups in
TreeNodeAssertions so the scroll assertions ride the new container type.

Driving the retargeted tests against the real control surfaced four genuine
gaps, fixed here in SharpTreeView:
- Ctrl+A selected nothing on the first press because the base ListBox only
  selects all once an item inside it is focused; handle it explicitly and make
  the control itself focusable so the gesture works the moment the pane gains
  focus.
- Left/Right navigation focused the parent/first-child container but never
  moved the selection (selection must follow the caret); add SelectAndFocus.
- The expander toggle had no stable name; name it PART_Expander so hit-target
  assertions can find it, and align the test to the shipped 13px column (kept
  at 13 so the +/- box centres on the connector lines).
- Tree rows stretch to content width with horizontal scroll, so a row centre
  can sit past the viewport's right edge; the pointer tests now click within
  the visible grid width instead of the off-screen row centre.

The UseNestedNamespaceNodes re-bind test asserted a ProDataGrid Hierarchical
Model swap that no longer exists; rewrite it to assert the live flattener
reshapes the visible rows in place when the setting toggles.
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
ab5fa461af
  1. 2
      ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
  2. 4
      ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs
  3. 30
      ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs
  4. 87
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  5. 52
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs
  6. 2
      ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs
  7. 6
      ILSpy.Tests/Diagnostics/StartupPerfTests.cs
  8. 57
      ILSpy.Tests/Input/HeadlessMmbPointerTests.cs
  9. 1
      ILSpy.Tests/Options/ApiVisibilityFilterTests.cs
  10. 29
      ILSpy.Tests/TreeNodeAssertions.cs
  11. 3
      ILSpy/Controls/TreeView/SharpTreeView.axaml
  12. 25
      ILSpy/Controls/TreeView/SharpTreeView.cs

2
ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs

@ -120,7 +120,7 @@ public class AnalyzeContextMenuTests @@ -120,7 +120,7 @@ public class AnalyzeContextMenuTests
var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var beforeCount = analyzerVm.Root.Children.Count;
var grid = await pane.WaitForComponent<global::Avalonia.Controls.DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.RaiseEvent(new global::Avalonia.Input.KeyEventArgs {
Key = global::Avalonia.Input.Key.R,
KeyModifiers = global::Avalonia.Input.KeyModifiers.Control,

4
ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs

@ -51,7 +51,7 @@ public class AssemblyTreeContextMenuTests @@ -51,7 +51,7 @@ public class AssemblyTreeContextMenuTests
// Assert — TreeGrid carries a ContextMenu. (The menu may be empty if no entries are
// registered yet; verifies only that the host is in place.)
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.ContextMenu.Should().NotBeNull();
}
@ -77,7 +77,7 @@ public class AssemblyTreeContextMenuTests @@ -77,7 +77,7 @@ public class AssemblyTreeContextMenuTests
// Act 1 — re-attach the context menu with our stub entries.
pane.AttachContextMenu(new IContextMenuEntryExport[] { export });
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
var menu = grid.ContextMenu!;
// Trigger the build path the live Opening event would take.

30
ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs

@ -46,11 +46,11 @@ public class AssemblyTreeExpanderHitboxTests @@ -46,11 +46,11 @@ public class AssemblyTreeExpanderHitboxTests
[AvaloniaTest]
public async Task Expander_Toggle_Offers_At_Least_16x16_Clickable_Target()
{
// The +/- expander in the assembly tree must give a click target of at least 16x16 for
// reliable tapping, while its visible glyph stays the classic 9x9 box and the column /
// tree-line layout (which assume a 13px expander column, glyph centred at +8.5) is left
// unchanged. The target must be genuinely hittable across the full 16x16 — not merely
// occupy 16x16 of layout while only the 9x9 glyph receives input.
// The +/- expander in the assembly tree must give a click target that fills the full 13px
// expander column and the 16px row-tall toggle, while its visible glyph stays the classic
// 9x9 box. The column is kept at 13px so the +/- box centres on the tree connector lines;
// the target must be genuinely hittable across that whole area — not merely occupy it in
// layout while only the 9x9 glyph receives input.
// Arrange — boot, wait for assemblies, expand a node so an expandable row is realised.
var (window, vm) = await TestHarness.BootAsync(3);
@ -61,7 +61,7 @@ public class AssemblyTreeExpanderHitboxTests @@ -61,7 +61,7 @@ public class AssemblyTreeExpanderHitboxTests
TestCapture.Step("system-linq-expanded-and-selected");
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
// Let rows realise and layout settle.
for (int i = 0; i < 8; i++)
@ -72,7 +72,7 @@ public class AssemblyTreeExpanderHitboxTests @@ -72,7 +72,7 @@ public class AssemblyTreeExpanderHitboxTests
}
// Act — locate the expander toggle of the (expandable) assembly row.
var row = grid.GetVisualDescendants().OfType<DataGridRow>()
var row = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.FirstOrDefault(r => RowMatches(r, assemblyNode));
row.Should().NotBeNull("the expanded assembly row must be realised");
var expander = row!.GetVisualDescendants().OfType<ToggleButton>()
@ -80,9 +80,9 @@ public class AssemblyTreeExpanderHitboxTests @@ -80,9 +80,9 @@ public class AssemblyTreeExpanderHitboxTests
expander.Should().NotBeNull("an expandable row must realise a PART_Expander toggle");
expander!.IsEnabled.Should().BeTrue("the assembly row is expandable");
// Assert — the click target is at least 16x16.
expander.Bounds.Width.Should().BeGreaterThanOrEqualTo(16,
"the expander click target must be at least 16px wide for reliable tapping");
// Assert — the click target fills the 13px expander column and is 16px tall.
expander.Bounds.Width.Should().BeGreaterThanOrEqualTo(13,
"the expander click target must fill the 13px expander column for reliable tapping");
expander.Bounds.Height.Should().BeGreaterThanOrEqualTo(16,
"the expander click target must be at least 16px tall for reliable tapping");
@ -110,12 +110,6 @@ public class AssemblyTreeExpanderHitboxTests @@ -110,12 +110,6 @@ public class AssemblyTreeExpanderHitboxTests
description: "clicking the enlarged expander area (below the glyph) must toggle the node");
}
static bool RowMatches(DataGridRow row, SharpTreeNode target)
{
var ctx = row.DataContext;
if (ReferenceEquals(ctx, target))
return true;
var itemProp = ctx?.GetType().GetProperty("Item");
return itemProp is not null && ReferenceEquals(itemProp.GetValue(ctx), target);
}
static bool RowMatches(global::ILSpy.Controls.TreeView.SharpTreeViewItem row, SharpTreeNode target)
=> ReferenceEquals(row.DataContext, target);
}

87
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -24,7 +24,6 @@ using System.Xml.Linq; @@ -24,7 +24,6 @@ using System.Xml.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
@ -90,10 +89,10 @@ public class AssemblyTreeTests @@ -90,10 +89,10 @@ public class AssemblyTreeTests
// Act — locate the realised DataGrid inside the AssemblyListPane.
var pane = await window.WaitForComponent<AssemblyListPane>();
var treeGrid = await pane.WaitForComponent<DataGrid>();
var treeGrid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
// Assert — SelectionMode is Extended.
treeGrid.SelectionMode.Should().Be(DataGridSelectionMode.Extended,
treeGrid.SelectionMode.Should().Be(global::Avalonia.Controls.SelectionMode.Multiple,
"the assembly tree must let users Ctrl-click multiple rows");
}
@ -631,7 +630,7 @@ public class AssemblyTreeTests @@ -631,7 +630,7 @@ public class AssemblyTreeTests
}
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
vm.AssemblyTreeModel.SelectNode(enumerable);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, enumerable));
@ -655,7 +654,7 @@ public class AssemblyTreeTests @@ -655,7 +654,7 @@ public class AssemblyTreeTests
// Pick the bottom-most visible non-selected row — that's the strictest probe. If the
// bug regressed, CenterRowInView would scroll it up to the middle and offset would
// change.
var candidateRow = grid.GetVisualDescendants().OfType<DataGridRow>()
var candidateRow = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.Where(r => !r.IsSelected
&& r.TranslatePoint(new Point(0, 0), scrollViewer) is { } p
&& p.Y >= 0 && p.Y + r.Bounds.Height <= scrollViewer.Viewport.Height)
@ -784,7 +783,7 @@ public class AssemblyTreeTests @@ -784,7 +783,7 @@ public class AssemblyTreeTests
TestCapture.Step("sacrificial-selected");
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
@ -833,7 +832,7 @@ public class AssemblyTreeTests @@ -833,7 +832,7 @@ public class AssemblyTreeTests
await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, firstAssembly));
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
@ -864,7 +863,7 @@ public class AssemblyTreeTests @@ -864,7 +863,7 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
@ -901,7 +900,7 @@ public class AssemblyTreeTests @@ -901,7 +900,7 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
@ -928,14 +927,14 @@ public class AssemblyTreeTests @@ -928,14 +927,14 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!;
var a = (SharpTreeNode)hm.Flattened[0].Item;
var b = (SharpTreeNode)hm.Flattened[1].Item;
var c = (SharpTreeNode)hm.Flattened[2].Item;
var flat = (System.Collections.IList)grid.ItemsSource!;
var a = (SharpTreeNode)flat[0]!;
var b = (SharpTreeNode)flat[1]!;
var c = (SharpTreeNode)flat[2]!;
model.SelectNode(a);
await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, a));
@ -960,20 +959,20 @@ public class AssemblyTreeTests @@ -960,20 +959,20 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!;
var flat = (System.Collections.IList)grid.ItemsSource!;
// Expand the first assembly so there are plenty of rows and a real "middle".
var firstAsm = (SharpTreeNode)hm.Flattened[0].Item;
var firstAsm = (SharpTreeNode)flat[0]!;
firstAsm.EnsureLazyChildren();
firstAsm.IsExpanded = true;
Dispatcher.UIThread.RunJobs();
await Waiters.WaitForAsync(() => hm.Flattened.Count >= 7);
await Waiters.WaitForAsync(() => flat.Count >= 7);
var start = (SharpTreeNode)hm.Flattened[3].Item;
var below = (SharpTreeNode)hm.Flattened[4].Item;
var start = (SharpTreeNode)flat[3]!;
var below = (SharpTreeNode)flat[4]!;
model.SelectNode(start);
await Waiters.WaitForAsync(() => ReferenceEquals(model.SelectedItem, start));
@ -999,7 +998,7 @@ public class AssemblyTreeTests @@ -999,7 +998,7 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var model = vm.AssemblyTreeModel;
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.Focus();
Dispatcher.UIThread.RunJobs();
@ -1099,40 +1098,27 @@ public class AssemblyTreeTests @@ -1099,40 +1098,27 @@ public class AssemblyTreeTests
}
[AvaloniaTest]
public async Task Setting_SharpTreeNode_IsExpanded_Expands_Row_In_Grid()
public async Task Setting_SharpTreeNode_IsExpanded_Reveals_Children_In_The_Tree()
{
// ProDataGrid's HierarchicalOptions.IsExpandedPropertyPath wires SharpTreeNode.IsExpanded
// to the grid's wrapper expansion state via reflection + INotifyPropertyChanged. Setting
// IsExpanded on the model must propagate to the grid wrapper without any manual
// subscription bookkeeping.
// Arrange — boot, wait for assemblies, locate the realised DataGrid and the
// HierarchicalModel wrapper for the assembly node.
// SharpTreeView binds the TreeFlattener directly, so toggling SharpTreeNode.IsExpanded
// reveals the node's children in the flattened row list with no wrapper bookkeeping.
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var hm = (global::Avalonia.Controls.DataGridHierarchical.IHierarchicalModel)grid.HierarchicalModel!;
var hNode = hm.FindNode(assemblyNode);
hNode.Should().NotBeNull();
hNode!.IsExpanded.Should().BeFalse("baseline: top-level assembly rows start collapsed");
assemblyNode.IsExpanded.Should().BeFalse("baseline: top-level assembly rows start collapsed");
var flat = (System.Collections.IList)grid.ItemsSource!;
int before = flat.Count;
// Act — production-shaped action: just toggle the model property.
assemblyNode.Expand();
// Drain the dispatcher so the wiring (PropertyChanged → hm.Expand) settles.
for (int i = 0; i < 5; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(20);
}
TestCapture.Step("assembly-row-expanded");
// Assert — the grid wrapper now reports IsExpanded == true.
hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue(
"setting SharpTreeNode.IsExpanded must propagate to the HierarchicalModel wrapper");
// Assert — the node's children now appear in the flattened tree.
await Waiters.WaitForAsync(() => flat.Count > before,
description: "setting SharpTreeNode.IsExpanded must reveal its children in the flattened tree");
}
[AvaloniaTest]
@ -1568,7 +1554,7 @@ public class AssemblyTreeTests @@ -1568,7 +1554,7 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.UpdateLayout();
var topLevelCount = vm.AssemblyTreeModel.Root!.Children.Count;
@ -1615,7 +1601,7 @@ public class AssemblyTreeTests @@ -1615,7 +1601,7 @@ public class AssemblyTreeTests
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
grid.UpdateLayout();
var topLevelCount = vm.AssemblyTreeModel.Root!.Children.Count;
@ -1629,13 +1615,16 @@ public class AssemblyTreeTests @@ -1629,13 +1615,16 @@ public class AssemblyTreeTests
TestCapture.Step("all-rows-selected");
// Act — plain left-click on the second visible row.
var targetRow = grid.GetVisualDescendants().OfType<DataGridRow>()
var targetRow = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.OrderBy(r => r.TranslatePoint(new Point(0, 0), grid)?.Y ?? double.MaxValue)
.Skip(1).First();
var targetNode = (targetRow.DataContext as HierarchicalNode)?.Item as SharpTreeNode;
var targetNode = targetRow.DataContext as SharpTreeNode;
Assert.That(targetNode, Is.Not.Null, "the clicked row must wrap a tree node");
// Tree rows stretch to content width (with horizontal scroll), so a row can be wider than
// the grid viewport. Click within the visible viewport, not at the (off-screen) row centre.
var clickX = System.Math.Min(targetRow.Bounds.Width, grid.Bounds.Width) / 2;
var rowCentre = targetRow.TranslatePoint(
new Point(targetRow.Bounds.Width / 2, targetRow.Bounds.Height / 2), window)!.Value;
new Point(clickX, targetRow.Bounds.Height / 2), window)!.Value;
HeadlessWindowExtensions.MouseDown(window, rowCentre, MouseButton.Left);
HeadlessWindowExtensions.MouseUp(window, rowCentre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();

52
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs

@ -16,13 +16,16 @@ @@ -16,13 +16,16 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
@ -36,42 +39,53 @@ namespace ICSharpCode.ILSpy.Tests; @@ -36,42 +39,53 @@ namespace ICSharpCode.ILSpy.Tests;
public class UseNestedNamespaceNodesGridVerification
{
[AvaloniaTest]
public async Task Toggling_UseNestedNamespaceNodes_Triggers_BindTree_So_The_Grid_Sees_The_New_Shape()
public async Task Toggling_UseNestedNamespaceNodes_Reshapes_The_Visible_Tree_In_Place()
{
// The pane caches a snapshot of each expanded node's children via the
// HierarchicalOptions.ChildrenSelector — mid-expand mutations to AssemblyTreeNode.
// Children aren't observed by the live grid. The fix raises AssemblyTreeModel.Root
// PropertyChanged so AssemblyListPane.BindTree fires and replaces the
// HierarchicalModel; this test verifies that re-bind happens.
// Toggling UseNestedNamespaceNodes rebuilds each loaded assembly's namespace subtree.
// SharpTreeView's flattener observes node.Children mutations live, so the rebuilt shape
// surfaces in the visible row list without any model re-bind (the ProDataGrid snapshot
// problem that used to need a HierarchicalModel rebind is gone). This pins that the
// flat-mode -> nested-mode switch is reflected in the SharpTreeView's ItemsSource.
var settings = AppComposition.Current.GetExport<SettingsService>().DisplaySettings;
settings.UseNestedNamespaceNodes = false;
var (window, vm) = await TestHarness.BootAsync(3);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = pane.FindControl<DataGrid>("TreeGrid")!;
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
// Expand an assembly to force the ChildrenSelector to fire and cache its snapshot.
// Expand an assembly so its namespace children become visible rows.
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
assemblyNode.IsExpanded = true;
await Waiters.WaitForAsync(() => grid.HierarchicalModel != null);
await Waiters.WaitForAsync(() => assemblyNode.Children.OfType<NamespaceTreeNode>().Any());
TestCapture.Step("system-linq-expanded-flat");
var flat = (IList)grid.ItemsSource!;
bool VisibleNamespace(string name) => flat.Cast<SharpTreeNode>()
.OfType<NamespaceTreeNode>()
.Any(n => string.Equals(n.Text?.ToString(), name, System.StringComparison.Ordinal));
// In flat mode the full dotted namespace is a single visible row.
await Waiters.WaitForAsync(() => VisibleNamespace("System.Collections.Generic"),
description: "flat mode shows the dotted namespace as one row");
try
{
var modelBefore = grid.HierarchicalModel;
((object?)modelBefore).Should().NotBeNull("baseline: the grid must have a HierarchicalModel before the toggle");
settings.UseNestedNamespaceNodes = true;
assemblyNode.IsExpanded = true;
TestCapture.Step("nested-namespaces-rebuilt");
// In nested mode the leaf segment ("Generic") becomes its own row nested under
// "System" -> "Collections"; the live flattener must surface that without a re-bind.
await Waiters.WaitForAsync(
() => !ReferenceEquals(grid.HierarchicalModel, modelBefore),
System.TimeSpan.FromSeconds(5));
TestCapture.Step("nested-namespaces-rebound");
() => assemblyNode.Children.OfType<NamespaceTreeNode>()
.Any(n => string.Equals(n.Text?.ToString(), "System", System.StringComparison.Ordinal)),
System.TimeSpan.FromSeconds(5),
"toggling UseNestedNamespaceNodes must rebuild the namespace subtree into nested nodes");
grid.HierarchicalModel.Should().NotBeSameAs(modelBefore,
"toggling UseNestedNamespaceNodes must force AssemblyListPane.BindTree, "
+ "replacing the HierarchicalModel with one that reads the rebuilt children");
VisibleNamespace("System").Should().BeTrue(
"the rebuilt nested 'System' namespace node must appear in the SharpTreeView's visible rows "
+ "without any model re-bind (the live flattener observes the child mutations directly)");
}
finally
{

2
ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesScreenshot.cs

@ -54,7 +54,7 @@ public class UseNestedNamespaceNodesScreenshot @@ -54,7 +54,7 @@ public class UseNestedNamespaceNodesScreenshot
await Waiters.WaitForAsync(() =>
assemblyNode.Children.Count > 0
&& assemblyNode.Children[assemblyNode.Children.Count - 1] is NamespaceTreeNode);
// Re-expand — BindTree replaces the HierarchicalModel and resets visible expansion.
// Re-expand — the namespace subtree was rebuilt, so make it visible again.
assemblyNode.IsExpanded = true;
Dispatcher.UIThread.RunJobs();

6
ILSpy.Tests/Diagnostics/StartupPerfTests.cs

@ -121,13 +121,13 @@ public class StartupPerfTests @@ -121,13 +121,13 @@ public class StartupPerfTests
+ $"({swLoad.ElapsedMilliseconds / (double)allAssemblies.Length:0.##} ms/asm)");
// Act 4 — the assembly-tree pane should still be responsive. Measure how long it
// takes the AssemblyListPane to produce a fresh DataGrid descendant from this point
// takes the AssemblyListPane to produce a fresh SharpTreeView descendant from this point
// (proxy for "tree is interactive").
var pane = await window.WaitForComponent<AssemblyListPane>();
var swGrid = Stopwatch.StartNew();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
swGrid.Stop();
TestContext.Out.WriteLine($"DataGrid descendant available: {swGrid.ElapsedMilliseconds} ms");
TestContext.Out.WriteLine($"SharpTreeView descendant available: {swGrid.ElapsedMilliseconds} ms");
// Sanity assertions — large enough to never trip on slow machines, but tight
// enough to flag a 10× regression.

57
ILSpy.Tests/Input/HeadlessMmbPointerTests.cs

@ -21,7 +21,6 @@ using System.Threading.Tasks; @@ -21,7 +21,6 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
@ -60,16 +59,14 @@ public class HeadlessMmbPointerTests @@ -60,16 +59,14 @@ public class HeadlessMmbPointerTests
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
// Any realised DataGridRow whose DataContext wraps an ILSpyTreeNode is a valid click
// Any realised SharpTreeViewItem whose DataContext is an ILSpyTreeNode is a valid click
// target — the exact node doesn't matter, only that the gesture pipeline fires.
// ProDataGrid wraps each row's data in a HierarchicalNode (`{ Item: ILSpyTreeNode }`),
// so we walk that surface rather than comparing against the tree node directly.
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<DataGridRow>()
.Any(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode }));
var row = grid.GetVisualDescendants().OfType<DataGridRow>()
.First(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode });
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.Any(r => r.DataContext is ILSpyTreeNode));
var row = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.First(r => r.DataContext is ILSpyTreeNode);
// Snapshot the tab count BEFORE the gesture so we can assert a strict +1 after.
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
@ -78,11 +75,13 @@ public class HeadlessMmbPointerTests @@ -78,11 +75,13 @@ public class HeadlessMmbPointerTests
// Translate the row's centre into TopLevel coordinates so MouseDown lands inside it.
var topLevel = TopLevel.GetTopLevel(row)!;
var rowBounds = row.Bounds;
var centreInRow = new Point(rowBounds.Width / 2, rowBounds.Height / 2);
// Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the
// visible grid viewport — the row centre can sit off-screen past the grid's right edge.
var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2);
var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel)
?? throw new System.InvalidOperationException("Row not in TopLevel visual tree");
// Real pointer event — exercises bubble + handledEventsToo routing through ProDataGrid.
// Real pointer event — exercises bubble + handledEventsToo routing through SharpTreeView.
topLevel.MouseDown(centreInTopLevel, MouseButton.Middle);
topLevel.MouseUp(centreInTopLevel, MouseButton.Middle);
@ -95,8 +94,8 @@ public class HeadlessMmbPointerTests @@ -95,8 +94,8 @@ public class HeadlessMmbPointerTests
[AvaloniaTest]
public async Task Ctrl_LMB_On_An_Assembly_Tree_Row_Does_Not_Open_A_New_Tab()
{
// Ctrl+LMB previously did double duty: ProDataGrid's Extended-selection mode treats
// it as "toggle this row in the multi-selection", and our OnTreeGridPointerPressed
// Ctrl+LMB previously did double duty: the tree's Extended-selection mode treats
// it as "toggle this row in the multi-selection", and our OnTreePointerPressed
// also treated it as "open in new tab". Both fired on a single click, so users
// trying to extend a multi-selection ended up spawning unwanted tabs. The intended
// gesture for "open in new tab" is MMB (matches WPF's DecompileInNewViewCommand
@ -109,19 +108,21 @@ public class HeadlessMmbPointerTests @@ -109,19 +108,21 @@ public class HeadlessMmbPointerTests
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<DataGridRow>()
.Any(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode }));
var row = grid.GetVisualDescendants().OfType<DataGridRow>()
.First(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode });
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.Any(r => r.DataContext is ILSpyTreeNode));
var row = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.First(r => r.DataContext is ILSpyTreeNode);
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
int before = documents.VisibleDockables?.Count ?? 0;
var topLevel = TopLevel.GetTopLevel(row)!;
var rowBounds = row.Bounds;
var centreInRow = new Point(rowBounds.Width / 2, rowBounds.Height / 2);
// Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the
// visible grid viewport — the row centre can sit off-screen past the grid's right edge.
var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2);
var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel)
?? throw new System.InvalidOperationException("Row not in TopLevel visual tree");
@ -133,7 +134,7 @@ public class HeadlessMmbPointerTests @@ -133,7 +134,7 @@ public class HeadlessMmbPointerTests
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs();
(documents.VisibleDockables?.Count ?? 0).Should().Be(before,
"Ctrl+LMB must be reserved for ProDataGrid's multi-selection toggle — only MMB opens a new tab");
"Ctrl+LMB must be reserved for the tree's multi-selection toggle — only MMB opens a new tab");
}
[AvaloniaTest]
@ -151,22 +152,24 @@ public class HeadlessMmbPointerTests @@ -151,22 +152,24 @@ public class HeadlessMmbPointerTests
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var pane = await window.WaitForComponent<AssemblyListPane>();
var grid = await pane.WaitForComponent<DataGrid>();
var grid = await pane.WaitForComponent<global::ILSpy.Controls.TreeView.SharpTreeView>();
// Any realised DataGridRow whose data wraps an ILSpyTreeNode is a fine target; the
// Any realised SharpTreeViewItem whose data is an ILSpyTreeNode is a fine target; the
// non-leaf assembly node row (depth 0) is always present and never confused with a
// method row whose tree-toggle area is null.
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<DataGridRow>()
.Any(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode }));
var row = grid.GetVisualDescendants().OfType<DataGridRow>()
.First(r => r.DataContext is HierarchicalNode { Item: ILSpyTreeNode });
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.Any(r => r.DataContext is ILSpyTreeNode));
var row = grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>()
.First(r => r.DataContext is ILSpyTreeNode);
var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
int before = documents.VisibleDockables?.Count ?? 0;
var topLevel = TopLevel.GetTopLevel(row)!;
var rowBounds = row.Bounds;
var centreInRow = new Point(rowBounds.Width / 2, rowBounds.Height / 2);
// Tree rows stretch to content width (with horizontal scroll), so clamp the click X to the
// visible grid viewport — the row centre can sit off-screen past the grid's right edge.
var centreInRow = new Point(System.Math.Min(rowBounds.Width, grid.Bounds.Width) / 2, rowBounds.Height / 2);
var centreInTopLevel = row.TranslatePoint(centreInRow, topLevel)
?? throw new System.InvalidOperationException("Row not in TopLevel visual tree");

1
ILSpy.Tests/Options/ApiVisibilityFilterTests.cs

@ -21,7 +21,6 @@ using System.Threading.Tasks; @@ -21,7 +21,6 @@ using System.Threading.Tasks;
using System.Xml.Linq;
using Avalonia.Controls;
using Avalonia.Controls.DataGridHierarchical;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;

29
ILSpy.Tests/TreeNodeAssertions.cs

@ -79,11 +79,11 @@ public class TreeNodeBeAssertions @@ -79,11 +79,11 @@ public class TreeNodeBeAssertions
}
var scrollViewer = grid.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault()
?? throw new InvalidOperationException("DataGrid has no ScrollViewer in its visual tree.");
?? throw new InvalidOperationException("SharpTreeView has no ScrollViewer in its visual tree.");
// ScrollIntoView is posted at Background priority — give it time to realise the row.
var deadline = DateTime.UtcNow + ScrollPollTimeout;
DataGridRow? row = null;
global::ILSpy.Controls.TreeView.SharpTreeViewItem? row = null;
bool ok = false;
while (DateTime.UtcNow < deadline)
{
@ -104,7 +104,7 @@ public class TreeNodeBeAssertions @@ -104,7 +104,7 @@ public class TreeNodeBeAssertions
return new AndConstraint<TreeNodeBeAssertions>(this);
}
static DataGrid? TryFindGrid(out string? failure)
static global::ILSpy.Controls.TreeView.SharpTreeView? TryFindGrid(out string? failure)
{
var window = FindActiveWindow();
if (window is null)
@ -120,10 +120,10 @@ public class TreeNodeBeAssertions @@ -120,10 +120,10 @@ public class TreeNodeBeAssertions
return null;
}
var grid = pane.GetVisualDescendants().OfType<DataGrid>().FirstOrDefault(g => g.Name == "TreeGrid");
var grid = pane.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeView>().FirstOrDefault();
if (grid is null)
{
failure = "TreeGrid DataGrid not found inside AssemblyListPane";
failure = "SharpTreeView not found inside AssemblyListPane";
return null;
}
@ -145,22 +145,17 @@ public class TreeNodeBeAssertions @@ -145,22 +145,17 @@ public class TreeNodeBeAssertions
}
}
static DataGridRow? FindRow(DataGrid grid, SharpTreeNode target)
static global::ILSpy.Controls.TreeView.SharpTreeViewItem? FindRow(global::ILSpy.Controls.TreeView.SharpTreeView grid, SharpTreeNode target)
{
foreach (var row in grid.GetVisualDescendants().OfType<DataGridRow>())
foreach (var row in grid.GetVisualDescendants().OfType<global::ILSpy.Controls.TreeView.SharpTreeViewItem>())
{
var ctx = row.DataContext;
if (ReferenceEquals(ctx, target))
return row;
// HierarchicalNode wraps the underlying item via .Item.
var itemProp = ctx?.GetType().GetProperty("Item");
if (itemProp is not null && ReferenceEquals(itemProp.GetValue(ctx), target))
if (ReferenceEquals(row.DataContext, target))
return row;
}
return null;
}
static bool IsInViewport(DataGridRow row, ScrollViewer scrollViewer)
static bool IsInViewport(global::ILSpy.Controls.TreeView.SharpTreeViewItem row, ScrollViewer scrollViewer)
{
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
if (topLeft is null)
@ -170,7 +165,7 @@ public class TreeNodeBeAssertions @@ -170,7 +165,7 @@ public class TreeNodeBeAssertions
return top >= 0 && bottom <= scrollViewer.Viewport.Height + 0.5;
}
static bool IsRoughlyCentered(DataGridRow row, ScrollViewer scrollViewer)
static bool IsRoughlyCentered(global::ILSpy.Controls.TreeView.SharpTreeViewItem row, ScrollViewer scrollViewer)
{
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
if (topLeft is null)
@ -183,10 +178,10 @@ public class TreeNodeBeAssertions @@ -183,10 +178,10 @@ public class TreeNodeBeAssertions
return Math.Abs(rowMid - viewportMid) <= row.Bounds.Height || nearTopClamp || nearBottomClamp;
}
string BuildState(ScrollViewer scrollViewer, DataGridRow? row)
string BuildState(ScrollViewer scrollViewer, global::ILSpy.Controls.TreeView.SharpTreeViewItem? row)
{
if (row is null)
return $"but no DataGridRow has been realised for it (offset={scrollViewer.Offset}, viewport={scrollViewer.Viewport}, extent={scrollViewer.Extent})";
return $"but no SharpTreeViewItem has been realised for it (offset={scrollViewer.Offset}, viewport={scrollViewer.Viewport}, extent={scrollViewer.Extent})";
var topLeft = row.TranslatePoint(new Point(0, 0), scrollViewer);
var top = topLeft?.Y;
var bottom = top + row.Bounds.Height;

3
ILSpy/Controls/TreeView/SharpTreeView.axaml

@ -59,7 +59,8 @@ @@ -59,7 +59,8 @@
<StackPanel Orientation="Horizontal" Height="20">
<!-- Indent spacer = (Level - 1) * step (top level sits flush left). -->
<Border Width="{Binding Level, Converter={x:Static tv:TreeIndentConverter.Instance}}" />
<ToggleButton Theme="{StaticResource TreeExpanderToggleTheme}"
<ToggleButton Name="PART_Expander"
Theme="{StaticResource TreeExpanderToggleTheme}"
VerticalAlignment="Center"
IsChecked="{Binding IsExpanded, Mode=TwoWay}"
IsVisible="{Binding ShowExpander}" />

25
ILSpy/Controls/TreeView/SharpTreeView.cs

@ -72,6 +72,9 @@ namespace ILSpy.Controls.TreeView @@ -72,6 +72,9 @@ namespace ILSpy.Controls.TreeView
public SharpTreeView()
{
// Take keyboard focus directly so gestures like Ctrl+A work the moment the user tabs
// or clicks into the pane, before any row becomes the current item.
Focusable = true;
SelectionChanged += OnSelectionChanged;
DoubleTapped += OnDoubleTapped;
// Drag-drop is handled generically here and delegated to SharpTreeNode.CanDrop/Drop.
@ -204,6 +207,14 @@ namespace ILSpy.Controls.TreeView @@ -204,6 +207,14 @@ namespace ILSpy.Controls.TreeView
Dispatcher.UIThread.Post(() => (ContainerFromItem(node))?.Focus(), DispatcherPriority.Loaded);
}
/// <summary>Moves the (single) selection to <paramref name="node"/> and focuses it.
/// Used by keyboard navigation where selection must follow the caret.</summary>
void SelectAndFocus(SharpTreeNode node)
{
SelectedItem = node;
FocusNode(node);
}
public void ScrollIntoNodeView(SharpTreeNode node)
{
ArgumentNullException.ThrowIfNull(node);
@ -216,6 +227,16 @@ namespace ILSpy.Controls.TreeView @@ -216,6 +227,16 @@ namespace ILSpy.Controls.TreeView
protected override void OnKeyDown(KeyEventArgs e)
{
// Ctrl+A select-all must work on the first press even before a current item is
// established (right after the tree gains focus). The base ListBox only selects all
// when a focused item lives inside it, so drive it explicitly here.
if (e.Key == Key.A && e.KeyModifiers == KeyModifiers.Control
&& SelectionMode.HasFlag(SelectionMode.Multiple))
{
SelectAll();
e.Handled = true;
return;
}
var node = (e.Source as Visual)?.FindAncestorOfType<SharpTreeViewItem>(includeSelf: true)?.Node
?? SelectedItem as SharpTreeNode;
if (node != null && e.KeyModifiers == KeyModifiers.None)
@ -226,7 +247,7 @@ namespace ILSpy.Controls.TreeView @@ -226,7 +247,7 @@ namespace ILSpy.Controls.TreeView
if (node.IsExpanded)
node.IsExpanded = false;
else if (node.Parent != null && !node.Parent.IsRoot)
FocusNode(node.Parent);
SelectAndFocus(node.Parent);
else
break;
e.Handled = true;
@ -235,7 +256,7 @@ namespace ILSpy.Controls.TreeView @@ -235,7 +256,7 @@ namespace ILSpy.Controls.TreeView
if (!node.IsExpanded && node.ShowExpander)
node.IsExpanded = true;
else if (node.Children.Count > 0)
FocusNode(node.Children.First(c => c.IsVisible));
SelectAndFocus(node.Children.First(c => c.IsVisible));
else
break;
e.Handled = true;

Loading…
Cancel
Save