Browse Source

Give the bookmark gutter immediate feedback when removing a bookmark

Two fixes to the gutter's hover preview, the faint glyph shown on a hovered
bookmarkable line:

A removal click left the pointer hovering the line it had just cleared, so the
hover preview redrew a glyph there immediately and the click looked like a
no-op. The just-removed line's preview is now suppressed until the pointer
leaves that line; a jitter that stays on the same line keeps it hidden, and a
fresh hover after leaving shows it again.

The preview also never actually faded: the glyph is an SvgImage, which paints
through a custom Skia draw operation that ignores DrawingContext.PushOpacity,
so it always rendered fully opaque. The glyph is now rasterized once into a
bitmap, which DrawImage does composite at the pushed opacity.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3839/head
Siegfried Pammer 1 week ago committed by Siegfried Pammer
parent
commit
c24a98a2c3
  1. 76
      ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs
  2. 52
      ILSpy/Bookmarks/BookmarkMargin.cs
  3. 10
      ILSpy/TextView/DecompilerTextView.axaml.cs

76
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"); 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<BookmarkManager>();
manager.Clear();
var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().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");
}
} }

52
ILSpy/Bookmarks/BookmarkMargin.cs

@ -24,6 +24,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using AvaloniaEdit.Editing; using AvaloniaEdit.Editing;
@ -40,6 +41,9 @@ namespace ICSharpCode.ILSpy.Bookmarks
public sealed class BookmarkMargin : AbstractMargin public sealed class BookmarkMargin : AbstractMargin
{ {
const double IconSize = 16; 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 FallbackBackground = new SolidColorBrush(Color.FromRgb(0xF3, 0xF0, 0xD0));
static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3)); static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3));
// One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs. // One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs.
@ -54,6 +58,8 @@ namespace ICSharpCode.ILSpy.Bookmarks
bool subscribedToManager; bool subscribedToManager;
int pulseLine = -1; int pulseLine = -1;
int hoverLine = -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) public BookmarkMargin(TextView.DecompilerTextView owner)
{ {
@ -167,8 +173,8 @@ namespace ICSharpCode.ILSpy.Bookmarks
if (isHoverPreview) if (isHoverPreview)
{ {
using (drawingContext.PushOpacity(0.35)) using (drawingContext.PushOpacity(HoverPreviewOpacity))
drawingContext.DrawImage(glyph, rect); drawingContext.DrawImage(HoverPreviewGlyph(), rect);
continue; 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) void DrawBackground(DrawingContext drawingContext)
{ {
// The filled background keeps the empty gutter hit-testable so the first click can // 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); var visualLine = textView.GetVisualLineFromVisualTop(y);
if (visualLine == null) if (visualLine == null)
return; 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(); InvalidateVisual();
e.Handled = true; e.Handled = true;
} }
@ -238,12 +270,23 @@ namespace ICSharpCode.ILSpy.Bookmarks
if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber))
newHoverLine = 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); SetHoverLine(newHoverLine);
} }
protected override void OnPointerExited(PointerEventArgs e) protected override void OnPointerExited(PointerEventArgs e)
{ {
base.OnPointerExited(e); base.OnPointerExited(e);
suppressedHoverLine = -1;
SetHoverLine(-1); SetHoverLine(-1);
} }
@ -254,5 +297,8 @@ namespace ICSharpCode.ILSpy.Bookmarks
hoverLine = line; hoverLine = line;
InvalidateVisual(); InvalidateVisual();
} }
// The line currently drawing a hover-preview glyph, or -1 when none. Exposed for tests.
internal int HoverPreviewLine => hoverLine;
} }
} }

10
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -715,11 +715,15 @@ namespace ICSharpCode.ILSpy.TextView
return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber);
} }
/// <summary>Adds or removes a bookmark on <paramref name="line"/>; a no-op for non-anchorable lines.</summary> /// <summary>
internal void ToggleBookmarkAtLine(int line) /// Adds or removes a bookmark on <paramref name="line"/>; 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.
/// </summary>
internal bool ToggleBookmarkAtLine(int line)
{ {
if (CreateBookmarkForLine(line) is { } candidate) if (CreateBookmarkForLine(line) is { } candidate)
BookmarkManager?.Toggle(candidate); return BookmarkManager?.Toggle(candidate) ?? false;
return false;
} }
internal void ToggleBookmarkAtOffset(int offset) internal void ToggleBookmarkAtOffset(int offset)

Loading…
Cancel
Save