From 3ae24fc12a477cd403166c645e69760fc5eff654 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 7 May 2026 21:07:19 +0200 Subject: [PATCH] Render token cells as hyperlink-styled buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [ColumnInfo(Kind=Token)] properties now produce a DataGridTemplateColumn whose cell is a transparent-background, blue-foreground Button — the hyperlink affordance the user clicks to jump to the target table row. Click raises NavigateToCellRequested(Row, ColumnName) on the owning MetadataTablePageModel; Phase 3c subscribes from the dock workspace, resolves the token to its target table tree node, and scrolls into view. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Metadata/MetadataColumnBuilderTests.cs | 21 +++++ ILSpy/Metadata/MetadataColumnBuilder.cs | 76 ++++++++++++++++--- ILSpy/ViewModels/MetadataTablePageModel.cs | 13 ++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs index 5cde5b16b..a8fcd4c2a 100644 --- a/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs +++ b/ILSpy.Tests/Metadata/MetadataColumnBuilderTests.cs @@ -73,4 +73,25 @@ public class MetadataColumnBuilderTests first.Should().NotBeSameAs(second); first[0].Should().NotBeSameAs(second[0]); } + + sealed class SampleEntryWithToken + { + public int RID { get; set; } + [ColumnInfo("X8", Kind = ColumnKind.Token)] + public int Method { get; set; } + public string Name { get; set; } = ""; + } + + [AvaloniaTest] + public void For_Emits_Template_Column_For_Token_Kind_Properties() + { + // Properties annotated Kind=Token render as a DataGridTemplateColumn whose cell is + // a hyperlink-styled button. Plain Format-only columns stay as text. + + var columns = MetadataColumnBuilder.For(); + + columns[0].Should().BeOfType("RID has no [ColumnInfo]"); + columns[1].Should().BeOfType("Method is Kind=Token"); + columns[2].Should().BeOfType("Name has no [ColumnInfo]"); + } } diff --git a/ILSpy/Metadata/MetadataColumnBuilder.cs b/ILSpy/Metadata/MetadataColumnBuilder.cs index 3a0ae3a12..26db79ac5 100644 --- a/ILSpy/Metadata/MetadataColumnBuilder.cs +++ b/ILSpy/Metadata/MetadataColumnBuilder.cs @@ -22,9 +22,16 @@ using System.Globalization; using System.Linq; using System.Reflection; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Data.Converters; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +using ILSpy.ViewModels; namespace ILSpy.Metadata { @@ -61,21 +68,70 @@ namespace ILSpy.Metadata continue; var info = prop.GetCustomAttribute(); - var binding = new Binding(prop.Name) { Mode = BindingMode.OneWay }; - - if (!string.IsNullOrEmpty(info?.Format)) + if (info?.Kind == ColumnKind.Token) { - binding.Converter = new HexFormatConverter(info!.Format); + columns.Add(BuildTokenColumn(prop, info)); + } + else + { + columns.Add(BuildTextColumn(prop, info)); } - columns.Add(new DataGridTextColumn { - Header = prop.Name, - IsReadOnly = true, - Binding = binding, - SortMemberPath = prop.Name, - }); } return columns; } + + static DataGridTextColumn BuildTextColumn(PropertyInfo prop, ColumnInfoAttribute? info) + { + var binding = new Binding(prop.Name) { Mode = BindingMode.OneWay }; + if (!string.IsNullOrEmpty(info?.Format)) + binding.Converter = new HexFormatConverter(info.Format); + return new DataGridTextColumn { + Header = prop.Name, + IsReadOnly = true, + Binding = binding, + SortMemberPath = prop.Name, + }; + } + + static DataGridTemplateColumn BuildTokenColumn(PropertyInfo prop, ColumnInfoAttribute? info) + { + var format = info?.Format; + var template = new FuncDataTemplate((row, _) => { + var btn = new Button { + Background = Brushes.Transparent, + BorderThickness = default, + Padding = new Thickness(2, 0), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Foreground = Brushes.Blue, + Cursor = new global::Avalonia.Input.Cursor(global::Avalonia.Input.StandardCursorType.Hand), + Content = FormatTokenValue(row, prop, format), + }; + btn.Click += (_, _) => { + if (btn.FindAncestorOfType()?.DataContext is MetadataTablePageModel page) + page.RaiseNavigateToCell(row, prop.Name); + }; + return btn; + }, supportsRecycling: true); + return new DataGridTemplateColumn { + Header = prop.Name, + IsReadOnly = true, + CellTemplate = template, + SortMemberPath = prop.Name, + }; + } + + static string FormatTokenValue(object row, PropertyInfo prop, string? format) + { + var value = prop.GetValue(row); + if (value is null) + return string.Empty; + if (value is Enum enumValue) + value = System.Convert.ChangeType(enumValue, enumValue.GetTypeCode(), CultureInfo.InvariantCulture); + if (!string.IsNullOrEmpty(format) && value is IFormattable formattable) + return formattable.ToString(format, CultureInfo.InvariantCulture); + return value.ToString() ?? string.Empty; + } } /// diff --git a/ILSpy/ViewModels/MetadataTablePageModel.cs b/ILSpy/ViewModels/MetadataTablePageModel.cs index 1df53f6e6..fff0df400 100644 --- a/ILSpy/ViewModels/MetadataTablePageModel.cs +++ b/ILSpy/ViewModels/MetadataTablePageModel.cs @@ -46,5 +46,18 @@ namespace ILSpy.ViewModels /// [ObservableProperty] private int? scrollToRow; + + /// + /// Raised when the user clicks a hyperlink-styled token cell. The host (the dock + /// workspace) resolves the (row, columnName) pair to a metadata token and navigates + /// to the target table row. + /// + public event Action? NavigateToCellRequested; + + internal void RaiseNavigateToCell(object row, string columnName) + => NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName)); } + + /// The (row, column) pair clicked in a token cell. + public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName); }