using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Controls;
///
/// The AvaloniaEdit host plumbing shared by the two diff surfaces: the read-only side-by-side
/// and the 3-pane conflict resolver.
///
///
/// Only the boilerplate lives here. The two surfaces are not variants of one control — one is a
/// read-only two-way renderer over aligned diff rows (with filler rows the other has no concept
/// of), the other a three-way editor over a writable result document with anchored regions. Their
/// documents, margins and background renderers therefore stay with their own views.
///
internal static class DiffEditorSetup
{
/// Grammars and themes are process-wide; loading a registry per view would be wasteful.
public static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
public static TextMate.Installation InstallHighlighting(TextEditor editor) =>
editor.InstallTextMate(Registry);
///
/// Points every installation at the grammar for 's extension. No path,
/// no extension or no matching language leaves the editors as plain text.
///
///
/// Both surfaces feed fragments (diff hunks / conflict regions) rather than 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.
///
public static void ApplyGrammar(string? path, params TextMate.Installation?[] installations)
{
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);
foreach (var installation in installations) installation?.SetGrammar(scope);
}
/// Resolves a themed brush, falling back to a literal when the lookup fails.
///
/// Two traps, both silent. It must be TryFindResource (the extension that walks up to
/// Application) and not the TryGetResource instance method, which only sees the
/// control's own Resources. And it must run while is attached —
/// a detached control has no resource parent to walk, so resolving in a constructor freezes
/// every brush on its fallback for good.
///
public static IBrush Brush(Control owner, string key, Color fallback) =>
owner.TryFindResource(key, out var value) && value is IBrush brush
? brush
: new SolidColorBrush(fallback);
/// A minimal for geometry and read-only queries.
public 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;
}
}
///
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
/// the text views own the instance.
///
///
///
/// Reading is driven off rather than the editors'
/// templated ScrollViewers. A TextView exists from construction, whereas the ScrollViewer only
/// materialises once the editor has been measured — so subscribing to the ScrollViewer at load
/// time found nothing whenever a pane started out collapsed, and the sync then stayed dead for
/// the lifetime of the window.
///
///
/// Writing still goes through the ScrollViewer, resolved lazily on first use (by which point the
/// panes are on screen). looks like the obvious
/// call and is silently a no-op in AvaloniaEdit 12 even with the editor templated and
/// TextEditor.ScrollViewer non-null — verified against 12.0.0. Assigning
/// ILogicalScrollable.Offset on the TextView is no good either: it moves the text but
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
///
///
internal sealed class VerticalScrollSync
{
private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers;
private readonly Func _isActive;
private readonly Func _anchorByLine;
private readonly Action? _afterSync;
private bool _syncing;
/// Gate for layouts where the panes are not side by side.
/// True while lines wrap — see .
/// Runs once per user scroll, not per echo.
public VerticalScrollSync(TextEditor[] editors, Func? isActive = null,
Func? anchorByLine = null, Action? afterSync = null)
{
_editors = editors;
_viewers = new ScrollViewer?[editors.Length];
_isActive = isActive ?? (() => true);
_anchorByLine = anchorByLine ?? (() => false);
_afterSync = afterSync;
for (var i = 0; i < editors.Length; i++)
{
var index = i;
editors[i].TextArea.TextView.ScrollOffsetChanged += (_, _) => Sync(index);
}
}
/// Resolved on demand and cached: at construction the editor may still be collapsed and
/// therefore untemplated, but by the time anything scrolls it is on screen.
private ScrollViewer? Viewer(int index) =>
_viewers[index] ??= _editors[index].GetVisualDescendants().OfType().FirstOrDefault();
private void Sync(int sourceIndex)
{
if (_syncing || !_isActive()) return;
_syncing = true;
try
{
for (var i = 0; i < _editors.Length; i++)
{
if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
// cleared again, so this comparison — not the flag — is what ends the loop.
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(viewer.Offset.X, y);
}
}
finally { _syncing = false; }
_afterSync?.Invoke();
}
/// Where has to sit to show the same content as
/// , or null when the two cannot be related right now.
private double? TargetOffset(TextEditor source, TextEditor target)
{
if (!_anchorByLine()) return source.TextArea.TextView.ScrollOffset.Y;
// Line heights differ once lines wrap, so pixel offsets no longer correspond between the
// panes — anchor on the top visible line instead and top-align it by computing the
// target's own vertical offset for that document line. ScrollToLine would not do: it is a
// "bring into view" primitive that parks the line near mid-viewport past a threshold.
if (source.TextArea.TextView.VisualLines is not { Count: > 0 } visualLines) return null;
var line = visualLines[0].FirstDocumentLine.LineNumber;
if (line < 1 || line > target.Document.LineCount) return null;
return target.TextArea.TextView.GetVisualTopByDocumentLine(line);
}
}