mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
3 changed files with 219 additions and 0 deletions
@ -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)); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue