diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs index 75a8bb5a8..929c2f0b0 100644 --- a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -57,8 +57,8 @@ public class CaretHighlightAdornerTests // AvaloniaEditTextOutput.AddReference), so `OnReferenceClicked` resolves the click // to an in-document jump and DecompilerTextView fires the caret highlight. // - // The lifecycle (register then unregister after ~1 s) is verified end-to-end — - // hand-tuned animation curve is left for eyeball validation. + // The lifecycle (register on click, then unregister via Dismiss) is verified end-to-end — + // the hand-tuned animation curve and the one-second timing are left for eyeball validation. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -103,9 +103,12 @@ public class CaretHighlightAdornerTests "clicking a member definition must route through DecompilerTextView.OnReferenceClicked " + "and call CaretHighlightAdorner.DisplayCaretHighlightAnimation"); - // Wait for the lifetime timer (~1 s) to tear it back down. - await Waiters.WaitForAsync( - () => !renderers.Any(r => r is CaretHighlightAdorner), - timeout: TimeSpan.FromSeconds(3)); + // The 1 s lifetime is a real-time DispatcherTimer, which is unreliable to wait on in a + // headless run; invoke the same teardown the timer runs (Dismiss) directly so the + // unregister half is verified deterministically rather than against the wall clock. + var adorner = renderers.OfType().Single(); + adorner.Dismiss(); + renderers.Should().NotContain(r => r is CaretHighlightAdorner, + "Dismiss unregisters the caret-highlight adorner from the text view"); } } diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs index f8f1f064b..a3b3510a3 100644 --- a/ILSpy/TextView/CaretHighlightAdorner.cs +++ b/ILSpy/TextView/CaretHighlightAdorner.cs @@ -47,9 +47,14 @@ namespace ILSpy.TextView readonly Rect maxRect; readonly IPen pen; readonly Stopwatch elapsed = Stopwatch.StartNew(); + readonly TextArea textArea; + readonly DispatcherTimer frameTimer; + readonly DispatcherTimer lifetimeTimer; CaretHighlightAdorner(TextArea textArea) { + this.textArea = textArea; + // Caret rect is in document coordinates; subtracting ScrollOffset converts to the // viewport-relative space IBackgroundRenderer.Draw paints into. var caretRect = textArea.Caret.CalculateCaretRectangle(); @@ -64,6 +69,13 @@ namespace ILSpy.TextView // black when unset (e.g. design-time / standalone-renderer tests). 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 + // 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); + lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; + lifetimeTimer.Tick += (_, _) => Dismiss(); } public KnownLayer Layer => KnownLayer.Caret; @@ -107,7 +119,7 @@ namespace ILSpy.TextView /// /// Registers a one-shot caret-highlight animation on . Spins /// up two timers: one ticks at ~60 fps to invalidate the Caret layer so - /// re-runs with fresh elapsed time, the other unregisters the adorner after one second. + /// re-runs with fresh elapsed time, the other calls after one second. /// public static void DisplayCaretHighlightAnimation(TextArea textArea) { @@ -115,18 +127,20 @@ namespace ILSpy.TextView var adorner = new CaretHighlightAdorner(textArea); textArea.TextView.BackgroundRenderers.Add(adorner); + adorner.frameTimer.Start(); + adorner.lifetimeTimer.Start(); + } - var frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; - frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Caret); - frameTimer.Start(); - - var lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; - lifetimeTimer.Tick += (_, _) => { - lifetimeTimer.Stop(); - frameTimer.Stop(); - textArea.TextView.BackgroundRenderers.Remove(adorner); - }; - lifetimeTimer.Start(); + /// + /// Ends the animation immediately: stops both timers and unregisters the adorner from the + /// text view. Invoked by the one-second lifetime timer, and usable to tear the highlight + /// down on demand instead of waiting the lifetime out. + /// + public void Dismiss() + { + lifetimeTimer.Stop(); + frameTimer.Stop(); + textArea.TextView.BackgroundRenderers.Remove(this); } } }