diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs index 5b50d41e1..5215c0043 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -134,4 +134,80 @@ public class BookmarkGutterTests manager.Bookmarks.Should().ContainSingle("a left-click at the same coordinate still toggles"); } + + [AvaloniaTest] + public async Task Removing_A_Bookmark_From_The_Gutter_Hides_Its_Hover_Preview_Until_The_Pointer_Leaves_The_Line() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + window.UpdateLayout(); + + var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); + var textView = view.Editor.TextArea.TextView; + textView.EnsureVisualLines(); + var bookmarkable = textView.VisualLines + .Where(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)) + .Take(2) + .ToArray(); + bookmarkable.Should().HaveCount(2, "two bookmarkable lines are needed to leave and re-enter the removed line"); + + Point GutterPoint(VisualLine line) + { + double y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; + var p = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); + p.Should().NotBeNull("the gutter line coordinate must map into the test window"); + return p!.Value; + } + + int line0 = bookmarkable[0].FirstDocumentLine.LineNumber; + int line1 = bookmarkable[1].FirstDocumentLine.LineNumber; + Point point0 = GutterPoint(bookmarkable[0]); + Point point1 = GutterPoint(bookmarkable[1]); + + // Hovering an empty bookmarkable line previews a ghost glyph. + window.MouseMove(point0); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line0, "hovering a bookmarkable line previews its glyph"); + + // Click adds the bookmark; the pointer stays on the line. + window.MouseDown(point0, MouseButton.Left); + window.MouseUp(point0, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().ContainSingle(); + + // Clicking again removes it: the line must visibly empty rather than redraw a ghost. + window.MouseDown(point0, MouseButton.Left); + window.MouseUp(point0, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().BeEmpty(); + margin.HoverPreviewLine.Should().Be(-1, "removing via the gutter hides the preview"); + + // A jitter that stays on the just-removed line must NOT bring the preview back. + window.MouseMove(new Point(point0.X + 1, point0.Y)); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(-1, "moving within the same line keeps the removed line's preview hidden"); + + // Moving onto a different line resumes normal hover. + window.MouseMove(point1); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line1, "a different line hovers normally"); + + // Returning to the original line now shows its preview again. + window.MouseMove(point0); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line0, "leaving and re-entering the line restores its preview"); + } } diff --git a/ILSpy/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index b0fad444d..68a413462 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -24,6 +24,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Threading; using AvaloniaEdit.Editing; @@ -40,6 +41,9 @@ namespace ICSharpCode.ILSpy.Bookmarks public sealed class BookmarkMargin : AbstractMargin { const double IconSize = 16; + // Faded glyph shown on a hovered, not-yet-bookmarked line to hint the gutter is clickable; + // kept translucent so it reads as a preview, not an actual (opaque) bookmark. + const double HoverPreviewOpacity = 0.5; static readonly IBrush FallbackBackground = new SolidColorBrush(Color.FromRgb(0xF3, 0xF0, 0xD0)); static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3)); // One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs. @@ -54,6 +58,8 @@ namespace ICSharpCode.ILSpy.Bookmarks bool subscribedToManager; int pulseLine = -1; int hoverLine = -1; + // A line whose hover preview stays hidden after a removal click, until the pointer leaves it. + int suppressedHoverLine = -1; public BookmarkMargin(TextView.DecompilerTextView owner) { @@ -167,8 +173,8 @@ namespace ICSharpCode.ILSpy.Bookmarks if (isHoverPreview) { - using (drawingContext.PushOpacity(0.35)) - drawingContext.DrawImage(glyph, rect); + using (drawingContext.PushOpacity(HoverPreviewOpacity)) + drawingContext.DrawImage(HoverPreviewGlyph(), rect); continue; } @@ -188,6 +194,24 @@ namespace ICSharpCode.ILSpy.Bookmarks } } + // A bitmap copy of the bookmark glyph, shared by all gutters, used only for the hover preview. + // SvgImage paints through a custom Skia draw operation that ignores DrawingContext.PushOpacity, + // so the SVG can't be faded directly; a rasterized bitmap is composited at the pushed opacity. + static Bitmap? hoverPreviewGlyph; + + static Bitmap HoverPreviewGlyph() + { + if (hoverPreviewGlyph != null) + return hoverPreviewGlyph; + var source = Images.Bookmark; + // Oversample so the bitmap stays crisp when the gutter draws it at the device scale. + int pixels = (int)(IconSize * 3); + var bitmap = new RenderTargetBitmap(new PixelSize(pixels, pixels), new Vector(96, 96)); + using (var context = bitmap.CreateDrawingContext()) + source.Draw(context, new Rect(source.Size), new Rect(0, 0, pixels, pixels)); + return hoverPreviewGlyph = bitmap; + } + void DrawBackground(DrawingContext drawingContext) { // The filled background keeps the empty gutter hit-testable so the first click can @@ -221,7 +245,15 @@ namespace ICSharpCode.ILSpy.Bookmarks var visualLine = textView.GetVisualLineFromVisualTop(y); if (visualLine == null) return; - owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber); + // A removal click leaves the pointer hovering the line it just cleared. Without this, Render + // would immediately redraw the line's hover-preview glyph, so the click would look like a + // no-op. Suppress the preview on that line until the pointer leaves it (a jitter that stays + // on the same line must not bring it back); a fresh hover after leaving shows it again. + if (!owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) + { + suppressedHoverLine = visualLine.FirstDocumentLine.LineNumber; + SetHoverLine(-1); + } InvalidateVisual(); e.Handled = true; } @@ -238,12 +270,23 @@ namespace ICSharpCode.ILSpy.Bookmarks if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) newHoverLine = visualLine.FirstDocumentLine.LineNumber; } + if (newHoverLine == suppressedHoverLine) + { + // Still on the just-removed line: keep its preview hidden. + newHoverLine = -1; + } + else + { + // The pointer moved onto a different line, so normal hover resumes. + suppressedHoverLine = -1; + } SetHoverLine(newHoverLine); } protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); + suppressedHoverLine = -1; SetHoverLine(-1); } @@ -254,5 +297,8 @@ namespace ICSharpCode.ILSpy.Bookmarks hoverLine = line; InvalidateVisual(); } + + // The line currently drawing a hover-preview glyph, or -1 when none. Exposed for tests. + internal int HoverPreviewLine => hoverLine; } } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index d94bbfc7c..e3dc78f23 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -715,11 +715,15 @@ namespace ICSharpCode.ILSpy.TextView return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); } - /// Adds or removes a bookmark on ; a no-op for non-anchorable lines. - internal void ToggleBookmarkAtLine(int line) + /// + /// Adds or removes a bookmark on ; a no-op for non-anchorable lines. + /// Returns true when a bookmark was added, false when one was removed or the line is not anchorable. + /// + internal bool ToggleBookmarkAtLine(int line) { if (CreateBookmarkForLine(line) is { } candidate) - BookmarkManager?.Toggle(candidate); + return BookmarkManager?.Toggle(candidate) ?? false; + return false; } internal void ToggleBookmarkAtOffset(int offset)