diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
index c74dcee4f..c80cc3fe2 100644
--- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
+++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
@@ -21,7 +21,13 @@ using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
+using Avalonia.Headless;
using Avalonia.Headless.NUnit;
+using Avalonia.Threading;
+
+using AvaloniaEdit;
+using AvaloniaEdit.Document;
+using AvaloniaEdit.Rendering;
using AwesomeAssertions;
@@ -111,4 +117,70 @@ public class CaretHighlightAdornerTests
renderers.Should().NotContain(r => r is CaretHighlightAdorner,
"Dismiss unregisters the caret-highlight adorner from the text view");
}
+
+ ///
+ /// Counts how often AvaloniaEdit repaints the caret layer: background renderers are drawn
+ /// by the layer's Render pass, so a Draw call here means the layer visual was invalidated
+ /// and re-rendered.
+ ///
+ sealed class CaretLayerRepaintProbe : IBackgroundRenderer
+ {
+ public int DrawCount;
+ public KnownLayer Layer => KnownLayer.Caret;
+ public void Draw(AvaloniaEdit.Rendering.TextView textView, global::Avalonia.Media.DrawingContext drawingContext)
+ => DrawCount++;
+ }
+
+ static void PumpRender()
+ {
+ Dispatcher.UIThread.RunJobs();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ // Regression test: while the editor is unfocused (the debug-steps navigation case — focus
+ // stays in the Debug Steps panel), the caret is hidden and never blinks, so nothing else
+ // repaints the caret layer. TextView.InvalidateLayer only invalidates measure and never
+ // reaches the caret layer's own visual, so the adorner must invalidate the layer visuals
+ // itself — both to animate at all and, crucially, to erase the last painted frame on
+ // Dismiss. Without that, the final frame stays composited as a caret-sized black line
+ // fixed in the viewport until some unrelated action repaints the layer.
+ [AvaloniaTest]
+ public void Highlight_Start_And_Dismiss_Repaint_The_Caret_Layer_While_The_Editor_Is_Unfocused()
+ {
+ var editor = new TextEditor {
+ Document = new TextDocument("line one\nline two\nline three")
+ };
+ var window = new Window { Content = editor };
+ window.Show();
+ window.UpdateLayout();
+ editor.TextArea.TextView.EnsureVisualLines();
+
+ var probe = new CaretLayerRepaintProbe();
+ editor.TextArea.TextView.BackgroundRenderers.Add(probe);
+ PumpRender();
+
+ // Sanity check that the probe measures dirtiness, not a repaint-everything loop: an
+ // idle render tick must not repaint the caret layer, or the assertions below would
+ // pass vacuously.
+ int baseline = probe.DrawCount;
+ PumpRender();
+ probe.DrawCount.Should().Be(baseline,
+ "an idle render tick must not repaint the caret layer; the probe would be useless otherwise");
+
+ // The editor is deliberately NOT focused: the hidden caret never blinks, so the layer
+ // only repaints if the adorner invalidates it.
+ CaretHighlightAdorner.DisplayCaretHighlightAnimation(editor.TextArea);
+ PumpRender();
+ probe.DrawCount.Should().BeGreaterThan(baseline,
+ "starting the caret-highlight animation must repaint the caret layer even when the editor is unfocused");
+
+ var adorner = editor.TextArea.TextView.BackgroundRenderers
+ .OfType().Single();
+ baseline = probe.DrawCount;
+ adorner.Dismiss();
+ PumpRender();
+ probe.DrawCount.Should().BeGreaterThan(baseline,
+ "dismissing the adorner must repaint the caret layer so its last painted frame is erased");
+ }
}
diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs
index 71bc804ed..bf9f61a20 100644
--- a/ILSpy/TextView/CaretHighlightAdorner.cs
+++ b/ILSpy/TextView/CaretHighlightAdorner.cs
@@ -55,10 +55,11 @@ namespace ICSharpCode.ILSpy.TextView
{
this.textArea = textArea;
- // Caret rect is in document coordinates; subtracting ScrollOffset converts to the
- // viewport-relative space IBackgroundRenderer.Draw paints into.
+ // The rects are kept in document coordinates and translated by the live ScrollOffset
+ // on every Draw, so the highlight sticks to its text through any scrolling during the
+ // animation — in particular the deferred CenterLineInView post that debug-step
+ // navigation issues after starting the highlight.
var caretRect = textArea.Caret.CalculateCaretRectangle();
- caretRect = caretRect.Translate(-textArea.TextView.ScrollOffset);
minRect = caretRect;
double growBy = Math.Max(caretRect.Width, caretRect.Height) * 0.25;
@@ -70,14 +71,28 @@ namespace ICSharpCode.ILSpy.TextView
var brush = textArea.TextView.GetValue(TextElement.ForegroundProperty) ?? Brushes.Black;
pen = new Pen(brush, 1).ToImmutable();
- // The frame timer ticks at ~60 fps to invalidate the Caret layer so Draw re-runs with
+ // The frame timer ticks at ~60 fps to repaint the Caret layer so Draw re-runs with
// fresh elapsed time; the lifetime timer dismisses the adorner after one second.
frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
- frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret);
+ frameTimer.Tick += (_, _) => InvalidateHostLayer();
lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) };
lifetimeTimer.Tick += (_, _) => Dismiss();
}
+ ///
+ /// Repaints the layer visual hosting this renderer. TextView.InvalidateLayer only
+ /// invalidates the TextView's measure (AvaloniaEdit 12.0.0) and never re-renders the
+ /// per-layer child controls, and the caret layer otherwise repaints only on caret
+ /// blinks (focused editor) or scroll changes — with focus elsewhere (e.g. the Debug
+ /// Steps panel) a painted frame would stay on screen indefinitely. The caret layer
+ /// control is internal to AvaloniaEdit, so every layer is asked to repaint.
+ ///
+ void InvalidateHostLayer()
+ {
+ foreach (var layer in textArea.TextView.Layers)
+ layer.InvalidateVisual();
+ }
+
public KnownLayer Layer => KnownLayer.Caret;
public void Draw(AvaloniaEdit.Rendering.TextView textView, DrawingContext drawingContext)
@@ -86,7 +101,7 @@ namespace ICSharpCode.ILSpy.TextView
if (ms >= LifetimeMs)
return;
- // Bounce: 0..GrowDurationMs grows from min → max, GrowDurationMs..2× shrinks back.
+ // Bounce: 0..GrowDurationMs grows from min -> max, GrowDurationMs..2x shrinks back.
Rect rect;
if (ms < GrowDurationMs)
rect = Lerp(minRect, maxRect, ms / (double)GrowDurationMs);
@@ -95,6 +110,10 @@ namespace ICSharpCode.ILSpy.TextView
else
rect = minRect;
+ // minRect/maxRect are in document coordinates; Draw paints in viewport-relative
+ // space, so translate by the current scroll offset each frame.
+ rect = rect.Translate(-textView.ScrollOffset);
+
// Opacity holds at 1.0 until FadeBeginMs, then linear ramp to 0 over FadeDurationMs.
double opacity;
if (ms < FadeBeginMs)
@@ -129,6 +148,9 @@ namespace ICSharpCode.ILSpy.TextView
textArea.TextView.BackgroundRenderers.Add(adorner);
adorner.frameTimer.Start();
adorner.lifetimeTimer.Start();
+ // Paint the first frame right away instead of waiting for the first timer tick;
+ // registration alone does not repaint the caret layer (see InvalidateHostLayer).
+ adorner.InvalidateHostLayer();
}
///
@@ -141,6 +163,10 @@ namespace ICSharpCode.ILSpy.TextView
lifetimeTimer.Stop();
frameTimer.Stop();
textArea.TextView.BackgroundRenderers.Remove(this);
+ // Unregistering does not repaint the caret layer on its own; without an explicit
+ // repaint the last painted frame would stay visible until the caret blinks or the
+ // view scrolls (with focus in another panel: indefinitely).
+ InvalidateHostLayer();
}
}
}