Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
c3bedd90b5
  1. 78
      ILSpy.Tests/Metadata/MetadataCellTooltipTests.cs
  2. 57
      ILSpy/Metadata/MetadataCellTooltip.cs
  3. 27
      ILSpy/Views/MetadataTablePage.axaml.cs

78
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();
}
}

57
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
{
/// <summary>
/// Resolves per-cell tooltips for a metadata-table row. Entry classes opt in by exposing
/// a public <c>{ColumnName}Tooltip</c> property — <c>NameTooltip</c>, <c>FlagsTooltip</c>,
/// <c>BaseTypeTooltip</c>, and so on. The resolver returns a stringified rendering, so
/// callers can flow it straight into <c>ToolTip.SetTip</c>.
/// </summary>
public static class MetadataCellTooltip
{
static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyCache = new();
/// <summary>
/// Looks up <paramref name="columnName"/><c>Tooltip</c> on <paramref name="item"/>'s
/// runtime type. Returns the property's stringified value, or <see langword="null"/>
/// if the item is null, the tooltip property is absent, or the value is null / blank.
/// </summary>
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;
}
}
}

27
ILSpy/Views/MetadataTablePage.axaml.cs

@ -19,10 +19,14 @@
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using Avalonia;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.VisualTree;
using ILSpy.Metadata;
using ILSpy.ViewModels; using ILSpy.ViewModels;
namespace ILSpy.Views namespace ILSpy.Views
@ -37,15 +41,38 @@ namespace ILSpy.Views
{ {
MetadataTablePageModel? boundModel; MetadataTablePageModel? boundModel;
DataGridCollectionView? itemsView; DataGridCollectionView? itemsView;
DataGridCell? hoveredCell;
public MetadataTablePage() public MetadataTablePage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += (_, _) => RebindModel(); DataContextChanged += (_, _) => RebindModel();
AddHandler(PointerMovedEvent, OnPointerMovedOverGrid);
} }
void InitializeComponent() => AvaloniaXamlLoader.Load(this); 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() void RebindModel()
{ {
if (boundModel != null) if (boundModel != null)

Loading…
Cancel
Save