using System; using System.Collections.Generic; using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Media; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using AvaloniaEdit.TextMate; using ClaudeDo.Ui.ViewModels.Modals; using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg; namespace ClaudeDo.Ui.Views.Controls; /// /// Read-only diff renderer. false shows one editor with the unified /// stream; true shows the old state on the left and the new state on the right, aligned by /// . Syntax highlighting comes from TextMate, keyed off the file's /// extension — the same mechanism the 3-pane conflict resolver uses. /// public partial class DiffTextView : UserControl { public static readonly StyledProperty FileProperty = AvaloniaProperty.Register(nameof(File)); public static readonly StyledProperty IsSplitProperty = AvaloniaProperty.Register(nameof(IsSplit)); public static readonly StyledProperty WrapLinesProperty = AvaloniaProperty.Register(nameof(WrapLines)); public DiffFileViewModel? File { get => GetValue(FileProperty); set => SetValue(FileProperty, value); } public bool IsSplit { get => GetValue(IsSplitProperty); set => SetValue(IsSplitProperty, value); } public bool WrapLines { get => GetValue(WrapLinesProperty); set => SetValue(WrapLinesProperty, value); } private TextMate.Installation? _leftTm, _rightTm; // Row lookup per editor, indexed by document line number (1-based). Populated on rebuild // and consumed by the margin and background renderers added in later tasks. private RowInfo?[] _leftRows = Array.Empty(); private RowInfo?[] _rightRows = Array.Empty(); private AlignedDiff _aligned = AlignedDiff.Empty; private DiffLineNumberMargin? _leftMargin, _rightMargin; private bool _renderersInstalled; public DiffTextView() { InitializeComponent(); _leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor); _rightTm = DiffEditorSetup.InstallHighlighting(RightEditor); ReloadFile(); new VerticalScrollSync(new[] { LeftEditor, RightEditor }, isActive: () => IsSplit, anchorByLine: () => WrapLines); } /// Brushes and the mono typeface only resolve once the control is in the visual tree — /// a detached control has no resource parent to walk up to, and its styles haven't been /// applied yet. Installing the renderers in the constructor would freeze them on their /// hardcoded fallbacks for good, since they capture their brushes once. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); if (_renderersInstalled) return; _renderersInstalled = true; InstallRenderers(); RebuildMargins(); InvalidateRenderers(); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == FileProperty) ReloadFile(); else if (change.Property == IsSplitProperty) RefreshLayout(); else if (change.Property == WrapLinesProperty) ApplyWrap(); } /// Recomputes the alignment (word-diff LCS) for the current and then /// refreshes everything downstream of it. Only ever needed when the file actually changes. private void ReloadFile() { _aligned = DiffAlignment.Build(File?.Lines); RefreshLayout(); } /// Re-applies the current to the editors for the current layout /// (split vs unified). A single already carries both row sets, /// so toggling never needs to re-run the alignment. private void RefreshLayout() { var split = IsSplit; RightEditor.IsVisible = split; PaneDivider.IsVisible = split; PaneHeader.IsVisible = split; Grid.SetColumnSpan(LeftEditor, split ? 1 : 3); if (split) { LeftEditor.Text = _aligned.LeftText; RightEditor.Text = _aligned.RightText; _leftRows = BuildRows(_aligned.SplitRows.Count, i => new RowInfo(_aligned.SplitRows[i].LeftKind, _aligned.SplitRows[i].OldNo, null, _aligned.SplitRows[i].LeftSpans)); _rightRows = BuildRows(_aligned.SplitRows.Count, i => new RowInfo(_aligned.SplitRows[i].RightKind, null, _aligned.SplitRows[i].NewNo, _aligned.SplitRows[i].RightSpans)); } else { LeftEditor.Text = _aligned.UnifiedText; RightEditor.Text = ""; _leftRows = BuildRows(_aligned.UnifiedRows.Count, i => new RowInfo(_aligned.UnifiedRows[i].Kind, _aligned.UnifiedRows[i].OldNo, _aligned.UnifiedRows[i].NewNo, _aligned.UnifiedRows[i].Spans)); _rightRows = Array.Empty(); } RebuildMargins(); ApplyWrap(); DiffEditorSetup.ApplyGrammar(File?.Path, _leftTm, _rightTm); InvalidateRenderers(); } /// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the /// line number straight through. private static RowInfo?[] BuildRows(int count, Func project) { var rows = new RowInfo?[count + 1]; for (var i = 0; i < count; i++) rows[i + 1] = project(i); return rows; } private RowInfo? LeftRow(int line) => line > 0 && line < _leftRows.Length ? _leftRows[line] : null; private RowInfo? RightRow(int line) => line > 0 && line < _rightRows.Length ? _rightRows[line] : null; private void ApplyWrap() { LeftEditor.WordWrap = WrapLines; RightEditor.WordWrap = WrapLines; } /// The margin's column layout depends on split vs unified, so it is rebuilt rather than /// reconfigured whenever the layout changes. private void RebuildMargins() { if (_leftMargin is not null) LeftEditor.TextArea.LeftMargins.Remove(_leftMargin); if (_rightMargin is not null) RightEditor.TextArea.LeftMargins.Remove(_rightMargin); var foreground = Brush("TextFaintBrush", Color.Parse("#80FFFFFF")); var typeface = new Typeface(LeftEditor.FontFamily); _leftMargin = new DiffLineNumberMargin(LeftRow, showOld: true, showNew: !IsSplit, foreground, typeface, LeftEditor.FontSize); LeftEditor.TextArea.LeftMargins.Insert(0, _leftMargin); _rightMargin = new DiffLineNumberMargin(RightRow, showOld: false, showNew: true, foreground, typeface, RightEditor.FontSize); RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin); } private IBrush Brush(string key, Color fallback) => DiffEditorSetup.Brush(this, key, 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); /// Draws one or two gutter columns of diff line numbers from the row lookup. private sealed class DiffLineNumberMargin : AbstractMargin { private const double ColumnWidth = 44; private const double Gap = 6; private readonly Func _rows; private readonly bool _showOld; private readonly bool _showNew; private readonly IBrush _foreground; private readonly Typeface _typeface; private readonly double _fontSize; public DiffLineNumberMargin(Func rows, bool showOld, bool showNew, IBrush foreground, Typeface typeface, double fontSize) { _rows = rows; _showOld = showOld; _showNew = showNew; _foreground = foreground; _typeface = typeface; _fontSize = fontSize; } private double Columns => (_showOld ? 1 : 0) + (_showNew ? 1 : 0); protected override Size MeasureOverride(Size availableSize) => new(Columns * ColumnWidth + Gap, 0); protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTextView) { if (oldTextView is not null) oldTextView.VisualLinesChanged -= OnVisualLinesChanged; base.OnTextViewChanged(oldTextView, newTextView); if (newTextView is not null) newTextView.VisualLinesChanged += OnVisualLinesChanged; InvalidateVisual(); } private void OnVisualLinesChanged(object? sender, EventArgs e) => InvalidateVisual(); public override void Render(DrawingContext context) { var textView = TextView; if (textView is null || !textView.VisualLinesValid) return; foreach (var visualLine in textView.VisualLines) { var lineNumber = visualLine.FirstDocumentLine.LineNumber; if (_rows(lineNumber) is not { } row) continue; var y = visualLine.VisualTop - textView.ScrollOffset.Y; var column = 0; if (_showOld) DrawNumber(context, row.OldNo, column++, y); if (_showNew) DrawNumber(context, row.NewNo, column, y); } } private void DrawNumber(DrawingContext context, int? value, int column, double y) { if (value is null) return; var text = new FormattedText(value.Value.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture, FlowDirection.LeftToRight, _typeface, _fontSize, _foreground); // Right-align inside the column so the digits line up across rows. var x = (column + 1) * ColumnWidth - text.Width - Gap; 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); } } } } }