diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
index 7553daf9..f896c5a3 100644
--- a/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
+++ b/src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs
@@ -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();
+ }
+
/// What one document line represents, for the margin and the background renderers.
internal sealed record RowInfo(
AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList Spans);
@@ -245,4 +269,88 @@ public partial class DiffTextView : UserControl
context.DrawText(text, new Point(x, y));
}
}
+
+ /// Tints whole rows by their diff role.
+ private sealed class DiffLineRenderer : IBackgroundRenderer
+ {
+ private readonly Func _rows;
+ private readonly IBrush _add, _del, _filler, _gap;
+
+ public DiffLineRenderer(Func 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));
+ }
+ }
+ }
+
+ /// Tints the changed character ranges inside a row, on top of the row tint.
+ private sealed class WordDiffRenderer : IBackgroundRenderer
+ {
+ private readonly Func _rows;
+ private readonly IBrush _add, _del;
+
+ public WordDiffRenderer(Func 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);
+ }
+ }
+ }
+ }
+
+ /// A minimal for geometry queries.
+ 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;
+ }
}