From 5f2e448a0936e0dbc31ddda903dc82f95c6b7cad Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 11 Jun 2026 17:51:22 +0200 Subject: [PATCH] Keep the hover popup open while the user interacts with it Selecting text inside the popup closed it: the selection drag captures the pointer, so sweeping past the popup edge fired the content's PointerExited with IsPointerOver already false. The close veto now covers pointer-over, keyboard-focus-within (so a finished selection survives for Ctrl+C, as the WPF tooltip did), and pointer capture held inside the popup; only a document change force-closes. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.Tests/Editor/DocumentationLinkTests.cs | 52 ++++++++++++++++++++ ILSpy/TextView/DecompilerTextView.axaml.cs | 22 +++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/ILSpy.Tests/Editor/DocumentationLinkTests.cs b/ILSpy.Tests/Editor/DocumentationLinkTests.cs index 00c51739b..caa73e548 100644 --- a/ILSpy.Tests/Editor/DocumentationLinkTests.cs +++ b/ILSpy.Tests/Editor/DocumentationLinkTests.cs @@ -104,6 +104,58 @@ public class DocumentationLinkTests "the hosting popup closes itself when a link is followed"); } + [AvaloniaTest] + public async Task Pressing_Inside_The_Popup_To_Select_Does_Not_Close_It() + { + var (window, vm) = await TestHarness.BootAsync(); + var coreLibName = typeof(object).Assembly.GetName().Name!; + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + window.UpdateLayout(); + + var view = window.GetVisualDescendants().OfType().First(); + var renderer = new DocumentationRenderer( + new CSharpAmbience(), + new FontFamily("Consolas, Menlo, Monospace"), + 12); + renderer.AddXmlDocumentation( + """Some text the user may want to select and copy.""", + declaringEntity: null, + resolver: _ => null); + var content = renderer.CreateView(); + view.OpenRichPopup(content); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + + var popup = view.FindControl("RichHoverPopup")!; + popup.IsOpen.Should().BeTrue("setup precondition — the popup must be open before the press"); + + var paragraph = content.GetVisualDescendants().OfType().First(); + var inside = paragraph.TranslatePoint( + new Point(10, paragraph.Bounds.Height / 2), window)!.Value; + + window.MouseDown(inside, MouseButton.Left); + popup.IsOpen.Should().BeTrue("holding LMB inside the popup starts a text selection, not a dismiss"); + window.MouseMove(inside + new Point(25, 0)); + popup.IsOpen.Should().BeTrue("dragging a selection inside the popup must not close it"); + + // Selecting to the end of a line routinely sweeps the pointer past the popup edge + // while the button is still held — the popup must survive that. + var farOutside = inside + new Point(600, 80); + window.MouseMove(farOutside); + popup.IsOpen.Should().BeTrue("a selection drag sweeping past the popup edge must not close it"); + + // Finish the selection back over the popup and release: the pointer is still over the + // popup, so it stays open for the copy. (Releasing far outside the popup is left to + // normal leave-to-close behaviour, which differs by platform focus model.) + window.MouseMove(inside + new Point(40, 0)); + window.MouseUp(inside + new Point(40, 0), MouseButton.Left); + Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + popup.IsOpen.Should().BeTrue("the selection just finished over the popup — it stays for the copy"); + } + [AvaloniaTest] public void Unresolvable_Cref_Falls_Back_To_Plain_Text() { diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 6f573498d..fd18ebd93 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -850,10 +850,14 @@ namespace ILSpy.TextView { if (richPopup.IsOpen) { - // TODO: refuse to close while the popup has keyboard focus (the user is - // reading / clicking links inside it). Not tracked yet — for now the popup - // is always replaceable. - _ = mouseClick; + // While the pointer is over the popup or keyboard focus is inside it (the + // user is reading, selecting text to copy, or reaching for a link), only a + // forced close (document change) may take it down. + if (!mouseClick && richPopup.Child is { } child + && (child.IsPointerOver || child.IsKeyboardFocusWithin)) + { + return false; + } CloseRichPopup(); return true; } @@ -907,7 +911,7 @@ namespace ILSpy.TextView ToolTip.SetIsOpen(Editor.TextArea.TextView, true); } - void OpenRichPopup(Control content) + internal void OpenRichPopup(Control content) { richPopup.Child = content; // While the pointer is over the popup, the editor no longer receives the moves @@ -920,6 +924,14 @@ namespace ILSpy.TextView void OnRichPopupContentPointerExited(object? sender, PointerEventArgs e) { + // A selection drag that sweeps past the popup edge keeps the pointer captured + // inside the popup — that's a wide swipe, not a leave. The over/focus vetoes in + // TryCloseExistingPopup don't see the capture, so check it here. + if (e.Pointer.Captured is Visual captured && richPopup.Child is { } child + && (ReferenceEquals(captured, child) || captured.GetVisualAncestors().Contains(child))) + { + return; + } TryCloseExistingPopup(mouseClick: false); }