diff --git a/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs new file mode 100644 index 000000000..e49263ad0 --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataRowDetailsTests.cs @@ -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(); + var shell = new MetadataRowDetailsControl(item => { + factoryInputs.Add(item); + return item switch { + string s => new TextBox { Text = s }, + IList bits => new DataGrid { ItemsSource = bits }, + _ => null, + }; + }); + + shell.DataContext = "blob text"; + shell.Content.Should().BeOfType() + .Which.Text.Should().Be("blob text"); + + var bits = new List { new(true, "<0001> bit") }; + shell.DataContext = bits; + shell.Content.Should().BeOfType() + .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().Subject; + shell.DataContext = "hello"; + shell.Content.Should().BeOfType().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() + .GetChild(); + + 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(); + var grid = metadataPage.FindControl("Grid")!; + await grid.WaitForComponent(); + + await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType() + .Any(r => r.DataContext is Entry { Member: "Characteristics" } && r.AreDetailsVisible)); + TestCapture.Step("characteristics-row-expanded"); + + var rows = grid.GetVisualDescendants().OfType().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(); + var flagsGrid = shell.Content.Should().BeOfType().Subject; + ((IEnumerable)flagsGrid.ItemsSource!).Cast().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() + .GetChild(); + + 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().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() + .GetChild(); + + vm.AssemblyTreeModel.SelectNode(coffNode); + await vm.DockWorkspace.WaitForMetadataTabAsync(); + + var metadataPage = await window.WaitForComponent(); + var grid = metadataPage.FindControl("Grid")!; + await grid.WaitForComponent(); + await Waiters.WaitForAsync(() => grid.GetVisualDescendants().OfType() + .Any(r => r.DataContext is Entry { Member: "Characteristics" } && r.AreDetailsVisible)); + + var characteristicsRow = grid.GetVisualDescendants().OfType() + .Single(r => r.DataContext is Entry { Member: "Characteristics" }); + var shell = await characteristicsRow.WaitForComponent(); + + MetadataTablePage.FindActivatableRow(shell).Should().BeNull( + "visuals under the details presenter must not trigger row activation"); + + var cell = characteristicsRow.GetVisualDescendants().OfType().First(); + MetadataTablePage.FindActivatableRow(cell).Should().BeSameAs(characteristicsRow, + "regular cells keep resolving to their owning row"); + } +} diff --git a/ILSpy/Metadata/CoffHeaderTreeNode.cs b/ILSpy/Metadata/CoffHeaderTreeNode.cs index e0629ebe5..917ff0ff1 100644 --- a/ILSpy/Metadata/CoffHeaderTreeNode.cs +++ b/ILSpy/Metadata/CoffHeaderTreeNode.cs @@ -52,6 +52,7 @@ namespace ILSpy.Metadata Items = BuildEntries(), }; MetadataColumnBuilder.Populate(page); + MetadataRowDetails.ConfigureEntryFlagsDetails(page); return page; } diff --git a/ILSpy/Metadata/MetadataRowDetails.cs b/ILSpy/Metadata/MetadataRowDetails.cs new file mode 100644 index 000000000..586b09e26 --- /dev/null +++ b/ILSpy/Metadata/MetadataRowDetails.cs @@ -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 +{ + /// + /// Hosts the per-row details content of a metadata DataGrid. The DataGrid builds its + /// RowDetailsTemplate 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). + /// + public sealed class MetadataRowDetailsControl : ContentControl + { + readonly Func contentFactory; + + public MetadataRowDetailsControl(Func 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); + } + } + + /// + /// 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). + /// + public static class MetadataRowDetails + { + /// + /// Wraps in a row-details template. The factory + /// receives the row item (or while the row is recycled) and + /// returns the details content for that row, or for rows + /// without details. + /// + public static IDataTemplate CreateTemplate(Func contentFactory) + { + ArgumentNullException.ThrowIfNull(contentFactory); + return new FuncDataTemplate( + (_, _) => new MetadataRowDetailsControl(contentFactory), + supportsRecycling: false); + } + + /// + /// Configures so every row carrying a + /// flag-bit breakdown () renders it permanently + /// expanded beneath the row, while all other rows stay detail-less. + /// + 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 }; + } + + /// Headerless two-column (set? / meaning) breakdown of a flags word. + public static Control BuildFlagsGrid(IList 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; + } + + /// Read-only, word-wrapped view of a decoded text blob (source, JSON, hex). + 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, + }; + } + + /// + /// Sub-grid over parsed blob rows; one text column per (header, property) pair. + /// + 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, + }; + } +} diff --git a/ILSpy/Metadata/MetadataTableTreeNode.cs b/ILSpy/Metadata/MetadataTableTreeNode.cs index ec883b547..43394ef1f 100644 --- a/ILSpy/Metadata/MetadataTableTreeNode.cs +++ b/ILSpy/Metadata/MetadataTableTreeNode.cs @@ -177,7 +177,17 @@ namespace ILSpy.Metadata Items = cached ??= LoadTable(), }; MetadataColumnBuilder.Populate(page); + ConfigurePage(page); return page; } + + /// + /// 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. + /// + protected virtual void ConfigurePage(MetadataTablePageModel page) + { + } } } diff --git a/ILSpy/Metadata/OptionalHeaderTreeNode.cs b/ILSpy/Metadata/OptionalHeaderTreeNode.cs index 7e5939bbf..ab8215699 100644 --- a/ILSpy/Metadata/OptionalHeaderTreeNode.cs +++ b/ILSpy/Metadata/OptionalHeaderTreeNode.cs @@ -53,6 +53,7 @@ namespace ILSpy.Metadata Items = BuildEntries(), }; MetadataColumnBuilder.Populate(page); + MetadataRowDetails.ConfigureEntryFlagsDetails(page); return page; } diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index d8e4790c4..6db351ba9 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -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 [ObservableProperty] private int? scrollToRow; + /// + /// 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; leaves the table without row details. + /// + public IDataTemplate? RowDetailsTemplate { get; set; } + + /// + /// When the details area shows: Collapsed tables expand rows individually via + /// ; VisibleWhenSelected previews the selected + /// row's details as the user moves through the table. + /// + public DataGridRowDetailsVisibilityMode RowDetailsVisibilityMode { get; set; } + = DataGridRowDetailsVisibilityMode.Collapsed; + + /// + /// 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 . + /// + public Func? IsRowDetailsVisible { get; set; } + /// /// One filter input per column, in the same order as . The view /// renders each as a TextBox baked into that column's diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 1bb5aaf59..6b2442dcc 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -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 // 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); + } + + /// + /// Walks up from to the data row a click gesture may + /// activate. Returns 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. + /// + 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 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 grid.ItemsSource = Array.Empty(); 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)