407 lines
16 KiB
C#
407 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using AvaloniaEdit;
|
|
using AvaloniaEdit.Document;
|
|
using AvaloniaEdit.Editing;
|
|
using AvaloniaEdit.Rendering;
|
|
using AvaloniaEdit.TextMate;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using TextMateSharp.Grammars;
|
|
|
|
namespace ClaudeDo.Ui.Views.Controls;
|
|
|
|
/// <summary>
|
|
/// Read-only diff renderer. <see cref="IsSplit"/> 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
|
|
/// <see cref="DiffAlignment"/>. Syntax highlighting comes from TextMate, keyed off the file's
|
|
/// extension — the same mechanism the 3-pane conflict resolver uses.
|
|
/// </summary>
|
|
public partial class DiffTextView : UserControl
|
|
{
|
|
public static readonly StyledProperty<DiffFileViewModel?> FileProperty =
|
|
AvaloniaProperty.Register<DiffTextView, DiffFileViewModel?>(nameof(File));
|
|
|
|
public static readonly StyledProperty<bool> IsSplitProperty =
|
|
AvaloniaProperty.Register<DiffTextView, bool>(nameof(IsSplit));
|
|
|
|
public static readonly StyledProperty<bool> WrapLinesProperty =
|
|
AvaloniaProperty.Register<DiffTextView, bool>(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);
|
|
}
|
|
|
|
// Grammars and themes are process-wide; loading the registry per control would be wasteful.
|
|
private static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
|
|
|
|
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<RowInfo?>();
|
|
private RowInfo?[] _rightRows = Array.Empty<RowInfo?>();
|
|
|
|
private AlignedDiff _aligned = AlignedDiff.Empty;
|
|
|
|
private DiffLineNumberMargin? _leftMargin, _rightMargin;
|
|
|
|
private ScrollViewer? _leftScroll, _rightScroll;
|
|
private bool _scrollHooked;
|
|
private bool _syncing;
|
|
|
|
public DiffTextView()
|
|
{
|
|
InitializeComponent();
|
|
_leftTm = LeftEditor.InstallTextMate(Registry);
|
|
_rightTm = RightEditor.InstallTextMate(Registry);
|
|
InstallRenderers();
|
|
Rebuild();
|
|
HookScrollSync();
|
|
}
|
|
|
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
{
|
|
base.OnPropertyChanged(change);
|
|
if (change.Property == FileProperty || change.Property == IsSplitProperty)
|
|
Rebuild();
|
|
else if (change.Property == WrapLinesProperty)
|
|
ApplyWrap();
|
|
}
|
|
|
|
private void Rebuild()
|
|
{
|
|
_aligned = DiffAlignment.Build(File?.Lines);
|
|
|
|
var split = IsSplit;
|
|
RightEditor.IsVisible = split;
|
|
PaneDivider.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<RowInfo?>();
|
|
}
|
|
|
|
RebuildMargins();
|
|
ApplyWrap();
|
|
ApplyGrammar(File?.Path);
|
|
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<int, RowInfo> 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;
|
|
}
|
|
|
|
/// Only hunks are in the document, not whole files, so TextMate's line-by-line state can
|
|
/// be wrong at a fragment boundary (a line inside a block comment may highlight as code).
|
|
/// Accepted — every fragment-based diff viewer has this.
|
|
private void ApplyGrammar(string? path)
|
|
{
|
|
if (string.IsNullOrEmpty(path)) return;
|
|
var ext = Path.GetExtension(path);
|
|
if (string.IsNullOrEmpty(ext)) return;
|
|
var language = Registry.GetLanguageByExtension(ext);
|
|
if (language is null) return;
|
|
var scope = Registry.GetScopeByLanguageId(language.Id);
|
|
_leftTm?.SetGrammar(scope);
|
|
_rightTm?.SetGrammar(scope);
|
|
}
|
|
|
|
/// 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) =>
|
|
this.TryGetResource(key, ActualThemeVariant, out var value) && value is IBrush brush
|
|
? 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();
|
|
}
|
|
|
|
/// The editors' ScrollViewers only exist once the template has been applied.
|
|
private void HookScrollSync()
|
|
{
|
|
if (_scrollHooked) return;
|
|
_scrollHooked = true;
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
_leftScroll = LeftEditor.FindDescendantOfType<ScrollViewer>();
|
|
_rightScroll = RightEditor.FindDescendantOfType<ScrollViewer>();
|
|
if (_leftScroll is not null) _leftScroll.ScrollChanged += OnPaneScroll;
|
|
if (_rightScroll is not null) _rightScroll.ScrollChanged += OnPaneScroll;
|
|
}, DispatcherPriority.Loaded);
|
|
}
|
|
|
|
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
|
|
{
|
|
if (_syncing || !IsSplit || _leftScroll is null || _rightScroll is null) return;
|
|
var fromLeft = ReferenceEquals(sender, _leftScroll);
|
|
var source = fromLeft ? _leftScroll : _rightScroll;
|
|
var target = fromLeft ? _rightScroll : _leftScroll;
|
|
var targetEditor = fromLeft ? RightEditor : LeftEditor;
|
|
var sourceEditor = fromLeft ? LeftEditor : RightEditor;
|
|
|
|
_syncing = true;
|
|
try
|
|
{
|
|
if (WrapLines)
|
|
{
|
|
// Line heights differ once lines wrap, so anchor on the top visible line.
|
|
var topLine = sourceEditor.TextArea.TextView.VisualLines is { Count: > 0 } visualLines
|
|
? visualLines[0].FirstDocumentLine.LineNumber
|
|
: (int?)null;
|
|
if (topLine is { } line && line >= 1 && line <= targetEditor.Document.LineCount)
|
|
targetEditor.ScrollToLine(line);
|
|
}
|
|
else if (Math.Abs(target.Offset.Y - source.Offset.Y) > 0.5)
|
|
{
|
|
target.Offset = new Vector(target.Offset.X, source.Offset.Y);
|
|
}
|
|
}
|
|
finally { _syncing = false; }
|
|
}
|
|
|
|
/// <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);
|
|
|
|
/// <summary>Draws one or two gutter columns of diff line numbers from the row lookup.</summary>
|
|
private sealed class DiffLineNumberMargin : AbstractMargin
|
|
{
|
|
private const double ColumnWidth = 44;
|
|
private const double Gap = 6;
|
|
|
|
private readonly Func<int, RowInfo?> _rows;
|
|
private readonly bool _showOld;
|
|
private readonly bool _showNew;
|
|
private readonly IBrush _foreground;
|
|
private readonly Typeface _typeface;
|
|
private readonly double _fontSize;
|
|
|
|
public DiffLineNumberMargin(Func<int, RowInfo?> 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));
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|