Browse Source

Filter funnel icon, hover-to-show input, drop flags textbox

Each metadata column header now shows a small funnel icon to the right of the
column name. The TextBox / dropdown trigger collapses by default and only
becomes visible when the pointer is over the header, when a filter is set, or
while the [Flags] popup is open. Active filters tint the funnel SteelBlue so
the user can see at a glance which columns are constraining the view.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
c291822f62
  1. 14
      ILSpy.Tests/Metadata/MetadataFilterTests.cs
  2. 111
      ILSpy/Metadata/MetadataColumnBuilder.cs

14
ILSpy.Tests/Metadata/MetadataFilterTests.cs

@ -311,6 +311,14 @@ public class MetadataFilterTests @@ -311,6 +311,14 @@ public class MetadataFilterTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
// The header layout collapses the per-column TextBox until the user hovers the
// header or sets a filter (the funnel icon stays visible all the time). Force the
// model TextBox visible up-front so the rendered visual tree realises it; the
// hover/auto-show wiring is exercised in its own UI-state tests.
var nameColumn = tab.Columns.Single(c => (string?)c.Tag == "Name");
var modelHeaderBox = ((StackPanel)nameColumn.Header!).Children.OfType<TextBox>().Single();
modelHeaderBox.IsVisible = true;
var metadataPage = await window.WaitForComponent<MetadataTablePage>();
var grid = await metadataPage.WaitForComponent<DataGrid>();
await grid.WaitForComponent<DataGridColumnHeader>();
@ -318,12 +326,10 @@ public class MetadataFilterTests @@ -318,12 +326,10 @@ public class MetadataFilterTests
var headerBox = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(tb => tb.FindAncestorOfType<DataGridColumnHeader>() is { } owner
&& owner.Content is StackPanel sp
&& sp.Children.OfType<TextBlock>().FirstOrDefault()?.Text == "Name");
&& sp.Children.OfType<DockPanel>().FirstOrDefault()
?.Children.OfType<TextBlock>().FirstOrDefault()?.Text == "Name");
headerBox.Should().NotBeNull(
"the column-builder bakes a TextBox into the Name column's header — it must reach the rendered visual tree");
var nameColumn = tab.Columns.Single(c => (string?)c.Tag == "Name");
var modelHeaderBox = ((StackPanel)nameColumn.Header!).Children.OfType<TextBox>().Single();
ReferenceEquals(headerBox, modelHeaderBox).Should().BeTrue(
"DataGrid must render the StackPanel Header directly without re-templating; otherwise the property-changed handler is on a different TextBox than the user types into");

