Browse Source

Render row details in the metadata viewer

WPF ILSpy used DataGrid row details in three places: the COFF and
Optional header views (flag-bit breakdown of the Characteristics /
DLL Characteristics words, permanently expanded) and the
CustomDebugInformation table (decoded Value blob, visible when
selected). The Avalonia port already carried the data — both header
nodes populate Entry.RowDetails and the column builder skips it — but
nothing displayed it.

ProDataGrid builds the row-details template once per details element
and recycles the built control across rows, swapping only the
DataContext, so a WPF-style template selector would go stale;
MetadataRowDetailsControl re-runs its content factory on every
DataContext change instead. Per-row initial visibility has no
SetDetailsVisibilityForItem equivalent and recycling resets
AreDetailsVisible, so the view re-derives it in LoadingRow. Row
activation now ignores gestures originating inside the details
presenter — selecting text in a details blob must not navigate away.

This lands the infrastructure plus the two header consumers; the
CustomDebugInformation details build on the EmbeddedSource decoding
work and follow with it. ConfigurePage on MetadataTableTreeNode is
the hook that consumer overrides.

Assisted-by: Claude:claude-fable-5[1m]:Claude Code
pull/3759/head
Siegfried Pammer 4 weeks ago
parent
commit
5048bae569
  1. 183
      ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs
  2. 1
      ILSpy/Metadata/CoffHeaderTreeNode.cs
  3. 160
      ILSpy/Metadata/MetadataRowDetails.cs
  4. 10
      ILSpy/Metadata/MetadataTableTreeNode.cs
  5. 1
      ILSpy/Metadata/OptionalHeaderTreeNode.cs
  6. 23
      ILSpy/ViewModels/MetadataTablePageModel.cs
  7. 44
      ILSpy/Views/MetadataTablePage.axaml.cs

183
ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs

