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