Three things were wrong with the fresh side-by-side viewer: - The panes scrolled independently. The sync hunted the editors' templated ScrollViewers at load time, but the viewer starts collapsed (no file selected), so nothing was ever measured, no template existed, and the hook silently no-opped for the lifetime of the window. Reading now goes through TextView.ScrollOffsetChanged, which exists from construction; writing goes through a lazily resolved ScrollViewer.Offset. TextEditor.ScrollToVerticalOffset is NOT usable here - it is a silent no-op in AvaloniaEdit 12.0.0 even with the editor templated (verified headlessly). - Neither pane said what it showed. Added a BASE | WORKTREE header row inside DiffTextView, sharing the pane grid's columns so each label sits over its editor. - The layout picker was two lookalike ToggleButtons. It is now a segmented switch (Border.segmented + Button.segment.active), with wrap demoted to an icon toggle since it is orthogonal to the layout mode. The scroll sync, TextMate setup, grammar switching, brush fallback and segment struct were duplicated between this control and the 3-pane conflict resolver, which the design doc had used as a copy-paste template. They now live in DiffEditorSetup; both surfaces migrated. They stay separate controls on purpose - a read-only two-way renderer over aligned rows with filler lines is not a variant of a three-way editor over a writable document. The resolver thereby also picks up the fixed scroll sync and the TryFindResource brush lookup (its TryGetResource never resolved anything). All four merge tokens happen to equal their hardcoded fallbacks, so nothing changes on screen.
353 lines
14 KiB
C#
353 lines
14 KiB
C#
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;
|
|
|
|
/// <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);
|
|
}
|
|
|
|
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 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 <see cref="File"/> 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 <see cref="_aligned"/> to the editors for the current layout
|
|
/// (split vs unified). A single <see cref="AlignedDiff"/> already carries both row sets,
|
|
/// so toggling <see cref="IsSplit"/> 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<RowInfo?>();
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|