111
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -119,15 +119,85 @@ namespace ILSpy.Metadata @@ -119,15 +119,85 @@ namespace ILSpy.Metadata
static Control BuildHeader(string columnName, ColumnFilter filter, Type propertyType)
{
// Header layout: column name on the left, filter funnel icon on the right of
// the top row; below them an input row that collapses by default and becomes
// visible on pointer-over (or while a filter is set / a popup is open). For
// [Flags] columns the input row is just the dropdown trigger — the popup
// carries the entire filter UI, so a TextBox would be redundant.
var label = new TextBlock {
Text = columnName,
FontWeight = FontWeight.SemiBold,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Center,
};
var filterIcon = new global::Avalonia.Controls.Shapes.Path {
// Funnel: 10×9 trapezoid above a 2×5 spout. Drawn directly because the
// Simple theme doesn't ship a filter glyph and pulling FontAwesome for a
// single icon isn't worth it.
Width = 10,
Height = 9,
Stretch = Stretch.None,
Fill = Brushes.Gray,
Data = global::Avalonia.Media.Geometry.Parse("M0,0 L10,0 L6,4 L6,9 L4,9 L4,4 Z"),
Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var topRow = new DockPanel { LastChildFill = true };
DockPanel.SetDock(filterIcon, global::Avalonia.Controls.Dock.Right);
topRow.Children.Add(filterIcon);
topRow.Children.Add(label);
bool isFlagsColumn = propertyType.IsEnum
&& Attribute.IsDefined(propertyType, typeof(FlagsAttribute));
Popup? popup = null;
Control inputRow;
if (isFlagsColumn)
{
(inputRow, popup) = BuildFlagsDropdownTrigger(filter, propertyType);
}
else
{
inputRow = BuildFilterTextBox(filter);
}
inputRow.IsVisible = false;
inputRow.Margin = new Thickness(0, 2, 0, 0);
var root = new StackPanel { Orientation = Orientation.Vertical };
root.Children.Add(topRow);
root.Children.Add(inputRow);
if (popup != null)
root.Children.Add(popup);
bool hovered = false;
bool popupOpen = false;
void Update()
{
bool active = !string.IsNullOrWhiteSpace(filter.Text)
|| (filter.FlagsState != null && !filter.FlagsState.IsEmpty);
inputRow.IsVisible = hovered || active || popupOpen;
// Tint the funnel when a filter is in effect so users can see at a glance
// which columns are constraining the view, even with the input collapsed.
filterIcon.Fill = active ? Brushes.SteelBlue : Brushes.Gray;
}
root.PointerEntered += (_, _) => { hovered = true; Update(); };
root.PointerExited += (_, _) => { hovered = false; Update(); };
filter.PropertyChanged += (_, _) => Update();
if (popup != null)
{
popup.Opened += (_, _) => { popupOpen = true; Update(); };
popup.Closed += (_, _) => { popupOpen = false; Update(); };
}
Update();
return root;
}
static TextBox BuildFilterTextBox(ColumnFilter filter)
{
var box = new TextBox {
MinHeight = 0,
Padding = new Thickness(2, 1),
Margin = new Thickness(0, 2, 0, 0),
FontWeight = FontWeight.Normal,
HorizontalAlignment = HorizontalAlignment.Stretch,
Text = filter.Text,
@ -145,24 +215,15 @@ namespace ILSpy.Metadata @@ -145,24 +215,15 @@ namespace ILSpy.Metadata
if (e.PropertyName == nameof(ColumnFilter.Text) && box.Text != filter.Text)
box.Text = filter.Text;
};
Control inputRow = box;
if (propertyType.IsEnum && Attribute.IsDefined(propertyType, typeof(FlagsAttribute)))
inputRow = WrapWithFlagsDropdown(box, filter, propertyType);
return new StackPanel {
Orientation = Orientation.Vertical,
Children = { label, inputRow },
};
return box;
}
static Control WrapWithFlagsDropdown(TextBox box, ColumnFilter filter, Type enumType)
static (Control Trigger, Popup Popup) BuildFlagsDropdownTrigger(ColumnFilter filter, Type enumType)
{
// Schema-driven dropdown: a FlagsFilterPopup that distinguishes mutex
// sub-ranges (multi-select chips) from independent flags (tri-state pills),
// driving ColumnFilter.FlagsState. The predicate path runs the compiled
// CompiledFilter alongside the TextBox-driven substring / regex / numeric
// path; both AND together.
// Schema-driven dropdown: FlagsFilterPopup distinguishes mutex sub-ranges
// (multi-select chips) from independent flags (tri-state pills) and drives
// ColumnFilter.FlagsState. No accompanying TextBox — the popup is the entire
// filter UI for [Flags] columns.
var schema = ILSpy.Metadata.Filters.FlagsSchemaInferer.For(enumType);
filter.FlagsState ??= new ILSpy.Metadata.Filters.FilterState(schema);
var popupContent = new ILSpy.Views.Filters.FlagsFilterPopup(filter.FlagsState);
@ -170,9 +231,9 @@ namespace ILSpy.Metadata @@ -170,9 +231,9 @@ namespace ILSpy.Metadata
var openButton = new Button {
Content = "▾",
Padding = new Thickness(4, 0),
Margin = new Thickness(2, 2, 0, 0),
MinHeight = 0,
VerticalAlignment = VerticalAlignment.Stretch,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
var popupRoot = new Border {
BorderBrush = Brushes.Gray,
@ -180,8 +241,8 @@ namespace ILSpy.Metadata @@ -180,8 +241,8 @@ namespace ILSpy.Metadata
Background = Brushes.White,
// Force the arrow cursor on the popup surface — without this, the
// EW-resize cursor set on the DataGrid column header's drag-grip can
// leak into the popup if the pointer enters from there before
// Avalonia recomputes the cursor for the new hit-test target.
// leak into the popup if the pointer enters from there before Avalonia
// recomputes the cursor for the new hit-test target.
Cursor = new global::Avalonia.Input.Cursor(global::Avalonia.Input.StandardCursorType.Arrow),
Child = new ScrollViewer {
MaxHeight = 400,
@ -202,15 +263,7 @@ namespace ILSpy.Metadata @@ -202,15 +263,7 @@ namespace ILSpy.Metadata
Child = popupRoot,
};
openButton.Click += (_, _) => popup.IsOpen = true;
var row = new DockPanel { LastChildFill = true, Margin = new Thickness(0, 2, 0, 0) };
DockPanel.SetDock(openButton, global::Avalonia.Controls.Dock.Right);
row.Children.Add(openButton);
row.Children.Add(box);
box.Margin = new Thickness(0);
return new StackPanel {
Orientation = Orientation.Vertical,
Children = { row, popup },
};
return (openButton, popup);
}
static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info)

Loading…
Cancel
Save