feat(diff): tint diff rows and changed words via background renderers

This commit is contained in:
mika kuns
2026-08-07 09:36:37 +02:00
parent e299421f76
commit 861ba12f7f
@@ -6,6 +6,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
@@ -68,6 +69,7 @@ public partial class DiffTextView : UserControl
InitializeComponent();
_leftTm = LeftEditor.InstallTextMate(Registry);
_rightTm = RightEditor.InstallTextMate(Registry);
InstallRenderers();
Rebuild();
}
@@ -113,6 +115,7 @@ public partial class DiffTextView : UserControl
RebuildMargins();
ApplyWrap();
ApplyGrammar(File?.Path);
InvalidateRenderers();
}
/// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the
@@ -175,6 +178,27 @@ public partial class DiffTextView : UserControl
? brush
: new SolidColorBrush(fallback);
private void InstallRenderers()
{
var add = Brush("RunningTintBrush", Color.Parse("#1F7C9166"));
var del = Brush("ErrorTintBrush", Color.Parse("#1FC87060"));
var filler = Brush("DiffFillerBrush", Color.Parse("#0AFFFFFF"));
var gap = Brush("DiffGapBrush", Color.Parse("#14FFFFFF"));
var wordAdd = Brush("DiffWordAddBrush", Color.Parse("#556FA86B"));
var wordDel = Brush("DiffWordDelBrush", Color.Parse("#55C87060"));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(LeftRow, add, del, filler, gap));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(LeftRow, wordAdd, wordDel));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(RightRow, add, del, filler, gap));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(RightRow, wordAdd, wordDel));
}
private void InvalidateRenderers()
{
LeftEditor.TextArea.TextView.InvalidateVisual();
RightEditor.TextArea.TextView.InvalidateVisual();
}
/// <summary>What one document line represents, for the margin and the background renderers.</summary>
internal sealed record RowInfo(
AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList<TextSpan> Spans);
@@ -245,4 +269,88 @@ public partial class DiffTextView : UserControl
context.DrawText(text, new Point(x, y));
}
}
/// <summary>Tints whole rows by their diff role.</summary>
private sealed class DiffLineRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del, _filler, _gap;
public DiffLineRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del, IBrush filler, IBrush gap)
{
_rows = rows; _add = add; _del = del; _filler = filler; _gap = gap;
}
public KnownLayer Layer => KnownLayer.Background;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var row = _rows(visualLine.FirstDocumentLine.LineNumber);
var brush = row?.Kind switch
{
AlignedSide.Add => _add,
AlignedSide.Del => _del,
AlignedSide.Filler => _filler,
AlignedSide.Gap => _gap,
_ => null,
};
if (brush is null) continue;
var top = visualLine.VisualTop - textView.ScrollOffset.Y;
drawingContext.FillRectangle(brush, new Rect(0, top, textView.Bounds.Width, visualLine.Height));
}
}
}
/// <summary>Tints the changed character ranges inside a row, on top of the row tint.</summary>
private sealed class WordDiffRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del;
public WordDiffRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del)
{
_rows = rows; _add = add; _del = del;
}
// Above the line tint but still behind the text.
public KnownLayer Layer => KnownLayer.Selection;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var documentLine = visualLine.FirstDocumentLine;
var row = _rows(documentLine.LineNumber);
if (row is null || row.Spans.Count == 0) continue;
var brush = row.Kind == AlignedSide.Add ? _add : _del;
foreach (var span in row.Spans)
{
var offset = documentLine.Offset + span.Start;
// Spans are computed against the row text; clamp in case the document
// and the row lookup ever disagree rather than drawing past the line.
if (offset < documentLine.Offset || offset + span.Length > documentLine.EndOffset) continue;
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
builder.AddSegment(textView, new Seg(offset, span.Length));
if (builder.CreateGeometry() is { } geometry)
drawingContext.DrawGeometry(brush, null, geometry);
}
}
}
}
/// <summary>A minimal <see cref="ISegment"/> for geometry queries.</summary>
private readonly struct Seg : ISegment
{
public Seg(int offset, int length) { Offset = offset; Length = length; }
public int Offset { get; }
public int Length { get; }
public int EndOffset => Offset + Length;
}
}