diff --git a/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs new file mode 100644 index 000000000..6f7448c50 --- /dev/null +++ b/ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.AppEnv; +using ILSpy.AssemblyTree; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class CaretHighlightAdornerTests +{ + [AvaloniaTest] + public async Task DisplayCaretHighlightAnimation_Adds_Then_Removes_Renderer_From_TextView() + { + // Activating the caret-highlight animation must register an IBackgroundRenderer on the + // editor's TextView and remove it when the lifetime timer expires (one second). Verifies + // the lifecycle without asserting visual pixels — the animation curve is hand-tuned and + // best validated by eye, but the registration / cleanup contract is what regressions are + // likely to break. + + // Arrange — boot, select a method so the decompiler view materialises. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.IsExpanded = true; + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + vm.AssemblyTreeModel.SelectNode(method); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + var editor = await view.WaitForComponent(); + var textArea = editor.TextArea; + var renderers = textArea.TextView.BackgroundRenderers; + + // Sanity check — no caret highlight adorner is in flight before we trigger one. + renderers.Should().NotContain(r => r is CaretHighlightAdorner, + "the test starts before any caret highlight is triggered"); + + // Act — trigger the highlight; assert it lands in the renderer list immediately. + CaretHighlightAdorner.DisplayCaretHighlightAnimation(textArea); + renderers.Should().Contain(r => r is CaretHighlightAdorner, + "DisplayCaretHighlightAnimation must register the adorner synchronously"); + + // Wait for the lifetime timer (~1 s) to remove the adorner, with margin. + await Waiters.WaitForAsync( + () => !renderers.Any(r => r is CaretHighlightAdorner), + timeout: TimeSpan.FromSeconds(3)); + } +} diff --git a/ILSpy/TextView/CaretHighlightAdorner.cs b/ILSpy/TextView/CaretHighlightAdorner.cs new file mode 100644 index 000000000..2ca900aba --- /dev/null +++ b/ILSpy/TextView/CaretHighlightAdorner.cs @@ -0,0 +1,132 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; + +using global::Avalonia; +using global::Avalonia.Controls.Documents; +using global::Avalonia.Media; +using global::Avalonia.Threading; + +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace ILSpy.TextView +{ + /// + /// Brief animated rectangle around the caret played after a navigation gesture (clicking + /// a hyperlink that scrolls to a different location, jumping back/forward through history) + /// so the user spots where the caret landed. The rectangle inflates 25% and collapses back + /// over the first 600 ms, then fades out over the following 200 ms; total visible lifetime + /// is one second. + /// + public sealed class CaretHighlightAdorner : IBackgroundRenderer + { + const int GrowDurationMs = 300; + const int FadeBeginMs = 450; + const int FadeDurationMs = 200; + const int LifetimeMs = 1000; + + readonly Rect minRect; + readonly Rect maxRect; + readonly IPen pen; + readonly Stopwatch elapsed = Stopwatch.StartNew(); + + CaretHighlightAdorner(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(); + caretRect = caretRect.Translate(-textArea.TextView.ScrollOffset); + minRect = caretRect; + + double growBy = Math.Max(caretRect.Width, caretRect.Height) * 0.25; + maxRect = caretRect.Inflate(growBy); + + // TextView itself has no Foreground; the inherited TextElement.Foreground attached + // property carries the editor's text colour through the visual tree. Falls back to + // 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(); + } + + public KnownLayer Layer => KnownLayer.Caret; + + public void Draw(AvaloniaEdit.Rendering.TextView textView, DrawingContext drawingContext) + { + long ms = elapsed.ElapsedMilliseconds; + if (ms >= LifetimeMs) + return; + + // Bounce: 0..GrowDurationMs grows from min → max, GrowDurationMs..2× shrinks back. + Rect rect; + if (ms < GrowDurationMs) + rect = Lerp(minRect, maxRect, ms / (double)GrowDurationMs); + else if (ms < GrowDurationMs * 2) + rect = Lerp(maxRect, minRect, (ms - GrowDurationMs) / (double)GrowDurationMs); + else + rect = minRect; + + // Opacity holds at 1.0 until FadeBeginMs, then linear ramp to 0 over FadeDurationMs. + double opacity; + if (ms < FadeBeginMs) + opacity = 1.0; + else if (ms < FadeBeginMs + FadeDurationMs) + opacity = 1.0 - (ms - FadeBeginMs) / (double)FadeDurationMs; + else + opacity = 0; + if (opacity <= 0) + return; + + using var _ = drawingContext.PushOpacity(opacity); + drawingContext.DrawRectangle(null, pen, rect, 2, 2); + } + + static Rect Lerp(Rect a, Rect b, double t) => new( + a.X + (b.X - a.X) * t, + a.Y + (b.Y - a.Y) * t, + a.Width + (b.Width - a.Width) * t, + a.Height + (b.Height - a.Height) * t); + + /// + /// 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. + /// + public static void DisplayCaretHighlightAnimation(TextArea textArea) + { + ArgumentNullException.ThrowIfNull(textArea); + + var adorner = new CaretHighlightAdorner(textArea); + textArea.TextView.BackgroundRenderers.Add(adorner); + + 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(); + } + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 6a08ecb9e..386a75425 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -343,6 +343,10 @@ namespace ILSpy.TextView { Editor.TextArea.Caret.Offset = definitionPos; Editor.TextArea.Caret.BringCaretToView(); + // Brief animated rectangle around the new caret position so the user spots + // where the jump landed — particularly useful for long methods where the + // scroll jump alone isn't enough to anchor attention. + CaretHighlightAdorner.DisplayCaretHighlightAnimation(Editor.TextArea); Dispatcher.UIThread.Post(() => Editor.TextArea.Focus()); return; }