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