@ -0,0 +1,183 @@ @@ -0,0 +1,183 @@
// 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.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ILSpy.Metadata;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Metadata;
[TestFixture]
public class MetadataRowDetailsTests
{
[AvaloniaTest]
public void Shell_Rebuilds_Its_Content_Whenever_The_DataContext_Changes()
{
// The DataGrid builds a row-details template once per details element and then reuses
// the built control across row recycling, swapping only the DataContext. Per-item
// content (text blob vs. sub-grid) therefore has to be produced by a control that
// re-runs its factory on every DataContext change — that is the shell's contract.
var factoryInputs = new List<object?>();
var shell = new MetadataRowDetailsControl(item => {
factoryInputs.Add(item);
return item switch {
string s => new TextBox { Text = s },
IList<BitEntry> bits => new DataGrid { ItemsSource = bits },
_ => null,
};
});
shell.DataContext = "blob text";
shell.Content.Should().BeOfType<TextBox>()
.Which.Text.Should().Be("blob text");
var bits = new List<BitEntry> { new(true, "<0001> bit") };
shell.DataContext = bits;
shell.Content.Should().BeOfType<DataGrid>()
.Which.ItemsSource.Should().BeSameAs(bits);
shell.DataContext = null;
shell.Content.Should().BeNull("a recycled details element gets its DataContext nulled and must drop stale content");
factoryInputs.Should().Equal("blob text", bits, null);
}
[AvaloniaTest]
public void CreateTemplate_Builds_A_DataContext_Tracking_Shell()
{
var template = MetadataRowDetails.CreateTemplate(
item => item is string s ? new TextBox { Text = s } : null);
var control = template.Build("ignored-build-parameter");
var shell = control.Should().BeOfType<MetadataRowDetailsControl>().Subject;
shell.DataContext = "hello";
shell.Content.Should().BeOfType<TextBox>().Which.Text.Should().Be("hello");
}
[AvaloniaTest]
public async Task Coff_Characteristics_Row_Renders_Its_Flag_Bits_In_Expanded_Row_Details()
{
// The COFF header view keeps the Characteristics flags word permanently expanded:
// the row's details area lists all 16 bits with their meanings, while every other
// row of the header stays detail-less.
var (window, vm) = await TestHarness.BootAsync();
var coffNode = vm.AssemblyTreeModel.FindCoreLib()
.GetChild<MetadataTreeNode>()
.GetChild<CoffHeaderTreeNode>();
vm.AssemblyTreeModel.SelectNode(coffNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("coff-header-grid");
tab.RowDetailsTemplate.Should().NotBeNull("the COFF header page carries a flags details template");
tab.IsRowDetailsVisible.Should().NotBeNull();
var metadataPage = await window.WaitForComponent<MetadataTablePage>();
var grid = metadataPage.FindControl<DataGrid>("Grid")!;
await grid.WaitForComponent<DataGridRow>();
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<DataGridRow>()
.Any(r => r.DataContext is Entry { Member: "Characteristics" } && r.AreDetailsVisible));
TestCapture.Step("characteristics-row-expanded");
var rows = grid.GetVisualDescendants().OfType<DataGridRow>().ToList();
var characteristicsRow = rows.Single(r => r.DataContext is Entry { Member: "Characteristics" });
characteristicsRow.AreDetailsVisible.Should().BeTrue();
rows.Where(r => r != characteristicsRow).Should()
.OnlyContain(r => !r.AreDetailsVisible, "only the flags row carries details");
var shell = await characteristicsRow.WaitForComponent<MetadataRowDetailsControl>();
var flagsGrid = shell.Content.Should().BeOfType<DataGrid>().Subject;
((IEnumerable)flagsGrid.ItemsSource!).Cast<BitEntry>().Should().HaveCount(16);
flagsGrid.HeadersVisibility.Should().Be(DataGridHeadersVisibility.None);
}
[AvaloniaTest]
public async Task Optional_Header_Expands_Details_For_The_Dll_Characteristics_Row_Only()
{
var (_, vm) = await TestHarness.BootAsync();
var optionalNode = vm.AssemblyTreeModel.FindCoreLib()
.GetChild<MetadataTreeNode>()
.GetChild<OptionalHeaderTreeNode>();
vm.AssemblyTreeModel.SelectNode(optionalNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
TestCapture.Step("optional-header-grid");
tab.RowDetailsTemplate.Should().NotBeNull();
tab.RowDetailsVisibilityMode.Should().Be(DataGridRowDetailsVisibilityMode.Collapsed,
"non-flag rows must never grow a details area, not even on selection");
tab.IsRowDetailsVisible.Should().NotBeNull();
var entries = tab.Items.Cast<Entry>().ToList();
var dllCharacteristics = entries.Single(e => e.Member == "DLL Characteristics");
tab.IsRowDetailsVisible!(dllCharacteristics).Should().BeTrue();
entries.Where(e => e != dllCharacteristics).Should()
.OnlyContain(e => !tab.IsRowDetailsVisible!(e));
}
[AvaloniaTest]
public async Task Double_Tap_Inside_The_Details_Area_Does_Not_Resolve_To_An_Activatable_Row()
{
// Row activation navigates away from the metadata view. A double-click inside the
// details area (e.g. word-selection in an embedded-source TextBox, or a click in the
// flags sub-grid) is interacting with the details content, not requesting navigation,
// so the row-resolution walk must reject sources under the details presenter.
var (window, vm) = await TestHarness.BootAsync();
var coffNode = vm.AssemblyTreeModel.FindCoreLib()
.GetChild<MetadataTreeNode>()
.GetChild<CoffHeaderTreeNode>();
vm.AssemblyTreeModel.SelectNode(coffNode);
await vm.DockWorkspace.WaitForMetadataTabAsync();
var metadataPage = await window.WaitForComponent<MetadataTablePage>();
var grid = metadataPage.FindControl<DataGrid>("Grid")!;
await grid.WaitForComponent<DataGridRow>();
await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType<DataGridRow>()
.Any(r => r.DataContext is Entry { Member: "Characteristics" } && r.AreDetailsVisible));
var characteristicsRow = grid.GetVisualDescendants().OfType<DataGridRow>()
.Single(r => r.DataContext is Entry { Member: "Characteristics" });
var shell = await characteristicsRow.WaitForComponent<MetadataRowDetailsControl>();
MetadataTablePage.FindActivatableRow(shell).Should().BeNull(
"visuals under the details presenter must not trigger row activation");
var cell = characteristicsRow.GetVisualDescendants().OfType<DataGridCell>().First();
MetadataTablePage.FindActivatableRow(cell).Should().BeSameAs(characteristicsRow,
"regular cells keep resolving to their owning row");
}
}

