Browse Source

Caret-highlight adorner on in-document jumps

CaretHighlightAdorner is an IBackgroundRenderer on KnownLayer.Caret with a
hand-rolled animation curve (Stopwatch + DispatcherTimer at ~60fps invalidating
the layer) instead of WPF's RectAnimation/BeginAnimation, since AvaloniaEdit
has no XAML-style animation primitive for ad-hoc visuals. Same timing as WPF
(300ms grow, 300ms shrink, opacity fade 450-650ms, 1s lifetime).

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
bb3f522e3a
  1. 83
      ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs
  2. 132
      ILSpy/TextView/CaretHighlightAdorner.cs
  3. 4
      ILSpy/TextView/DecompilerTextView.axaml.cs

83
ILSpy.Tests/Editor/CaretHighlightAdornerTests.cs

@ -0,0 +1,83 @@ @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
vm.AssemblyTreeModel.SelectNode(method);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
var editor = await view.WaitForComponent<AvaloniaEdit.TextEditor>();
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));
}
}

132
ILSpy/TextView/CaretHighlightAdorner.cs

@ -0,0 +1,132 @@ @@ -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
{
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// Registers a one-shot caret-highlight animation on <paramref name="textArea"/>. Spins
/// up two timers: one ticks at ~60 fps to invalidate the Caret layer so <see cref="Draw"/>
/// re-runs with fresh elapsed time, the other unregisters the adorner after one second.
/// </summary>
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();
}
}
}

4
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -343,6 +343,10 @@ namespace ILSpy.TextView @@ -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;
}

Loading…
Cancel
Save