From 102bf2cc5b8e5e3af1bafd4bd13c8a323eff0d97 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 8 May 2026 08:14:08 +0200 Subject: [PATCH] Per-column filter inputs in metadata grid headers Replace the single text-bar above the grid with one filter input per column, baked into each DataGridColumn's header. The page model carries a ColumnFilters collection alongside Columns; the filter predicate ANDs every non-empty column's case-insensitive substring match. Column instances are built fresh per page so sibling tabs don't share filter state, and the column name moves to Tag for cell-level lookups now that Header is a Panel. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Metadata/HeapTreeTests.cs | 6 +- .../Metadata/MetadataColumnBuilderTests.cs | 29 +++-- ILSpy.Tests/Metadata/MetadataFilterTests.cs | 71 +++++++---- ILSpy.Tests/Metadata/PEHeaderTreeTests.cs | 2 +- .../Metadata/TypedMetadataTableTreeTests.cs | 2 +- ILSpy/Commands/GoToTokenContextMenuEntry.cs | 3 +- ILSpy/Metadata/BlobHeapTreeNode.cs | 14 ++- ILSpy/Metadata/CoffHeaderTreeNode.cs | 14 ++- ILSpy/Metadata/DataDirectoriesTreeNode.cs | 14 ++- ILSpy/Metadata/DebugDirectoryTreeNode.cs | 14 ++- ILSpy/Metadata/DosHeaderTreeNode.cs | 14 ++- ILSpy/Metadata/GuidHeapTreeNode.cs | 14 ++- ILSpy/Metadata/MetadataColumnBuilder.cs | 112 ++++++++++++++---- ILSpy/Metadata/MetadataTableTreeNode.cs | 14 ++- ILSpy/Metadata/OptionalHeaderTreeNode.cs | 14 ++- ILSpy/Metadata/StringHeapTreeNode.cs | 14 ++- ILSpy/Metadata/UserStringHeapTreeNode.cs | 14 ++- ILSpy/ViewModels/MetadataTablePageModel.cs | 68 +++++++---- ILSpy/Views/MetadataTablePage.axaml | 38 +++--- ILSpy/Views/MetadataTablePage.axaml.cs | 43 ++++++- 20 files changed, 356 insertions(+), 158 deletions(-) diff --git a/ILSpy.Tests/Metadata/HeapTreeTests.cs b/ILSpy.Tests/Metadata/HeapTreeTests.cs index a85cea549..707129ef0 100644 --- a/ILSpy.Tests/Metadata/HeapTreeTests.cs +++ b/ILSpy.Tests/Metadata/HeapTreeTests.cs @@ -85,7 +85,7 @@ public class HeapTreeTests var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); tab.Title.Should().Be("String Heap"); - tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Offset", "Length", "Value"); + tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value"); // CoreLib's #Strings is well over a thousand entries; the grid handles them all. tab.Items.Should().HaveCountGreaterThan(1000); } @@ -109,7 +109,7 @@ public class HeapTreeTests var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); tab.Title.Should().Be("Guid Heap"); - tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Index", "Length", "Value"); + tab.Columns.Select(c => c.Tag).Should().Equal("Index", "Length", "Value"); // Each entry's Value is the parsed GUID's lowercase canonical form. var first = (GuidHeapTreeNode.GuidHeapEntry)tab.Items[0]; first.Value.Should().MatchRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); @@ -134,6 +134,6 @@ public class HeapTreeTests var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); tab.Title.Should().Be("Blob Heap"); - tab.Columns.Select(c => c.Header.ToString()).Should().Equal("Offset", "Length", "Value"); + tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value"); } } diff --git a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs index a8fcd4c2a..fb7c9cf63 100644 --- a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs +++ b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs @@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit; using AwesomeAssertions; using ILSpy.Metadata; +using ILSpy.ViewModels; using NUnit.Framework; @@ -51,7 +52,7 @@ public class MetadataColumnBuilderTests var columns = MetadataColumnBuilder.For(); columns.Should().HaveCount(3); - columns.Select(c => c.Header.ToString()).Should().Equal("RID", "Token", "Name"); + columns.Select(c => c.Tag).Should().Equal("RID", "Token", "Name"); var token = (DataGridTextColumn)columns[1]; token.Binding.Should().NotBeNull(); @@ -62,16 +63,30 @@ public class MetadataColumnBuilderTests } [AvaloniaTest] - public void For_Returns_Fresh_DataGridColumn_Instances_So_They_Can_Attach_To_Distinct_Grids() + public void For_Returns_Fresh_Column_Instances_So_Per_Page_Headers_Stay_Independent() { - // Avalonia's DataGrid tracks OwningGrid on each column; reusing column instances - // across grids throws InvalidOperationException. Each call must therefore yield - // fresh DataGridColumn instances, even though the underlying reflection metadata - // is the same. + // Per-column filter inputs live inside each column's Header, so two pages built + // from the same row type must hold *separate* DataGridColumn instances — otherwise + // a filter typed on one tab would echo into the other. var first = MetadataColumnBuilder.For(); var second = MetadataColumnBuilder.For(); first.Should().NotBeSameAs(second); - first[0].Should().NotBeSameAs(second[0]); + first.Zip(second, (a, b) => (a, b)).Should() + .AllSatisfy(p => p.a.Should().NotBeSameAs(p.b)); + } + + [AvaloniaTest] + public void Populate_Sets_Columns_And_ColumnFilters_In_Matched_Order() + { + // The page model uses the ColumnFilters collection to evaluate the row predicate; + // the view binds each column header's TextBox to the filter at the same index. The + // order must match Columns exactly or filters would route to the wrong column. + var page = new MetadataTablePageModel(); + MetadataColumnBuilder.Populate(page); + + page.Columns.Should().HaveCount(3); + page.ColumnFilters.Should().HaveCount(3); + page.ColumnFilters.Select(f => f.ColumnName).Should().Equal("RID", "Token", "Name"); } sealed class SampleEntryWithToken diff --git a/ILSpy.Tests/Metadata/MetadataFilterTests.cs b/ILSpy.Tests/Metadata/MetadataFilterTests.cs index 0ec80d6b7..ecabc3b3b 100644 --- a/ILSpy.Tests/Metadata/MetadataFilterTests.cs +++ b/ILSpy.Tests/Metadata/MetadataFilterTests.cs @@ -45,46 +45,67 @@ public class MetadataFilterTests } [Test] - public void MatchesFilter_Returns_True_For_Empty_Or_Null_Filter() + public void MatchesFilters_Returns_True_When_All_Column_Filters_Are_Empty() { - // An empty filter must show every row — the no-filter state is the identity case. + // An empty filter set is the identity case — every row passes. var entry = new SampleEntry { RID = 1, Name = "System.Runtime" }; - MetadataTablePageModel.MatchesFilter(entry, null).Should().BeTrue(); - MetadataTablePageModel.MatchesFilter(entry, "").Should().BeTrue(); + MetadataTablePageModel.MatchesFilters(entry, new[] { + new ColumnFilter("Name"), + new ColumnFilter("RID"), + }).Should().BeTrue(); } [Test] - public void MatchesFilter_Returns_True_When_Any_Property_Contains_The_Substring_Case_Insensitively() + public void MatchesFilters_Returns_True_When_Each_Filter_Matches_Its_Column_Case_Insensitively() { - // Filter is a case-insensitive Contains over every property's stringified value, so - // "system" matches "System.Runtime" and "neutral" matches the Culture column. + // Per-column filters AND together: every non-empty filter must hit its own column, + // not just any column. Case-insensitive substring match. var entry = new SampleEntry { RID = 1, Name = "System.Runtime", Culture = "neutral" }; - MetadataTablePageModel.MatchesFilter(entry, "system").Should().BeTrue(); - MetadataTablePageModel.MatchesFilter(entry, "RUNTIME").Should().BeTrue(); - MetadataTablePageModel.MatchesFilter(entry, "neutral").Should().BeTrue(); + MetadataTablePageModel.MatchesFilters(entry, new[] { + new ColumnFilter("Name") { Value = "system" }, + new ColumnFilter("Culture") { Value = "NEUTRAL" }, + }).Should().BeTrue(); } [Test] - public void MatchesFilter_Returns_False_When_No_Property_Contains_The_Substring() + public void MatchesFilters_Returns_False_When_A_Filter_Does_Not_Match_Its_Own_Column() { - var entry = new SampleEntry { RID = 1, Name = "System.Runtime" }; - MetadataTablePageModel.MatchesFilter(entry, "Foo").Should().BeFalse(); + // Anchored to a single column: "system" in Culture must not match a row whose + // Culture is "neutral", even though Name contains "System". + var entry = new SampleEntry { Name = "System.Runtime", Culture = "neutral" }; + MetadataTablePageModel.MatchesFilters(entry, new[] { + new ColumnFilter("Culture") { Value = "system" }, + }).Should().BeFalse(); + } + + [Test] + public void MatchesFilters_Returns_False_When_Any_AndEd_Filter_Misses() + { + // All filters must match — one missing column is enough to drop the row. + var entry = new SampleEntry { Name = "System.Runtime", Culture = "neutral" }; + MetadataTablePageModel.MatchesFilters(entry, new[] { + new ColumnFilter("Name") { Value = "system" }, + new ColumnFilter("Culture") { Value = "invariant" }, + }).Should().BeFalse(); } [Test] - public void MatchesFilter_Stringifies_Numeric_Values_So_Filter_Hits_Numeric_Columns() + public void MatchesFilters_Returns_False_When_The_Filtered_Column_Does_Not_Exist_On_The_Row() { - // Without stringifying, filtering "42" against a numeric RID would silently miss. - var entry = new SampleEntry { RID = 42, Name = "X" }; - MetadataTablePageModel.MatchesFilter(entry, "42").Should().BeTrue(); + // A filter targeting a non-existent property never matches; surfacing this as "no + // match" prevents stale filter rows from silently passing rows of a different shape. + var entry = new SampleEntry { Name = "X" }; + MetadataTablePageModel.MatchesFilters(entry, new[] { + new ColumnFilter("DoesNotExist") { Value = "anything" }, + }).Should().BeFalse(); } [AvaloniaTest] - public async Task FilterText_Reduces_Visible_Rows_To_Those_Whose_Property_Contains_The_Substring() + public async Task ColumnFilter_Reduces_Visible_Rows_To_Those_Whose_Column_Contains_The_Substring() { // Integration check: navigate to the TypeDef table (guaranteed populated for any - // loaded module) and confirm setting FilterText='System' shrinks the visible-row - // set, and clearing it restores the full set. + // loaded module) and confirm that setting a Name-column filter shrinks the visible + // row set to entries whose Name contains the filter, and clearing it restores all. var window = AppComposition.Current.GetExport(); window.Show(); @@ -106,13 +127,15 @@ public class MetadataFilterTests var totalCount = tab.Items.Count; totalCount.Should().BeGreaterThan(0); - tab.FilterText = "System"; - var visible = tab.Items.Where(e => MetadataTablePageModel.MatchesFilter(e, tab.FilterText)).ToList(); + var nameFilter = tab.ColumnFilters.Single(f => f.ColumnName == "Name"); + nameFilter.Value = "System"; + + var visible = tab.Items.Where(e => MetadataTablePageModel.MatchesFilters(e, tab.ColumnFilters)).ToList(); visible.Should().NotBeEmpty("at least one TypeDef row should mention 'System'"); visible.Count.Should().BeLessThan(totalCount, "the filter must hide at least one row"); - tab.FilterText = ""; - var afterClear = tab.Items.Where(e => MetadataTablePageModel.MatchesFilter(e, tab.FilterText)).Count(); + nameFilter.Value = ""; + var afterClear = tab.Items.Where(e => MetadataTablePageModel.MatchesFilters(e, tab.ColumnFilters)).Count(); afterClear.Should().Be(totalCount); } } diff --git a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs index dae99f914..0cfa68f4e 100644 --- a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs +++ b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs @@ -95,7 +95,7 @@ public class PEHeaderTreeTests tab.Title.Should().Be("DOS Header"); tab.Items.Should().HaveCount(31); - tab.Columns.Select(c => c.Header.ToString()).Should().Contain(["Member", "Offset", "Size", "Value", "Meaning"]); + tab.Columns.Select(c => c.Tag).Should().Contain(["Member", "Offset", "Size", "Value", "Meaning"]); var firstRow = (Entry)tab.Items[0]; firstRow.Member.Should().Be("e_magic"); diff --git a/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs b/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs index e571a3792..60ebf05d6 100644 --- a/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs +++ b/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs @@ -131,7 +131,7 @@ public class TypedMetadataTableTreeTests vm.AssemblyTreeModel.SelectNode(methodNode); var tab = await vm.DockWorkspace.WaitForMetadataTabAsync(); tab.Title.Should().StartWith("MethodDef"); - tab.Columns.Select(c => c.Header.ToString()).Should().Contain("RID"); + tab.Columns.Select(c => c.Tag).Should().Contain("RID"); } [AvaloniaTest] diff --git a/ILSpy/Commands/GoToTokenContextMenuEntry.cs b/ILSpy/Commands/GoToTokenContextMenuEntry.cs index 281704cc9..db1b4b1e1 100644 --- a/ILSpy/Commands/GoToTokenContextMenuEntry.cs +++ b/ILSpy/Commands/GoToTokenContextMenuEntry.cs @@ -60,7 +60,8 @@ namespace ILSpy.Commands return null; if (cell.OwningColumn is null) return null; - var columnName = cell.OwningColumn.Header?.ToString(); + var columnName = cell.OwningColumn.Tag as string + ?? cell.OwningColumn.Header?.ToString(); if (string.IsNullOrEmpty(columnName)) return null; var row = cell.DataContext; diff --git a/ILSpy/Metadata/BlobHeapTreeNode.cs b/ILSpy/Metadata/BlobHeapTreeNode.cs index 250909286..aa73c45de 100644 --- a/ILSpy/Metadata/BlobHeapTreeNode.cs +++ b/ILSpy/Metadata/BlobHeapTreeNode.cs @@ -45,11 +45,15 @@ namespace ILSpy.Metadata public override object Text => $"Blob Heap ({EnsureEntries().Count})"; public override string ToString() => "Blob Heap"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "Blob Heap", - Items = EnsureEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "Blob Heap", + Items = EnsureEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } List EnsureEntries() { diff --git a/ILSpy/Metadata/CoffHeaderTreeNode.cs b/ILSpy/Metadata/CoffHeaderTreeNode.cs index 524079dbe..f0ab79c18 100644 --- a/ILSpy/Metadata/CoffHeaderTreeNode.cs +++ b/ILSpy/Metadata/CoffHeaderTreeNode.cs @@ -45,11 +45,15 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.MetadataTable; public override string ToString() => "COFF Header"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "COFF Header", - Items = BuildEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "COFF Header", + Items = BuildEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } IReadOnlyList BuildEntries() { diff --git a/ILSpy/Metadata/DataDirectoriesTreeNode.cs b/ILSpy/Metadata/DataDirectoriesTreeNode.cs index 0c34dbd53..b70a3377c 100644 --- a/ILSpy/Metadata/DataDirectoriesTreeNode.cs +++ b/ILSpy/Metadata/DataDirectoriesTreeNode.cs @@ -46,11 +46,15 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.MetadataTable; public override string ToString() => "Data Directories"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "Data Directories", - Items = BuildEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "Data Directories", + Items = BuildEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } IReadOnlyList BuildEntries() { diff --git a/ILSpy/Metadata/DebugDirectoryTreeNode.cs b/ILSpy/Metadata/DebugDirectoryTreeNode.cs index 16263a026..42cd835e4 100644 --- a/ILSpy/Metadata/DebugDirectoryTreeNode.cs +++ b/ILSpy/Metadata/DebugDirectoryTreeNode.cs @@ -47,11 +47,15 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.MetadataTable; public override string ToString() => "Debug Directory"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "Debug Directory", - Items = BuildEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "Debug Directory", + Items = BuildEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } IReadOnlyList BuildEntries() { diff --git a/ILSpy/Metadata/DosHeaderTreeNode.cs b/ILSpy/Metadata/DosHeaderTreeNode.cs index 77ec06e20..8a5ef5a0b 100644 --- a/ILSpy/Metadata/DosHeaderTreeNode.cs +++ b/ILSpy/Metadata/DosHeaderTreeNode.cs @@ -44,11 +44,15 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.MetadataTable; public override string ToString() => "DOS Header"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "DOS Header", - Items = BuildEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "DOS Header", + Items = BuildEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } IReadOnlyList BuildEntries() { diff --git a/ILSpy/Metadata/GuidHeapTreeNode.cs b/ILSpy/Metadata/GuidHeapTreeNode.cs index 1fda03acf..82fb34fd1 100644 --- a/ILSpy/Metadata/GuidHeapTreeNode.cs +++ b/ILSpy/Metadata/GuidHeapTreeNode.cs @@ -44,11 +44,15 @@ namespace ILSpy.Metadata public override object Text => $"Guid Heap ({EnsureEntries().Count})"; public override string ToString() => "Guid Heap"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "Guid Heap", - Items = EnsureEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "Guid Heap", + Items = EnsureEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } List EnsureEntries() { diff --git a/ILSpy/Metadata/MetadataColumnBuilder.cs b/ILSpy/Metadata/MetadataColumnBuilder.cs index 26db79ac5..841b718b6 100644 --- a/ILSpy/Metadata/MetadataColumnBuilder.cs +++ b/ILSpy/Metadata/MetadataColumnBuilder.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -36,28 +37,59 @@ using ILSpy.ViewModels; namespace ILSpy.Metadata { /// - /// Builds DataGrid column descriptors for a metadata-row type by reflecting over its public - /// properties. Returns fresh instances per call — Avalonia's - /// DataGrid tracks an OwningGrid reference on each column, so reusing the same instances - /// across tabs (or even across rebinds of one tab) throws InvalidOperationException - /// "Column already belongs to a DataGrid instance and cannot be reassigned." Reflection - /// cost per tab-open is negligible against the layout pass that follows. - /// Token / heap-offset columns render as plain hex text in Phase 2; Phase 3 swaps them for - /// hyperlink-styled buttons that emit a token-navigation request. + /// Builds DataGrid columns for a metadata-row type by reflecting over its public + /// properties. Reflection metadata is cached as ColumnDescriptors; live + /// instances are constructed fresh on every call so each + /// page can carry its own per-column filter inputs in the headers without sharing state + /// with sibling pages. /// public static class MetadataColumnBuilder { + static readonly ConcurrentDictionary descriptorCache = new(); + + /// + /// Convenience overload for tests and simple consumers — returns columns with plain + /// string headers and no per-column filter wiring. Production code should call + /// , which also seeds the page's + /// ColumnFilters collection and bakes filter inputs into each header. + /// public static IReadOnlyList For() => For(typeof(TEntry)); public static IReadOnlyList For(Type entryType) { ArgumentNullException.ThrowIfNull(entryType); - return BuildColumns(entryType); + return descriptorCache.GetOrAdd(entryType, BuildDescriptors) + .Select(d => BuildColumn(d, filter: null)) + .ToArray(); } - static IReadOnlyList BuildColumns(Type entryType) + /// + /// Populates with both Columns and + /// ColumnFilters in matched order. Each column's header is a vertical pair — + /// the column name above a small TextBox bound two-way to the column's filter — so + /// the user can filter every column independently and the predicate ANDs the lot. + /// + public static void Populate(MetadataTablePageModel page) => Populate(page, typeof(TEntry)); + + public static void Populate(MetadataTablePageModel page, Type entryType) { - var columns = new List(); + ArgumentNullException.ThrowIfNull(page); + ArgumentNullException.ThrowIfNull(entryType); + var descriptors = descriptorCache.GetOrAdd(entryType, BuildDescriptors); + page.ColumnFilters.Clear(); + var columns = new List(descriptors.Length); + foreach (var d in descriptors) + { + var filter = new ColumnFilter(d.Name); + page.ColumnFilters.Add(filter); + columns.Add(BuildColumn(d, filter)); + } + page.Columns = columns; + } + + static ColumnDescriptor[] BuildDescriptors(Type entryType) + { + var list = new List(); foreach (var prop in entryType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (prop.GetIndexParameters().Length > 0) @@ -66,18 +98,47 @@ namespace ILSpy.Metadata continue; if (prop.PropertyType == typeof(IList)) continue; - - var info = prop.GetCustomAttribute(); - if (info?.Kind == ColumnKind.Token) - { - columns.Add(BuildTokenColumn(prop, info)); - } - else - { - columns.Add(BuildTextColumn(prop, info)); - } + list.Add(new ColumnDescriptor(prop, prop.GetCustomAttribute())); } - return columns; + return list.ToArray(); + } + + static DataGridColumn BuildColumn(ColumnDescriptor d, ColumnFilter? filter) + { + var column = d.Info?.Kind == ColumnKind.Token + ? (DataGridColumn)BuildTokenColumn(d.Property, d.Info) + : BuildTextColumn(d.Property, d.Info); + column.Header = filter is null ? d.Name : (object)BuildHeader(d.Name, filter); + // Header is a Panel when filters are wired, so callers can't recover the column + // name via Header.ToString(). Stash the name on Tag for hover-tooltip lookup, + // "Go to token" context-menu resolution, and any future cell-level navigation. + column.Tag = d.Name; + return column; + } + + static Control BuildHeader(string columnName, ColumnFilter filter) + { + var label = new TextBlock { + Text = columnName, + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + var box = new TextBox { + MinHeight = 0, + Padding = new Thickness(2, 1), + Margin = new Thickness(0, 2, 0, 0), + FontWeight = FontWeight.Normal, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + box.Bind(TextBox.TextProperty, new Binding(nameof(ColumnFilter.Value)) { + Source = filter, + Mode = BindingMode.TwoWay, + UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged, + }); + return new StackPanel { + Orientation = Orientation.Vertical, + Children = { label, box }, + }; } static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info) @@ -86,7 +147,6 @@ namespace ILSpy.Metadata if (!string.IsNullOrEmpty(info?.Format)) binding.Converter = new HexFormatConverter(info.Format); return new DataGridTextColumn { - Header = prop.Name, IsReadOnly = true, Binding = binding, SortMemberPath = prop.Name, @@ -114,7 +174,6 @@ namespace ILSpy.Metadata return btn; }, supportsRecycling: true); return new DataGridTemplateColumn { - Header = prop.Name, IsReadOnly = true, CellTemplate = template, SortMemberPath = prop.Name, @@ -132,6 +191,11 @@ namespace ILSpy.Metadata return formattable.ToString(format, CultureInfo.InvariantCulture); return value.ToString() ?? string.Empty; } + + sealed record ColumnDescriptor(PropertyInfo Property, ColumnInfoAttribute? Info) + { + public string Name => Property.Name; + } } /// diff --git a/ILSpy/Metadata/MetadataTableTreeNode.cs b/ILSpy/Metadata/MetadataTableTreeNode.cs index 0af75aa54..522026153 100644 --- a/ILSpy/Metadata/MetadataTableTreeNode.cs +++ b/ILSpy/Metadata/MetadataTableTreeNode.cs @@ -71,10 +71,14 @@ namespace ILSpy.Metadata protected abstract IReadOnlyList LoadTable(); - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = $"{Kind} ({RowCount})", - Items = (cached ??= LoadTable()).Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = $"{Kind} ({RowCount})", + Items = (cached ??= LoadTable()).Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } } } diff --git a/ILSpy/Metadata/OptionalHeaderTreeNode.cs b/ILSpy/Metadata/OptionalHeaderTreeNode.cs index 6192919d2..0a7c54668 100644 --- a/ILSpy/Metadata/OptionalHeaderTreeNode.cs +++ b/ILSpy/Metadata/OptionalHeaderTreeNode.cs @@ -46,11 +46,15 @@ namespace ILSpy.Metadata public override object Icon => Images.Images.MetadataTable; public override string ToString() => "Optional Header"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "Optional Header", - Items = BuildEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "Optional Header", + Items = BuildEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } IReadOnlyList BuildEntries() { diff --git a/ILSpy/Metadata/StringHeapTreeNode.cs b/ILSpy/Metadata/StringHeapTreeNode.cs index 97ffa17bc..4eb0d0ae4 100644 --- a/ILSpy/Metadata/StringHeapTreeNode.cs +++ b/ILSpy/Metadata/StringHeapTreeNode.cs @@ -45,11 +45,15 @@ namespace ILSpy.Metadata public override object Text => $"String Heap ({EnsureEntries().Count})"; public override string ToString() => "String Heap"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "String Heap", - Items = EnsureEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "String Heap", + Items = EnsureEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } List EnsureEntries() { diff --git a/ILSpy/Metadata/UserStringHeapTreeNode.cs b/ILSpy/Metadata/UserStringHeapTreeNode.cs index 9c41f3a49..bffd3f8df 100644 --- a/ILSpy/Metadata/UserStringHeapTreeNode.cs +++ b/ILSpy/Metadata/UserStringHeapTreeNode.cs @@ -44,11 +44,15 @@ namespace ILSpy.Metadata public override object Text => $"UserString Heap ({EnsureEntries().Count})"; public override string ToString() => "UserString Heap"; - public override TabPageModel CreateTab() => new MetadataTablePageModel { - Title = "UserString Heap", - Items = EnsureEntries().Cast().ToList(), - Columns = MetadataColumnBuilder.For(), - }; + public override TabPageModel CreateTab() + { + var page = new MetadataTablePageModel { + Title = "UserString Heap", + Items = EnsureEntries().Cast().ToList(), + }; + MetadataColumnBuilder.Populate(page); + return page; + } List EnsureEntries() { diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index 42579ba1b..5e00813c2 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Reflection; using Avalonia.Controls; @@ -50,12 +51,21 @@ namespace ILSpy.ViewModels private int? scrollToRow; /// - /// Free-text filter applied to the visible rows. Empty / null shows every row; - /// otherwise the view shows the rows where any property's stringified value contains - /// the filter (case-insensitive). Bound two-way to a TextBox above the grid. + /// One filter input per column, in the same order as . The view + /// renders each as a TextBox baked into that column's + /// header; the filter predicate ANDs every non-empty entry, requiring each row's + /// matching property's stringified value to contain its filter (case-insensitive). /// - [ObservableProperty] - private string? filterText; + public ObservableCollection ColumnFilters { get; } = new(); + + /// + /// Raised whenever any column's filter text changes. The view subscribes to this so + /// it can refresh the underlying DataGridCollectionView without each column + /// having to know how to find the live view instance. + /// + public event Action? ColumnFilterChanged; + + internal void RaiseColumnFilterChanged() => ColumnFilterChanged?.Invoke(); /// /// Raised when the user clicks a hyperlink-styled token cell. The host (the dock @@ -67,35 +77,53 @@ namespace ILSpy.ViewModels internal void RaiseNavigateToCell(object row, string columnName) => NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName)); - static readonly ConcurrentDictionary filterPropertyCache = new(); + static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = new(); /// - /// Predicate used by both the view's DataGridCollectionView.Filter and tests: - /// returns when is empty or any - /// public instance property's stringified value on contains - /// the filter case-insensitively. + /// Returns true when every in + /// either has an empty value or the matching property on + /// stringifies to a value that contains the filter case-insensitively. Used by the + /// view's DataGridCollectionView.Filter and by tests. /// - public static bool MatchesFilter(object item, string? filter) + public static bool MatchesFilters(object item, IEnumerable filters) { ArgumentNullException.ThrowIfNull(item); - if (string.IsNullOrEmpty(filter)) - return true; - var props = filterPropertyCache.GetOrAdd(item.GetType(), - static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); - foreach (var prop in props) + ArgumentNullException.ThrowIfNull(filters); + foreach (var f in filters) { + if (string.IsNullOrEmpty(f.Value)) + continue; + var prop = propertyLookupCache.GetOrAdd((item.GetType(), f.ColumnName), + static key => key.Type.GetProperty(key.Column, + BindingFlags.Public | BindingFlags.Instance)); + if (prop is null) + return false; object? value; try { value = prop.GetValue(item); } - catch { continue; } + catch { return false; } var s = value?.ToString(); - if (s is not null && s.Contains(filter, StringComparison.OrdinalIgnoreCase)) - return true; + if (s is null || !s.Contains(f.Value, StringComparison.OrdinalIgnoreCase)) + return false; } - return false; + return true; } } + /// One column's filter entry — the column name plus its current filter text. + public sealed partial class ColumnFilter : ObservableObject + { + public ColumnFilter(string columnName) + { + ColumnName = columnName; + } + + public string ColumnName { get; } + + [ObservableProperty] + private string? value; + } + /// The (row, column) pair clicked in a token cell. public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName); } diff --git a/ILSpy/Views/MetadataTablePage.axaml b/ILSpy/Views/MetadataTablePage.axaml index deaff0af0..cc190d1b0 100644 --- a/ILSpy/Views/MetadataTablePage.axaml +++ b/ILSpy/Views/MetadataTablePage.axaml @@ -7,27 +7,21 @@ x:Class="ILSpy.Views.MetadataTablePage" x:DataType="vm:MetadataTablePageModel"> - - - - - - + + diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 28abe4539..b78f9da44 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -117,7 +117,8 @@ namespace ILSpy.Views hoveredCell = cell; if (cell is null) return; - var columnName = cell.OwningColumn?.Header?.ToString(); + var columnName = cell.OwningColumn?.Tag as string + ?? cell.OwningColumn?.Header?.ToString(); if (columnName is null) return; var tip = MetadataCellTooltip.Resolve(cell.DataContext!, columnName); @@ -127,10 +128,16 @@ namespace ILSpy.Views void RebindModel() { if (boundModel != null) + { boundModel.PropertyChanged -= OnModelPropertyChanged; + DetachColumnFilterListeners(boundModel); + } boundModel = DataContext as MetadataTablePageModel; if (boundModel != null) + { boundModel.PropertyChanged += OnModelPropertyChanged; + AttachColumnFilterListeners(boundModel); + } ApplySchema(); ApplyScrollTarget(); } @@ -139,11 +146,36 @@ namespace ILSpy.Views { if (e.PropertyName is nameof(MetadataTablePageModel.Columns) or nameof(MetadataTablePageModel.Items)) + { + if (sender is MetadataTablePageModel m) + { + DetachColumnFilterListeners(m); + AttachColumnFilterListeners(m); + } ApplySchema(); - else if (e.PropertyName == nameof(MetadataTablePageModel.FilterText)) - itemsView?.Refresh(); + } else if (e.PropertyName == nameof(MetadataTablePageModel.ScrollToRow)) + { ApplyScrollTarget(); + } + } + + void OnColumnFilterChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ColumnFilter.Value)) + itemsView?.Refresh(); + } + + void AttachColumnFilterListeners(MetadataTablePageModel model) + { + foreach (var f in model.ColumnFilters) + f.PropertyChanged += OnColumnFilterChanged; + } + + void DetachColumnFilterListeners(MetadataTablePageModel model) + { + foreach (var f in model.ColumnFilters) + f.PropertyChanged -= OnColumnFilterChanged; } void ApplySchema() @@ -163,8 +195,9 @@ namespace ILSpy.Views return; foreach (var c in boundModel.Columns) grid.Columns.Add(c); - itemsView = new DataGridCollectionView(boundModel.Items) { - Filter = item => MetadataTablePageModel.MatchesFilter(item, boundModel?.FilterText), + var model = boundModel; + itemsView = new DataGridCollectionView(model.Items) { + Filter = item => MetadataTablePageModel.MatchesFilters(item, model.ColumnFilters), }; grid.ItemsSource = itemsView; }