Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
7ea8d3b911
  1. 88
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  2. 153
      ILSpy/Metadata/MetadataColumnBuilder.cs
  3. 79
      ILSpy/ViewModels/MetadataTablePageModel.cs

88
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -170,60 +170,74 @@ public class MetadataFilterTests @@ -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();
// "<All>" 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

153
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -158,10 +158,11 @@ namespace ILSpy.Metadata @@ -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 "<All>" 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 @@ -170,16 +171,7 @@ namespace ILSpy.Metadata
VerticalAlignment = VerticalAlignment.Stretch,
};
var checkPanel = new StackPanel { Orientation = Orientation.Vertical };
var checkBoxes = new System.Collections.Generic.Dictionary<string, CheckBox>(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 @@ -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 @@ -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<string, CheckBox> 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<string, CheckBox> checkBoxes)
/// <summary>
/// Owns the dropdown's CheckBoxes and reconciles them with
/// <see cref="ColumnFilter.FlagMask"/>. A re-entry guard suppresses the
/// <c>IsCheckedChanged</c> handler while we cross-toggle other boxes — without it,
/// flipping &lt;All&gt; would cascade through every regular flag's handler and
/// rebuild the mask from intermediate states.
/// </summary>
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<string> ParsePositiveTokens(string? text)
{
var set = new System.Collections.Generic.HashSet<string>(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 ? "<All>" : $"{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;
// <All> 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<FlagDescriptor> BuildFlagDescriptors(Type enumType)
{
// Parity with WPF's FlagGroup.GetFlags: an "<All>" 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<FlagDescriptor> {
new("<All>", -1, IsAll: true),
};
var seenValues = new System.Collections.Generic.HashSet<int>();
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)

79
ILSpy/ViewModels/MetadataTablePageModel.cs

@ -89,7 +89,7 @@ namespace ILSpy.ViewModels @@ -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 @@ -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 @@ -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<ColumnInfoAttribute>();
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 @@ -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 @@ -274,7 +260,7 @@ namespace ILSpy.ViewModels
}
}
/// <summary>One column's filter entry — the column name plus its current filter text.</summary>
/// <summary>One column's filter entry — the column name plus its current filter state.</summary>
public sealed partial class ColumnFilter : ObservableObject
{
public ColumnFilter(string columnName)
@ -289,6 +275,15 @@ namespace ILSpy.ViewModels @@ -289,6 +275,15 @@ namespace ILSpy.ViewModels
// plugin on write (the cached accessor's target reference comes back null).
[ObservableProperty]
private string? text;
/// <summary>
/// For <see cref="FlagsAttribute"/> enum columns: the OR-mask of flag values the
/// user picked in the dropdown checklist. <c>-1</c> (default) means "no flag
/// filter" — every row passes. Any other value is matched bitwise AND against the
/// row's flag value, mirroring WPF's <c>FlagsContentFilter</c>.
/// </summary>
[ObservableProperty]
private int flagMask = -1;
}
/// <summary>The (row, column) pair clicked in a token cell.</summary>

Loading…
Cancel
Save