From 7ea8d3b9111ee1bfd39013f992232f628b801d37 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 8 May 2026 20:52:19 +0200 Subject: [PATCH] Bitwise [Flags] filter, mirroring WPF FlagsContentFilter Replace the string-token flag predicate with a numeric bitmask on ColumnFilter (FlagMask, default -1 meaning "all rows"). The dropdown on a [Flags] column now drives the mask directly: Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Metadata/MetadataFilterTests.cs | 88 ++++++----- ILSpy/Metadata/MetadataColumnBuilder.cs | 153 +++++++++++++++----- ILSpy/ViewModels/MetadataTablePageModel.cs | 79 +++++----- 3 files changed, 202 insertions(+), 118 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataFilterTests.cs b/ILSpy.Tests/Metadata/MetadataFilterTests.cs index 94445b887..b41fb8d46 100644 --- a/ILSpy.Tests/Metadata/MetadataFilterTests.cs +++ b/ILSpy.Tests/Metadata/MetadataFilterTests.cs @@ -170,60 +170,74 @@ public class MetadataFilterTests } [Test] - public void Filter_Matches_Single_Flag_Name_When_Set_On_Flags_Enum_Column() + public void Default_FlagMask_Of_Minus_One_Matches_Every_Row() { - var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }; - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "Public" }, - }).Should().BeTrue(); + // Mask = -1 ("All") is the unfiltered default — every row passes regardless of + // its flag value. Mirrors FlagsContentFilter's `Mask == -1 || …` short-circuit. + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }, + new[] { new ColumnFilter("Attributes") }).Should().BeTrue(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.None }, + new[] { new ColumnFilter("Attributes") }).Should().BeTrue(); } [Test] - public void Filter_Matches_When_All_Comma_Separated_Flag_Names_Are_Set_Regardless_Of_Order() + public void FlagMask_Matches_Row_When_Any_Selected_Bit_Is_Set() { - // Substring fallback would fail "Static, Public" against "Public, Static" since the - // names are out of order — the [Flags]-aware predicate matches anyway. - var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }; - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "Static, Public" }, - }).Should().BeTrue(); + // WPF's FlagsContentFilter passes a row when (mask & value) != 0. With Public + // alone selected, only rows with the Public bit set match. + var publicMask = (int)SampleFlags.Public; + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }, + new[] { new ColumnFilter("Attributes") { FlagMask = publicMask } }).Should().BeTrue(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Static }, + new[] { new ColumnFilter("Attributes") { FlagMask = publicMask } }).Should().BeFalse(); } [Test] - public void Filter_Misses_When_Any_Named_Flag_Is_Not_Set() + public void Multiple_Selected_Flags_OR_Together_In_The_Mask() { - var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }; - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "Public, Sealed" }, - }).Should().BeFalse(); + // Picking Public + Static yields mask = Public | Static. A row matches when + // it has *either* flag set — that's bitwise OR semantics, not AND. + var orMask = (int)(SampleFlags.Public | SampleFlags.Static); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public }, + new[] { new ColumnFilter("Attributes") { FlagMask = orMask } }).Should().BeTrue(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Static }, + new[] { new ColumnFilter("Attributes") { FlagMask = orMask } }).Should().BeTrue(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Sealed }, + new[] { new ColumnFilter("Attributes") { FlagMask = orMask } }).Should().BeFalse(); } [Test] - public void Filter_With_Bang_Prefix_Requires_Flag_To_Be_Cleared() + public void FlagMask_Of_Zero_Hides_Every_Row() { - // `!Name` reads as "this flag must NOT be set" — useful for narrowing to - // non-static methods, non-public members, etc. - var entry = new FlagsEntry { Attributes = SampleFlags.Public }; - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "!Static" }, - }).Should().BeTrue(); - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "Public, !Static" }, - }).Should().BeTrue(); - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "!Public" }, - }).Should().BeFalse(); + // "" unchecked drives the mask to 0. WPF's filter then rejects everything + // because (0 & value) is never non-zero. + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public }, + new[] { new ColumnFilter("Attributes") { FlagMask = 0 } }).Should().BeFalse(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.None }, + new[] { new ColumnFilter("Attributes") { FlagMask = 0 } }).Should().BeFalse(); } [Test] - public void Filter_Falls_Back_To_Substring_When_Token_Is_Not_A_Valid_Flag_Name() + public void FlagMask_And_Text_Filter_AND_Together_On_The_Same_Column() { - // "Pub" alone isn't a flag name, so the predicate drops to substring on the - // formatted enum value ("Public, Static") — which contains "Pub". - var entry = new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }; - MetadataTablePageModel.MatchesFilters(entry, new[] { - new ColumnFilter("Attributes") { Text = "Pub" }, - }).Should().BeTrue(); + // Both inputs apply: mask narrows the rows by flag, then Text further narrows + // by substring on the formatted display. + var publicMask = (int)SampleFlags.Public; + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public | SampleFlags.Static }, + new[] { new ColumnFilter("Attributes") { FlagMask = publicMask, Text = "Static" } }).Should().BeTrue(); + MetadataTablePageModel.MatchesFilters( + new FlagsEntry { Attributes = SampleFlags.Public }, + new[] { new ColumnFilter("Attributes") { FlagMask = publicMask, Text = "Static" } }).Should().BeFalse(); } sealed class NumericEntry diff --git a/ILSpy/Metadata/MetadataColumnBuilder.cs b/ILSpy/Metadata/MetadataColumnBuilder.cs index 4401cfa9e..9dd295130 100644 --- a/ILSpy/Metadata/MetadataColumnBuilder.cs +++ b/ILSpy/Metadata/MetadataColumnBuilder.cs @@ -158,10 +158,11 @@ namespace ILSpy.Metadata static Control WrapWithFlagsDropdown(TextBox box, ColumnFilter filter, Type enumType) { - // Discoverable flag-checklist popup. The TextBox stays for free-form input (regex, - // numeric ops, mixed flag names with `!` negation); the dropdown rebuilds the - // filter from the boxes the user ticks, so the predicate's flags-aware path is - // reachable without typing the names from memory. + // Mirrors WPF's FlagsFilterControl: a dropdown with a CheckBox per defined flag + // value (plus an "" neutral item) that drives ColumnFilter.FlagMask. The + // predicate matches a row when (mask & value) != 0, the same bitwise rule + // FlagsContentFilter uses. The TextBox stays alongside for free-form filtering + // (regex / numeric ops) — both paths AND together. var openButton = new Button { Content = "▾", Padding = new Thickness(4, 0), @@ -170,16 +171,7 @@ namespace ILSpy.Metadata VerticalAlignment = VerticalAlignment.Stretch, }; var checkPanel = new StackPanel { Orientation = Orientation.Vertical }; - var checkBoxes = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var name in Enum.GetNames(enumType)) - { - if (name == "None") - continue; - var cb = new CheckBox { Content = name, Padding = new Thickness(4, 1) }; - cb.IsCheckedChanged += (_, _) => RebuildFilterFromCheckboxes(filter, checkBoxes); - checkBoxes[name] = cb; - checkPanel.Children.Add(cb); - } + var controller = new FlagsFilterController(filter, enumType, checkPanel); var popup = new Popup { PlacementTarget = openButton, Placement = PlacementMode.BottomEdgeAlignedLeft, @@ -196,7 +188,7 @@ namespace ILSpy.Metadata }, }; openButton.Click += (_, _) => { - SyncCheckboxesFromFilter(filter, checkBoxes); + controller.SyncFromMask(); popup.IsOpen = true; }; var row = new DockPanel { LastChildFill = true, Margin = new Thickness(0, 2, 0, 0) }; @@ -204,41 +196,124 @@ namespace ILSpy.Metadata row.Children.Add(openButton); row.Children.Add(box); box.Margin = new Thickness(0); - // Keep the popup in the logical tree so light-dismiss works; parking it in a - // zero-size container alongside the row avoids it taking layout space. return new StackPanel { Orientation = Orientation.Vertical, Children = { row, popup }, }; } - static void SyncCheckboxesFromFilter(ColumnFilter filter, System.Collections.Generic.IDictionary checkBoxes) - { - var positives = ParsePositiveTokens(filter.Text); - foreach (var (name, cb) in checkBoxes) - cb.IsChecked = positives.Contains(name); - } + readonly record struct FlagDescriptor(string Name, int Value, bool IsAll); - static void RebuildFilterFromCheckboxes(ColumnFilter filter, System.Collections.Generic.IDictionary checkBoxes) + /// + /// Owns the dropdown's CheckBoxes and reconciles them with + /// . A re-entry guard suppresses the + /// IsCheckedChanged handler while we cross-toggle other boxes — without it, + /// flipping <All> would cascade through every regular flag's handler and + /// rebuild the mask from intermediate states. + /// + sealed class FlagsFilterController { - var checkedNames = checkBoxes - .Where(p => p.Value.IsChecked == true) - .Select(p => p.Key); - filter.Text = string.Join(", ", checkedNames); - } + readonly ColumnFilter filter; + readonly (FlagDescriptor Flag, CheckBox CheckBox)[] checkBoxes; + bool suppress; - static System.Collections.Generic.HashSet ParsePositiveTokens(string? text) - { - var set = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(text)) - return set; - foreach (var raw in text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + public FlagsFilterController(ColumnFilter filter, Type enumType, StackPanel host) { - if (raw.StartsWith('!')) - continue; - set.Add(raw); + this.filter = filter; + var flags = BuildFlagDescriptors(enumType); + checkBoxes = new (FlagDescriptor, CheckBox)[flags.Count]; + for (int i = 0; i < flags.Count; i++) + { + var flag = flags[i]; + var cb = new CheckBox { + Content = flag.IsAll ? "" : $"{flag.Name} ({flag.Value:X4})", + Padding = new Thickness(4, 1), + }; + cb.IsCheckedChanged += OnCheckBoxToggled; + checkBoxes[i] = (flag, cb); + host.Children.Add(cb); + } + } + + public void SyncFromMask() + { + suppress = true; + try + { + int mask = filter.FlagMask; + foreach (var (flag, cb) in checkBoxes) + { + cb.IsChecked = mask == -1 + ? true + : !flag.IsAll && (mask & flag.Value) != 0; + } + } + finally + { suppress = false; } + } + + void OnCheckBoxToggled(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e) + { + if (suppress) + return; + if (sender is not CheckBox toggled) + return; + var entry = checkBoxes.FirstOrDefault(p => ReferenceEquals(p.CheckBox, toggled)); + if (entry.CheckBox is null) + return; + suppress = true; + try + { + if (entry.Flag.IsAll) + { + bool checkAll = toggled.IsChecked == true; + foreach (var (_, cb) in checkBoxes) + cb.IsChecked = checkAll; + filter.FlagMask = checkAll ? -1 : 0; + return; + } + int mask = 0; + foreach (var (flag, cb) in checkBoxes) + { + if (flag.IsAll) + continue; + if (cb.IsChecked == true) + mask |= flag.Value; + } + filter.FlagMask = mask; + // is only ticked when the mask is the unconditional -1; touching + // a regular flag always drops the mask to a specific value, so untick + // the neutral item to reflect the new partial state. + var allEntry = checkBoxes.First(p => p.Flag.IsAll); + if (allEntry.CheckBox.IsChecked == true) + allEntry.CheckBox.IsChecked = false; + } + finally + { suppress = false; } + } + + static System.Collections.Generic.IReadOnlyList BuildFlagDescriptors(Type enumType) + { + // Parity with WPF's FlagGroup.GetFlags: an "" neutral item with value -1 + // plus one descriptor per public-static field whose name doesn't end in "Mask" + // (those are conventionally sub-mask aliases, not real flags). Duplicate + // values are skipped so aliases (e.g. PrivateScope == 0) don't render twice. + var list = new System.Collections.Generic.List { + new("", -1, IsAll: true), + }; + var seenValues = new System.Collections.Generic.HashSet(); + foreach (var field in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.Name.EndsWith("Mask", StringComparison.Ordinal)) + continue; + int value = Convert.ToInt32(field.GetRawConstantValue(), + System.Globalization.CultureInfo.InvariantCulture); + if (!seenValues.Add(value)) + continue; + list.Add(new FlagDescriptor(field.Name, value, IsAll: false)); + } + return list; } - return set; } static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info) diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index e22b141d4..f73d8431c 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -89,7 +89,7 @@ namespace ILSpy.ViewModels void OnColumnFilterTextChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(ColumnFilter.Text)) + if (e.PropertyName is nameof(ColumnFilter.Text) or nameof(ColumnFilter.FlagMask)) ColumnFilterChanged?.Invoke(); } @@ -129,20 +129,22 @@ namespace ILSpy.ViewModels ArgumentNullException.ThrowIfNull(filters); foreach (var f in filters) { - if (string.IsNullOrEmpty(f.Text)) + bool textActive = !string.IsNullOrEmpty(f.Text); + bool maskActive = f.FlagMask != -1; + if (!textActive && !maskActive) 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; - if (!MatchesOne(item, prop, f.Text)) + if (!MatchesOne(item, prop, f)) return false; } return true; } - static bool MatchesOne(object item, PropertyInfo prop, string filter) + static bool MatchesOne(object item, PropertyInfo prop, ColumnFilter filter) { object? value; try @@ -151,23 +153,36 @@ namespace ILSpy.ViewModels if (value is null) return false; - if (TryGetRegex(filter) is { } rx) - return rx.IsMatch(value.ToString() ?? string.Empty); + // [Flags] column — numeric bitwise-AND match against the dropdown's mask. + // Mirrors WPF's FlagsContentFilter: -1 means "all rows", anything else passes + // rows where (mask & value) != 0. + if (prop.PropertyType.IsEnum && Attribute.IsDefined(prop.PropertyType, typeof(FlagsAttribute))) + { + if (filter.FlagMask != -1) + { + int actual = Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture); + if ((filter.FlagMask & actual) == 0) + return false; + } + // Fall through so a free-form substring filter on a [Flags] column still + // works alongside the dropdown. + } + + if (string.IsNullOrEmpty(filter.Text)) + return true; - if (prop.PropertyType.IsEnum - && Attribute.IsDefined(prop.PropertyType, typeof(FlagsAttribute)) - && TryMatchFlags(prop.PropertyType, value, filter, out var flagsResult)) - return flagsResult; + if (TryGetRegex(filter.Text) is { } rx) + return rx.IsMatch(value.ToString() ?? string.Empty); if ((IsIntegerType(prop.PropertyType) || prop.PropertyType.IsEnum) - && TryMatchNumeric(value, filter, out var numericResult)) + && TryMatchNumeric(value, filter.Text, out var numericResult)) return numericResult; // Substring matches the formatted display value so a typed "06000010" hits a // "06000010" cell — the predicate sees what the user sees, not the raw int. var info = prop.GetCustomAttribute(); var stringified = FormatForDisplay(value, info?.Format); - return stringified.Contains(filter, StringComparison.OrdinalIgnoreCase); + return stringified.Contains(filter.Text, StringComparison.OrdinalIgnoreCase); } static string FormatForDisplay(object value, string? format) @@ -182,35 +197,6 @@ namespace ILSpy.ViewModels : (value.ToString() ?? string.Empty); } - static bool TryMatchFlags(Type enumType, object value, string filter, out bool result) - { - result = false; - var actual = Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture); - foreach (var raw in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var token = raw; - bool negate = false; - if (token.StartsWith('!')) - { - negate = true; - token = token[1..].Trim(); - } - if (token.Length == 0) - return false; - if (!Enum.TryParse(enumType, token, ignoreCase: true, out var parsed) || parsed is null) - return false; - var bits = Convert.ToInt64(parsed, System.Globalization.CultureInfo.InvariantCulture); - bool isSet = bits == 0 ? actual == 0 : (actual & bits) == bits; - if (negate ? isSet : !isSet) - { - result = false; - return true; - } - } - result = true; - return true; - } - static bool TryMatchNumeric(object value, string filter, out bool result) { result = false; @@ -274,7 +260,7 @@ namespace ILSpy.ViewModels } } - /// One column's filter entry — the column name plus its current filter text. + /// One column's filter entry — the column name plus its current filter state. public sealed partial class ColumnFilter : ObservableObject { public ColumnFilter(string columnName) @@ -289,6 +275,15 @@ namespace ILSpy.ViewModels // plugin on write (the cached accessor's target reference comes back null). [ObservableProperty] private string? text; + + /// + /// For enum columns: the OR-mask of flag values the + /// user picked in the dropdown checklist. -1 (default) means "no flag + /// filter" — every row passes. Any other value is matched bitwise AND against the + /// row's flag value, mirroring WPF's FlagsContentFilter. + /// + [ObservableProperty] + private int flagMask = -1; } /// The (row, column) pair clicked in a token cell.