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(); } } }