Browse Source

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
pull/3776/head
Siegfried Pammer 4 weeks ago committed by Siegfried Pammer
parent
commit
5f2e448a09
  1. 52
      ILSpy.Tests/Editor/DocumentationLinkTests.cs
  2. 22
      ILSpy/TextView/DecompilerTextView.axaml.cs

52
ILSpy.Tests/Editor/DocumentationLinkTests.cs

@ -104,6 +104,58 @@ public class DocumentationLinkTests
"the hosting popup closes itself when a link is followed"); "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<TypeTreeNode>(coreLibName, "System", "System.String");
vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
window.UpdateLayout();
var view = window.GetVisualDescendants().OfType<DecompilerTextView>().First();
var renderer = new DocumentationRenderer(
new CSharpAmbience(),
new FontFamily("Consolas, Menlo, Monospace"),
12);
renderer.AddXmlDocumentation(
"""<summary>Some text the user may want to select and copy.</summary>""",
declaringEntity: null,
resolver: _ => null);
var content = renderer.CreateView();
view.OpenRichPopup(content);
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var popup = view.FindControl<Avalonia.Controls.Primitives.Popup>("RichHoverPopup")!;
popup.IsOpen.Should().BeTrue("setup precondition — the popup must be open before the press");
var paragraph = content.GetVisualDescendants().OfType<SelectableTextBlock>().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] [AvaloniaTest]
public void Unresolvable_Cref_Falls_Back_To_Plain_Text() public void Unresolvable_Cref_Falls_Back_To_Plain_Text()
{ {

22
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -850,10 +850,14 @@ namespace ILSpy.TextView
{ {
if (richPopup.IsOpen) if (richPopup.IsOpen)
{ {
// TODO: refuse to close while the popup has keyboard focus (the user is // While the pointer is over the popup or keyboard focus is inside it (the
// reading / clicking links inside it). Not tracked yet — for now the popup // user is reading, selecting text to copy, or reaching for a link), only a
// is always replaceable. // forced close (document change) may take it down.
_ = mouseClick; if (!mouseClick && richPopup.Child is { } child
&& (child.IsPointerOver || child.IsKeyboardFocusWithin))
{
return false;
}
CloseRichPopup(); CloseRichPopup();
return true; return true;
} }
@ -907,7 +911,7 @@ namespace ILSpy.TextView
ToolTip.SetIsOpen(Editor.TextArea.TextView, true); ToolTip.SetIsOpen(Editor.TextArea.TextView, true);
} }
void OpenRichPopup(Control content) internal void OpenRichPopup(Control content)
{ {
richPopup.Child = content; richPopup.Child = content;
// While the pointer is over the popup, the editor no longer receives the moves // 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) 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); TryCloseExistingPopup(mouseClick: false);
} }

Loading…
Cancel
Save