Browse Source

Move metadata filter inputs to a row above the grid

Hosting an interactive TextBox inside a DataGridColumn.Header proved
unreliable: the column header's PointerPressed handler intercepts clicks
for sort, so the filter input never received focus or keypresses on the
running app. Move the filter row to a sibling StackPanel above the
DataGrid where each TextBox tracks the corresponding column's
ActualWidth via the grid's LayoutUpdated event, keeping per-column
alignment without competing with the header for input.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
df6f9d1757
  1. 54
      ILSpy/Metadata/MetadataColumnBuilder.cs
  2. 41
      ILSpy/Views/MetadataTablePage.axaml
  3. 61
      ILSpy/Views/MetadataTablePage.axaml.cs

54
ILSpy/Metadata/MetadataColumnBuilder.cs

@ -59,15 +59,15 @@ namespace ILSpy.Metadata @@ -59,15 +59,15 @@ namespace ILSpy.Metadata
{
ArgumentNullException.ThrowIfNull(entryType);
return descriptorCache.GetOrAdd(entryType, BuildDescriptors)
.Select(d => BuildColumn(d, filter: null))
.Select(BuildColumn)
.ToArray();
}
/// <summary>
/// Populates <paramref name="page"/> with both <c>Columns</c> and
/// <c>ColumnFilters</c> 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.
/// <c>ColumnFilters</c> in matched order. The view renders a column-aligned filter
/// row above the grid bound to the page's <c>ColumnFilters</c>; the predicate ANDs
/// every non-empty filter against its own column.
/// </summary>
public static void Populate<TEntry>(MetadataTablePageModel page) => Populate(page, typeof(TEntry));
@ -80,9 +80,8 @@ namespace ILSpy.Metadata @@ -80,9 +80,8 @@ namespace ILSpy.Metadata
var columns = new List<DataGridColumn>(descriptors.Length);
foreach (var d in descriptors)
{
var filter = new ColumnFilter(d.Name);
page.ColumnFilters.Add(filter);
columns.Add(BuildColumn(d, filter));
page.ColumnFilters.Add(new ColumnFilter(d.Name));
columns.Add(BuildColumn(d));
}
page.Columns = columns;
}
@ -103,53 +102,16 @@ namespace ILSpy.Metadata @@ -103,53 +102,16 @@ namespace ILSpy.Metadata
return list.ToArray();
}
static DataGridColumn BuildColumn(ColumnDescriptor d, ColumnFilter? filter)
static DataGridColumn BuildColumn(ColumnDescriptor d)
{
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.Header = d.Name;
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,
Text = filter.Text,
};
// Wire TextBox <-> ColumnFilter imperatively. Avalonia's INPC binding plugin
// holds a WeakReference to the source and resolves the target lazily; for
// Header-hosted controls it reliably comes back null on write-back, throwing
// "Non-static method requires a target" out of PropertyInfo.SetValue. The plain
// event subscription side-steps the weak-ref machinery.
box.TextChanged += (_, _) => {
if (filter.Text != box.Text)
filter.Text = box.Text;
};
filter.PropertyChanged += (_, e) => {
if (e.PropertyName == nameof(ColumnFilter.Text) && box.Text != filter.Text)
box.Text = filter.Text;
};
return new StackPanel {
Orientation = Orientation.Vertical,
Children = { label, box },
};
}
static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info)
{
var binding = new Binding(prop.Name) { Mode = BindingMode.OneWay };

41
ILSpy/Views/MetadataTablePage.axaml

@ -7,21 +7,30 @@ @@ -7,21 +7,30 @@
x:Class="ILSpy.Views.MetadataTablePage"
x:DataType="vm:MetadataTablePageModel">
<!-- ItemsSource and Columns are wired imperatively in code-behind so the (clear-cols /
add-cols / set-items) sequence runs as a single transaction. Binding ItemsSource
declaratively crashes the DataGrid's measure pass when the view is reused across
content types whose schemas have a different column count, because Avalonia's
binding-system rebind happens after the imperative column rewrite. Per-column
filter inputs live inside each column's Header (built by MetadataColumnBuilder),
not in a separate row above the grid. -->
<DataGrid Name="Grid"
CanUserResizeColumns="True"
CanUserSortColumns="True"
CanUserReorderColumns="False"
IsReadOnly="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
SelectionMode="Single"
AutoGenerateColumns="False" />
<DockPanel LastChildFill="True">
<!-- Per-column filter row, aligned with the DataGrid columns below it. The view's
code-behind populates the StackPanel imperatively in lockstep with the grid's
Columns and tracks each DataGridColumn.ActualWidth so the inputs stay snapped
to the columns when the user resizes them. -->
<StackPanel Name="FilterRow"
DockPanel.Dock="Top"
Orientation="Horizontal"
Background="{DynamicResource ThemeControlMidBrush}" />
<!-- ItemsSource and Columns are wired imperatively in code-behind so the (clear-cols /
add-cols / set-items) sequence runs as a single transaction. Binding ItemsSource
declaratively crashes the DataGrid's measure pass when the view is reused across
content types whose schemas have a different column count, because Avalonia's
binding-system rebind happens after the imperative column rewrite. -->
<DataGrid Name="Grid"
CanUserResizeColumns="True"
CanUserSortColumns="True"
CanUserReorderColumns="False"
IsReadOnly="True"
GridLinesVisibility="None"
HeadersVisibility="Column"
SelectionMode="Single"
AutoGenerateColumns="False" />
</DockPanel>
</UserControl>

61
ILSpy/Views/MetadataTablePage.axaml.cs

@ -25,6 +25,7 @@ using Avalonia; @@ -25,6 +25,7 @@ using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
@ -165,11 +166,13 @@ namespace ILSpy.Views @@ -165,11 +166,13 @@ namespace ILSpy.Views
// the new schema has more columns than the old one.
grid.ItemsSource = Array.Empty<object>();
grid.Columns.Clear();
ClearFilterRow();
itemsView = null;
if (boundModel == null)
return;
foreach (var c in boundModel.Columns)
grid.Columns.Add(c);
BuildFilterRow(grid, boundModel);
var model = boundModel;
itemsView = new DataGridCollectionView(model.Items) {
Filter = item => MetadataTablePageModel.MatchesFilters(item, model.ColumnFilters),
@ -177,6 +180,64 @@ namespace ILSpy.Views @@ -177,6 +180,64 @@ namespace ILSpy.Views
grid.ItemsSource = itemsView;
}
void ClearFilterRow()
{
var grid = this.FindControl<DataGrid>("Grid");
if (grid != null)
grid.LayoutUpdated -= SyncFilterRowWidths;
var row = this.FindControl<StackPanel>("FilterRow");
row?.Children.Clear();
}
void BuildFilterRow(DataGrid grid, MetadataTablePageModel model)
{
var row = this.FindControl<StackPanel>("FilterRow");
if (row is null)
return;
for (int i = 0; i < grid.Columns.Count && i < model.ColumnFilters.Count; i++)
{
var filter = model.ColumnFilters[i];
var box = new TextBox {
MinHeight = 0,
Padding = new Thickness(2, 1),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Stretch,
Tag = grid.Columns[i],
PlaceholderText = filter.ColumnName,
Text = filter.Text,
};
// Two-way bridge between the box and the filter via plain events. The filter's
// PropertyChanged handler is hooked from the page model side and re-raises a
// single ColumnFilterChanged the view uses to refresh the collection view.
box.TextChanged += (_, _) => {
if (filter.Text != box.Text)
filter.Text = box.Text;
};
filter.PropertyChanged += (_, e) => {
if (e.PropertyName == nameof(ColumnFilter.Text) && box.Text != filter.Text)
box.Text = filter.Text;
};
row.Children.Add(box);
}
// Snap each input to the corresponding column's ActualWidth on every layout
// pass — DataGridColumn.ActualWidth isn't a styled property, so we can't
// subscribe to it; piggy-backing on LayoutUpdated is cheap and reliable.
grid.LayoutUpdated += SyncFilterRowWidths;
SyncFilterRowWidths(grid, EventArgs.Empty);
}
void SyncFilterRowWidths(object? sender, EventArgs e)
{
var row = this.FindControl<StackPanel>("FilterRow");
if (row is null)
return;
foreach (var child in row.Children)
{
if (child is TextBox box && box.Tag is DataGridColumn column)
box.Width = column.ActualWidth;
}
}
void ApplyScrollTarget()
{
if (boundModel?.ScrollToRow is not int row)

Loading…
Cancel
Save