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; + } }