From c3bedd90b54db2078c6a920cad1490c04d9b2cf5 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 8 May 2026 07:29:02 +0200 Subject: [PATCH] Hover tooltips for metadata cells MetadataCellTooltip resolves `{ColumnName}Tooltip` on the bound row by reflection. The view subscribes to PointerMoved, walks up to the DataGridCell under the pointer, and applies the resolved string via ToolTip.SetTip whenever the cell changes. Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../Metadata/MetadataCellTooltipTests.cs | 78 +++++++++++++++++++ ILSpy/Metadata/MetadataCellTooltip.cs | 57 ++++++++++++++ ILSpy/Views/MetadataTablePage.axaml.cs | 27 +++++++ 3 files changed, 162 insertions(+) create mode 100644 ILSpy.Tests/Metadata/MetadataCellTooltipTests.cs create mode 100644 ILSpy/Metadata/MetadataCellTooltip.cs diff --git a/ILSpy.Tests/Metadata/MetadataCellTooltipTests.cs b/ILSpy.Tests/Metadata/MetadataCellTooltipTests.cs new file mode 100644 index 000000000..ccca5724c --- /dev/null +++ b/ILSpy.Tests/Metadata/MetadataCellTooltipTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using AwesomeAssertions; + +using ILSpy.Metadata; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +[TestFixture] +public class MetadataCellTooltipTests +{ + sealed class EntryWithTooltips + { + public string Name { get; set; } = "Foo"; + public string NameTooltip => $"heap-offset for \"{Name}\""; + + public int Flags { get; set; } = 0x42; + // Non-string tooltip values flow through ToString() — the helper just normalises + // whatever the entry exposes into a string, leaving styled-tooltip rendering for + // a later phase. + public object FlagsTooltip => $"Flags=0x{Flags:X4}"; + } + + sealed class EntryWithoutTooltips + { + public string Name { get; set; } = "Bar"; + } + + [Test] + public void Resolve_Returns_String_Tooltip_When_Property_Is_Present() + { + // The convention is `{columnName}Tooltip` — looked up by reflection on the bound row. + var entry = new EntryWithTooltips { Name = "System.Object" }; + MetadataCellTooltip.Resolve(entry, "Name").Should().Be("heap-offset for \"System.Object\""); + } + + [Test] + public void Resolve_Returns_Null_When_The_Tooltip_Property_Is_Missing() + { + // Many entry types expose tooltips for only a subset of columns; missing means "no + // tooltip", not "throw". + var entry = new EntryWithoutTooltips(); + MetadataCellTooltip.Resolve(entry, "Name").Should().BeNull(); + } + + [Test] + public void Resolve_Stringifies_Non_String_Tooltip_Values() + { + // Some WPF entries return rich FlagsTooltip objects; stringification is the safe + // fallback so the helper still produces something usable. + var entry = new EntryWithTooltips { Flags = 0x1234 }; + MetadataCellTooltip.Resolve(entry, "Flags").Should().Be("Flags=0x1234"); + } + + [Test] + public void Resolve_Returns_Null_When_The_Item_Is_Null() + { + MetadataCellTooltip.Resolve(null!, "Name").Should().BeNull(); + } +} diff --git a/ILSpy/Metadata/MetadataCellTooltip.cs b/ILSpy/Metadata/MetadataCellTooltip.cs new file mode 100644 index 000000000..3caf4fa5a --- /dev/null +++ b/ILSpy/Metadata/MetadataCellTooltip.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Concurrent; +using System.Reflection; + +namespace ILSpy.Metadata +{ + /// + /// Resolves per-cell tooltips for a metadata-table row. Entry classes opt in by exposing + /// a public {ColumnName}Tooltip property — NameTooltip, FlagsTooltip, + /// BaseTypeTooltip, and so on. The resolver returns a stringified rendering, so + /// callers can flow it straight into ToolTip.SetTip. + /// + public static class MetadataCellTooltip + { + static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyCache = new(); + + /// + /// Looks up Tooltip on 's + /// runtime type. Returns the property's stringified value, or + /// if the item is null, the tooltip property is absent, or the value is null / blank. + /// + public static string? Resolve(object item, string columnName) + { + if (item is null) + return null; + var prop = propertyCache.GetOrAdd((item.GetType(), columnName), + static key => key.Type.GetProperty(key.Column + "Tooltip", + BindingFlags.Public | BindingFlags.Instance)); + if (prop is null) + return null; + object? value; + try + { value = prop.GetValue(item); } + catch { return null; } + var s = value?.ToString(); + return string.IsNullOrWhiteSpace(s) ? null : s; + } + } +} diff --git a/ILSpy/Views/MetadataTablePage.axaml.cs b/ILSpy/Views/MetadataTablePage.axaml.cs index 9aeb33c33..537f90372 100644 --- a/ILSpy/Views/MetadataTablePage.axaml.cs +++ b/ILSpy/Views/MetadataTablePage.axaml.cs @@ -19,10 +19,14 @@ using System; using System.ComponentModel; +using Avalonia; using Avalonia.Collections; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Markup.Xaml; +using Avalonia.VisualTree; +using ILSpy.Metadata; using ILSpy.ViewModels; namespace ILSpy.Views @@ -37,15 +41,38 @@ namespace ILSpy.Views { MetadataTablePageModel? boundModel; DataGridCollectionView? itemsView; + DataGridCell? hoveredCell; public MetadataTablePage() { InitializeComponent(); DataContextChanged += (_, _) => RebindModel(); + AddHandler(PointerMovedEvent, OnPointerMovedOverGrid); } void InitializeComponent() => AvaloniaXamlLoader.Load(this); + void OnPointerMovedOverGrid(object? sender, PointerEventArgs e) + { + // Walk up the visual tree from the pointer's source to the cell. Cells nest + // arbitrary content (TextBlock for plain columns, Button for token columns), so + // a per-cell PointerEntered subscription would miss most events. + var visual = e.Source as Visual; + while (visual is not null and not DataGridCell) + visual = visual.GetVisualParent(); + var cell = visual as DataGridCell; + if (cell == hoveredCell) + return; + hoveredCell = cell; + if (cell is null) + return; + var columnName = cell.OwningColumn?.Header?.ToString(); + if (columnName is null) + return; + var tip = MetadataCellTooltip.Resolve(cell.DataContext!, columnName); + ToolTip.SetTip(cell, tip); + } + void RebindModel() { if (boundModel != null)