1
ILSpy/Metadata/CoffHeaderTreeNode.cs

@ -52,6 +52,7 @@ namespace ILSpy.Metadata @@ -52,6 +52,7 @@ namespace ILSpy.Metadata
Items = BuildEntries(),
};
MetadataColumnBuilder.Populate<Entry>(page);
MetadataRowDetails.ConfigureEntryFlagsDetails(page);
return page;
}

160
ILSpy/Metadata/MetadataRowDetails.cs

@ -0,0 +1,160 @@ @@ -0,0 +1,160 @@
// 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;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Media;
using ILSpy.ViewModels;
namespace ILSpy.Metadata
{
/// <summary>
/// Hosts the per-row details content of a metadata DataGrid. The DataGrid builds its
/// <c>RowDetailsTemplate</c> once per details element and keeps the built control across
/// row recycling, only swapping the DataContext — a template that picked its control tree
/// at build time would keep showing the presentation of whatever row it was first built
/// for. This shell re-runs its content factory on every DataContext change instead, so
/// the factory can return a different control per row (text blob vs. sub-grid) or none
/// at all (a nulled DataContext on a recycled row drops the stale content).
/// </summary>
public sealed class MetadataRowDetailsControl : ContentControl
{
readonly Func<object?, Control?> contentFactory;
public MetadataRowDetailsControl(Func<object?, Control?> contentFactory)
{
this.contentFactory = contentFactory ?? throw new ArgumentNullException(nameof(contentFactory));
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == DataContextProperty)
Content = contentFactory(change.NewValue);
}
}
/// <summary>
/// Factories for the row-details area of metadata grids: the DataContext-tracking shell
/// template plus the content shapes shared across tables (flag-bit breakdown, decoded
/// text blob, typed sub-grid).
/// </summary>
public static class MetadataRowDetails
{
/// <summary>
/// Wraps <paramref name="contentFactory"/> in a row-details template. The factory
/// receives the row item (or <see langword="null"/> while the row is recycled) and
/// returns the details content for that row, or <see langword="null"/> for rows
/// without details.
/// </summary>
public static IDataTemplate CreateTemplate(Func<object?, Control?> contentFactory)
{
ArgumentNullException.ThrowIfNull(contentFactory);
return new FuncDataTemplate<object?>(
(_, _) => new MetadataRowDetailsControl(contentFactory),
supportsRecycling: false);
}
/// <summary>
/// Configures <paramref name="page"/> so every <see cref="Entry"/> row carrying a
/// flag-bit breakdown (<see cref="Entry.RowDetails"/>) renders it permanently
/// expanded beneath the row, while all other rows stay detail-less.
/// </summary>
public static void ConfigureEntryFlagsDetails(MetadataTablePageModel page)
{
ArgumentNullException.ThrowIfNull(page);
page.RowDetailsTemplate = CreateTemplate(
static item => item is Entry { RowDetails: { } bits } ? BuildFlagsGrid(bits) : null);
page.RowDetailsVisibilityMode = DataGridRowDetailsVisibilityMode.Collapsed;
page.IsRowDetailsVisible = static item => item is Entry { RowDetails: not null };
}
/// <summary>Headerless two-column (set? / meaning) breakdown of a flags word.</summary>
public static Control BuildFlagsGrid(IList<BitEntry> bits)
{
ArgumentNullException.ThrowIfNull(bits);
var grid = CreateInnerGrid(bits);
grid.HeadersVisibility = DataGridHeadersVisibility.None;
grid.Columns.Add(new DataGridCheckBoxColumn {
Binding = new Binding(nameof(BitEntry.Value)),
IsReadOnly = true,
});
grid.Columns.Add(new DataGridTextColumn {
Binding = new Binding(nameof(BitEntry.Meaning)),
IsReadOnly = true,
});
return grid;
}
/// <summary>Read-only, word-wrapped view of a decoded text blob (source, JSON, hex).</summary>
public static Control BuildTextBlob(string text)
{
ArgumentNullException.ThrowIfNull(text);
return new TextBox {
Text = text,
IsReadOnly = true,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 800,
MaxHeight = 400,
HorizontalAlignment = HorizontalAlignment.Left,
};
}
/// <summary>
/// Sub-grid over parsed blob rows; one text column per (header, property) pair.
/// </summary>
public static Control BuildDetailsGrid(IEnumerable rows, params (string Header, string PropertyName)[] columns)
{
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(columns);
var grid = CreateInnerGrid(rows);
grid.HeadersVisibility = DataGridHeadersVisibility.Column;
// Bound, because structured blobs (hoisted scopes, metadata references) can run
// to hundreds of rows; the sub-grid scrolls instead of growing the host row.
grid.MaxHeight = 250;
foreach (var (header, propertyName) in columns)
{
grid.Columns.Add(new DataGridTextColumn {
Header = header,
Binding = new Binding(propertyName),
IsReadOnly = true,
});
}
return grid;
}
static DataGrid CreateInnerGrid(IEnumerable rows) => new() {
ItemsSource = rows,
IsReadOnly = true,
AutoGenerateColumns = false,
GridLinesVisibility = DataGridGridLinesVisibility.None,
CanUserReorderColumns = false,
CanUserSortColumns = false,
SelectionMode = DataGridSelectionMode.Single,
HorizontalAlignment = HorizontalAlignment.Left,
};
}
}

10
ILSpy/Metadata/MetadataTableTreeNode.cs

@ -177,7 +177,17 @@ namespace ILSpy.Metadata @@ -177,7 +177,17 @@ namespace ILSpy.Metadata
Items = cached ??= LoadTable(),
};
MetadataColumnBuilder.Populate<TEntry>(page);
ConfigurePage(page);
return page;
}
/// <summary>
/// Per-table hook for page settings beyond the reflected columns — e.g. the
/// CustomDebugInformation table attaches a row-details template here. The default
/// leaves the page as the column builder produced it.
/// </summary>
protected virtual void ConfigurePage(MetadataTablePageModel page)
{
}
}
}

1
ILSpy/Metadata/OptionalHeaderTreeNode.cs

@ -53,6 +53,7 @@ namespace ILSpy.Metadata @@ -53,6 +53,7 @@ namespace ILSpy.Metadata
Items = BuildEntries(),
};
MetadataColumnBuilder.Populate<Entry>(page);
MetadataRowDetails.ConfigureEntryFlagsDetails(page);
return page;
}

23
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -26,6 +26,7 @@ using System.Reflection; @@ -26,6 +26,7 @@ using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using CommunityToolkit.Mvvm.ComponentModel;
@ -55,6 +56,28 @@ namespace ILSpy.ViewModels @@ -55,6 +56,28 @@ namespace ILSpy.ViewModels
[ObservableProperty]
private int? scrollToRow;
/// <summary>
/// Optional template for the expandable details area beneath each row (flag-bit
/// breakdowns, decoded blob content). The view copies it onto the DataGrid whenever
/// the schema rebinds; <see langword="null"/> leaves the table without row details.
/// </summary>
public IDataTemplate? RowDetailsTemplate { get; set; }
/// <summary>
/// When the details area shows: <c>Collapsed</c> tables expand rows individually via
/// <see cref="IsRowDetailsVisible"/>; <c>VisibleWhenSelected</c> previews the selected
/// row's details as the user moves through the table.
/// </summary>
public DataGridRowDetailsVisibilityMode RowDetailsVisibilityMode { get; set; }
= DataGridRowDetailsVisibilityMode.Collapsed;
/// <summary>
/// Per-row initial details visibility, applied by the view each time a row container
/// materialises (rows are recycled with their visibility reset). Only consulted when
/// non-null; rows without details should yield <see langword="false"/>.
/// </summary>
public Func<object, bool>? IsRowDetailsVisible { get; set; }
/// <summary>
/// One filter input per column, in the same order as <see cref="Columns"/>. The view
/// renders each <see cref="ColumnFilter.Text"/> as a TextBox baked into that column's

