VerticalScrollSync mirrort nur noch Y, X blieb pro Pane unabhaengig. Beide Panes nutzen dieselbe Schrift/Breite, also X 1:1 mitspiegeln statt wie bei Y ueber TargetOffset umzurechnen. Die Echo-Abbruchbedingung prueft jetzt beide Achsen (vorher nur Y), sonst wuerde der neue X-Schreibvorgang seine eigene Echo-Bedingung nie erfuellen und endlos weiterlaufen. Klasse in ScrollSync umbenannt (DiffTextView + ConflictResolverView mitgezogen), da sie nicht mehr nur vertikal synchronisiert. Kein Headless-Test hinzugefuegt: DiffDispatcherGuardTests.cs dokumentiert bereits, dass ein bootstrapptes Avalonia Application/Dispatcher in ClaudeDo.Ui.Tests nach drei eskalierenden Versuchen als Sackgasse verifiziert wurde (Dispatcher-Thread-Affinitaet kollidiert mit xUnits Worker-Pool ohne Thread-Garantie; Avalonia.Headless.XUnit kollidiert mit xunit 2.9.3). Als Finding avaloniaedit-ui-test-dead-end festgehalten. Sichtpruefung bleibt offen (Side-by-side + Merge-Editor mit ueberbreiten Zeilen).
176 lines
8.3 KiB
C#
176 lines
8.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The AvaloniaEdit host plumbing shared by the two diff surfaces: the read-only side-by-side
|
|
/// <see cref="DiffTextView"/> and the 3-pane conflict resolver.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Points every installation at the grammar for <paramref name="path"/>'s extension. No path,
|
|
/// no extension or no matching language leaves the editors as plain text.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Resolves a themed brush, falling back to a literal when the lookup fails.</summary>
|
|
/// <remarks>
|
|
/// Two traps, both silent. It must be <c>TryFindResource</c> (the extension that walks up to
|
|
/// Application) and not the <c>TryGetResource</c> instance method, which only sees the
|
|
/// control's own Resources. And it must run while <paramref name="owner"/> is attached —
|
|
/// a detached control has no resource parent to walk, so resolving in a constructor freezes
|
|
/// every brush on its fallback for good.
|
|
/// </remarks>
|
|
public static IBrush Brush(Control owner, string key, Color fallback) =>
|
|
owner.TryFindResource(key, out var value) && value is IBrush brush
|
|
? brush
|
|
: new SolidColorBrush(fallback);
|
|
|
|
/// <summary>A minimal <see cref="ISegment"/> for geometry and read-only queries.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps a set of editors scrolled to the same position, vertically always and horizontally
|
|
/// whenever the panes share the same content width (no per-pane offset conversion, unlike
|
|
/// <see cref="TargetOffset"/> for Y). 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Reading is driven off <see cref="TextView.ScrollOffsetChanged"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Writing still goes through the ScrollViewer, resolved lazily on first use (by which point the
|
|
/// panes are on screen). <see cref="TextEditor.ScrollToVerticalOffset"/> looks like the obvious
|
|
/// call and is <b>silently a no-op</b> in AvaloniaEdit 12 even with the editor templated and
|
|
/// <c>TextEditor.ScrollViewer</c> non-null — verified against 12.0.0. Assigning
|
|
/// <c>ILogicalScrollable.Offset</c> on the TextView is no good either: it moves the text but
|
|
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class ScrollSync
|
|
{
|
|
private readonly TextEditor[] _editors;
|
|
private readonly ScrollViewer?[] _viewers;
|
|
private readonly Func<bool> _isActive;
|
|
private readonly Func<bool> _anchorByLine;
|
|
private readonly Action? _afterSync;
|
|
private bool _syncing;
|
|
|
|
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
|
|
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
|
|
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
|
|
public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
|
|
Func<bool>? 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<ScrollViewer>().FirstOrDefault();
|
|
|
|
private void Sync(int sourceIndex)
|
|
{
|
|
if (_syncing || !_isActive()) return;
|
|
|
|
_syncing = true;
|
|
try
|
|
{
|
|
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
|
|
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. Both
|
|
// axes must be checked: writing only when Y moved left X's own echo unguarded,
|
|
// which is how it looped forever once X was written below.
|
|
if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5)
|
|
viewer.Offset = new Vector(x, y);
|
|
}
|
|
}
|
|
finally { _syncing = false; }
|
|
|
|
_afterSync?.Invoke();
|
|
}
|
|
|
|
/// Where <paramref name="target"/> has to sit to show the same content as
|
|
/// <paramref name="source"/>, 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);
|
|
}
|
|
}
|