Browse Source

Repaint the caret layer when the caret-highlight animation runs and ends

AvaloniaEdit 12's TextView.InvalidateLayer only invalidates the TextView's
measure and never re-renders the per-layer child visuals, so the caret layer
repaints only on caret blinks (focused editor) or scroll changes. During
debug-step navigation focus stays in the Debug Steps panel, so the one scroll
repaint painted an early animation frame and nothing ever erased it: the frame
stayed composited as a caret-sized black line fixed in the viewport. The
adorner now invalidates the layer visuals itself on start, every animation
frame, and dismissal, and keeps its rectangles in document coordinates so the
deferred centering scroll cannot strand the highlight at a stale viewport
position.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/3861/head
Siegfried Pammer 4 days ago committed by Siegfried Pammer
parent
commit
ca1656f01b
  1. 72
      ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
  2. 38
      ILSpy/TextView/CaretHighlightAdorner.cs

72
ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs

@ -21,7 +21,13 @@ using System.Linq; @@ -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 @@ -111,4 +117,70 @@ public class CaretHighlightAdornerTests
renderers.Should().NotContain(r => r is CaretHighlightAdorner,
"Dismiss unregisters the caret-highlight adorner from the text view");
}
/// <summary>
/// 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.
/// </summary>
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<CaretHighlightAdorner>().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");
}
}

38
ILSpy/TextView/CaretHighlightAdorner.cs

@ -55,10 +55,11 @@ namespace ICSharpCode.ILSpy.TextView @@ -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 @@ -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();
}
/// <summary>
/// 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.
/// </summary>
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 @@ -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 @@ -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 @@ -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();
}
/// <summary>
@ -141,6 +163,10 @@ namespace ICSharpCode.ILSpy.TextView @@ -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();
}
}
}

Loading…
Cancel
Save