44
ILSpy/Views/MetadataTablePage.axaml.cs

@ -60,6 +60,7 @@ namespace ILSpy.Views @@ -60,6 +60,7 @@ namespace ILSpy.Views
if (grid is not null)
{
grid.DoubleTapped += OnGridDoubleTapped;
grid.LoadingRow += OnGridLoadingRow;
// 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. Tunnel was tried first but
@ -82,11 +83,37 @@ namespace ILSpy.Views @@ -82,11 +83,37 @@ namespace ILSpy.Views
// Resolve the row from the double-tapped element rather than grid.SelectedItem: a
// double-click on a column header (e.g. rapidly toggling its sort button) must NOT
// navigate to the currently-selected row — only a double-click on a data row does.
var visual = e.Source as Visual;
if (FindActivatableRow(e.Source as Visual) is { DataContext: { } row })
page.RaiseRowActivated(row);
}
/// <summary>
/// Walks up from <paramref name="source"/> to the data row a click gesture may
/// activate. Returns <see langword="null"/> when the gesture landed outside any row,
/// or inside the row's details area — interacting with details content (selecting
/// text in an embedded-source blob, scrolling a flags sub-grid) must not navigate
/// away from the metadata view.
/// </summary>
internal static DataGridRow? FindActivatableRow(Visual? source)
{
var visual = source;
while (visual is not null and not DataGridRow)
{
if (visual is global::Avalonia.Controls.Primitives.DataGridDetailsPresenter)
return null;
visual = visual.GetVisualParent();
if (visual is DataGridRow { DataContext: { } row })
page.RaiseRowActivated(row);
}
return visual as DataGridRow;
}
void OnGridLoadingRow(object? sender, DataGridRowEventArgs e)
{
// Row containers are recycled with AreDetailsVisible reset to false, so the
// per-item visibility has to be re-derived every time a container materialises.
if (boundModel?.IsRowDetailsVisible is not { } isVisible)
return;
if (e.Row.DataContext is { } item)
e.Row.AreDetailsVisible = isVisible(item);
}
void OnGridPointerPressed(object? sender, PointerPressedEventArgs e)
@ -99,10 +126,7 @@ namespace ILSpy.Views @@ -99,10 +126,7 @@ namespace ILSpy.Views
if (e.Source is not Visual hit
|| !e.GetCurrentPoint(hit).Properties.IsMiddleButtonPressed)
return;
var visual = hit;
while (visual is not null and not DataGridRow)
visual = visual.GetVisualParent();
if (visual is not DataGridRow row || row.DataContext is null)
if (FindActivatableRow(hit) is not { } row || row.DataContext is null)
return;
page.RaiseRowActivated(row.DataContext, openInNewTab: true);
e.Handled = true;
@ -344,6 +368,12 @@ namespace ILSpy.Views @@ -344,6 +368,12 @@ namespace ILSpy.Views
grid.ItemsSource = Array.Empty<object>();
grid.Columns.Clear();
itemsView = null;
// Apply the row-details schema before the new items attach so the first row
// materialisation already sees the right template and visibility mode. The grid's
// property is annotated non-nullable but null is its documented "no details" value.
grid.RowDetailsTemplate = boundModel?.RowDetailsTemplate!;
grid.RowDetailsVisibilityMode = boundModel?.RowDetailsVisibilityMode
?? DataGridRowDetailsVisibilityMode.Collapsed;
if (boundModel == null)
return;
foreach (var c in boundModel.Columns)

Loading…
Cancel
Save