From a62f8249ecf8bca4791bc5f7501068c41e09047a Mon Sep 17 00:00:00 2001 From: Gadfly Date: Thu, 20 Aug 2026 18:10:38 +0800 Subject: [PATCH] fix: keep blame metadata aligned while scrolling Blame metadata could drift away from source lines when the first visible commit group started above the viewport. Large groups could also clip their hash at the top or make adjacent labels overlap, while measuring only visible lines could change the margin width during scrolling. Align metadata with AvaloniaEdit line centers, protect the first group only when there is enough room, and measure all blame data consistently so scrolling keeps labels stable and readable. --- src/Views/Blame.axaml.cs | 353 ++++++++++++++++++++++++--------------- 1 file changed, 221 insertions(+), 132 deletions(-) diff --git a/src/Views/Blame.axaml.cs b/src/Views/Blame.axaml.cs index c06121e7d..1bcbb6543 100644 --- a/src/Views/Blame.axaml.cs +++ b/src/Views/Blame.axaml.cs @@ -27,81 +27,196 @@ public CommitInfoMargin(BlameTextEditor editor) ClipToBounds = true; } + private sealed class CommitInfoLink + { + public CommitInfoLink(Models.BlameLineInfo info, FormattedText shaLink, double lineCenter) + { + Info = info; + ShaLink = shaLink; + LineCenter = lineCenter; + } + + public Models.BlameLineInfo Info { get; } + public FormattedText ShaLink { get; } + public double LineCenter { get; } + public double Top { get; set; } + public bool KeepFirstVisible { get; set; } + } + public override void Render(DrawingContext context) { if (_editor.BlameData == null) return; var view = TextView; - if (view is { VisualLinesValid: true }) - { - var typeface = view.CreateTypeface(); - var width = Bounds.Width; - var lineHeight = view.DefaultLineHeight; - var pixelHeight = PixelSnapHelpers.GetPixelSize(view).Height; + if (view is not { VisualLinesValid: true }) + return; - foreach (var line in view.VisualLines) - { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) - continue; + var underlinePen = new Pen(Brushes.DarkOrange); + var width = Bounds.Width; + var pixelHeight = PixelSnapHelpers.GetPixelSize(view).Height; + var typeface = view.CreateTypeface(); - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) - break; + foreach (var link in GetCommitInfoLinks(view, typeface)) + { + var shaLink = link.ShaLink; + var shaLinkTop = link.Top; + context.DrawText(shaLink, new Point(0, shaLinkTop)); + var underlineY = PixelSnapHelpers.PixelAlign(shaLinkTop + shaLink.Height + 0.5, pixelHeight); + context.DrawLine(underlinePen, new Point(0, underlineY), new Point(shaLink.Width, underlineY)); + + var author = new FormattedText( + link.Info.Author, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + var authorTop = GetTextTop(link.LineCenter, author.Height, link.KeepFirstVisible); + context.DrawText(author, new Point(shaLink.Width + 8, authorTop)); + + var timeStr = Models.DateTimeFormat.Format(link.Info.Timestamp, true); + var time = new FormattedText( + timeStr, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + _editor.Foreground); + var timeTop = GetTextTop(link.LineCenter, time.Height, link.KeepFirstVisible); + context.DrawText(time, new Point(width - time.Width, timeTop)); + } + } - var info = _editor.BlameData.LineInfos[lineNumber - 1]; - var x = 8.0; - var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; - if (!info.IsFirstInGroup) - continue; + private List GetCommitInfoLinks(TextView view, Typeface typeface) + { + var lineHeight = view.DefaultLineHeight; + var visualLineCount = view.VisualLines.Count; + if (_commitInfoLinks != null && + ReferenceEquals(_layoutBlameData, _editor.BlameData) && + _layoutVerticalOffset == view.VerticalOffset && + _layoutWidth == Bounds.Width && + _layoutHeight == Bounds.Height && + _layoutLineHeight == lineHeight && + _layoutVisualLineCount == visualLineCount) + { + return _commitInfoLinks; + } - var shaLink = new FormattedText( - info.CommitSHA, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - Brushes.DarkOrange); - var shaLinkTop = y - shaLink.Height * 0.5; - var underlineY = PixelSnapHelpers.PixelAlign(y + shaLink.Height * 0.5 + 0.5, pixelHeight); - context.DrawText(shaLink, new Point(x, shaLinkTop)); - x += shaLink.Width + 8; + var links = new List(); + var renderedGroup = string.Empty; + var firstGroup = string.Empty; + var firstLineCenter = 0.0; + var firstLabel = (FormattedText)null; + var hasFirstGroup = false; + var hasKeepFirstResult = false; + var keepFirstGroupVisible = false; - var author = new FormattedText( - info.Author, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - _editor.Foreground); - var authorTop = y - author.Height * 0.5; - context.DrawText(author, new Point(x, authorTop)); + foreach (var line in view.VisualLines) + { + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + continue; - var timeStr = Models.DateTimeFormat.Format(info.Timestamp, true); - var time = new FormattedText( - timeStr, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - _editor.Foreground); - var timeTop = y - time.Height * 0.5; - context.DrawText(time, new Point(width - time.Width - 8, timeTop)); + var lineNumber = line.FirstDocumentLine.LineNumber; + if (lineNumber > _editor.BlameData.LineInfos.Count) + break; + + var info = _editor.BlameData.LineInfos[lineNumber - 1]; + if (!TryGetLineCenter(view, line, out var lineCenter)) + continue; - if (lineNumber > 1) + if (!hasFirstGroup) + { + firstGroup = info.CommitSHA; + firstLineCenter = lineCenter; + firstLabel = CreateShaLink(typeface, info.CommitSHA); + hasFirstGroup = true; + } + else if (!hasKeepFirstResult && !string.Equals(firstGroup, info.CommitSHA, StringComparison.Ordinal)) + { + if (info.IsFirstInGroup || lineCenter > lineHeight) { - var lineTop = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; - context.DrawLine(new Pen(_editor.BorderBrush, 1), new Point(0, lineTop), new Point(Bounds.Width, lineTop)); + var nextLabelTop = lineCenter - firstLabel.Height * 0.5; + keepFirstGroupVisible = firstLineCenter - firstLabel.Height * 0.5 < 0 && nextLabelTop >= firstLabel.Height; + hasKeepFirstResult = true; } } + + if (links.Count > 0 && string.Equals(renderedGroup, info.CommitSHA, StringComparison.Ordinal)) + continue; + + if (!info.IsFirstInGroup && lineCenter > lineHeight) + continue; + + var shaLink = string.Equals(firstGroup, info.CommitSHA, StringComparison.Ordinal) + ? firstLabel + : CreateShaLink(typeface, info.CommitSHA); + links.Add(new CommitInfoLink(info, shaLink, lineCenter)); + renderedGroup = info.CommitSHA; } + + if (!hasKeepFirstResult) + keepFirstGroupVisible = hasFirstGroup && firstLineCenter - firstLabel.Height * 0.5 < 0; + + foreach (var link in links) + { + link.KeepFirstVisible = keepFirstGroupVisible; + link.Top = GetTextTop(link.LineCenter, link.ShaLink.Height, keepFirstGroupVisible); + } + + _commitInfoLinks = links; + _layoutBlameData = _editor.BlameData; + _layoutVerticalOffset = view.VerticalOffset; + _layoutWidth = Bounds.Width; + _layoutHeight = Bounds.Height; + _layoutLineHeight = lineHeight; + _layoutVisualLineCount = visualLineCount; + return links; + } + + private FormattedText CreateShaLink(Typeface typeface, string commitSHA) + { + return new FormattedText( + commitSHA, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + _editor.FontSize, + Brushes.DarkOrange); + } + + internal void InvalidateLayoutCache() + { + _commitInfoLinks = null; + _layoutBlameData = null; + } + + private bool TryGetLineCenter(TextView view, VisualLine line, out double lineCenter) + { + if (line.TextLines.Count == 0) + { + lineCenter = 0; + return false; + } + + lineCenter = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - view.VerticalOffset; + + var lineTop = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; + var lineBottom = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - view.VerticalOffset; + return lineBottom > 0 && lineTop < Bounds.Height; + } + + private double GetTextTop(double lineCenter, double textHeight, bool keepFirstVisible) + { + var textTop = lineCenter - textHeight * 0.5; + return keepFirstVisible ? Math.Max(0, textTop) : textTop; } protected override Size MeasureOverride(Size availableSize) { var view = TextView; var maxWidth = 0.0; - if (view is { VisualLinesValid: true } && _editor.BlameData != null) + if (view != null && _editor.BlameData != null) { var typeface = view.CreateTypeface(); var calculated = new HashSet(); @@ -111,13 +226,7 @@ protected override Size MeasureOverride(Size availableSize) continue; var x = 0.0; - var shaLink = new FormattedText( - info.CommitSHA, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - Brushes.DarkOrange); + var shaLink = CreateShaLink(typeface, info.CommitSHA); x += shaLink.Width + 8; var author = new FormattedText( @@ -139,9 +248,8 @@ protected override Size MeasureOverride(Size availableSize) _editor.Foreground); x += time.Width; - var required = x + 16; - if (maxWidth < required) - maxWidth = required; + if (maxWidth < x) + maxWidth = x; } } @@ -153,49 +261,36 @@ protected override void OnPointerMoved(PointerEventArgs e) base.OnPointerMoved(e); var view = TextView; - if (!e.Handled && view is { VisualLinesValid: true }) - { - var pos = e.GetPosition(this); - var typeface = view.CreateTypeface(); - var lineHeight = view.DefaultLineHeight; - - foreach (var line in view.VisualLines) - { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) - continue; - - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) - break; + if (e.Handled) + return; - var info = _editor.BlameData.LineInfos[lineNumber - 1]; - var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineTop) - view.VerticalOffset; - var shaLink = new FormattedText( - info.CommitSHA, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - Brushes.DarkOrange); + if (view is not { VisualLinesValid: true } || _editor.BlameData == null) + { + Cursor = Cursor.Default; + ToolTip.SetTip(this, null); + return; + } - var rect = new Rect(8, y, shaLink.Width, lineHeight); - if (rect.Contains(pos)) - { - Cursor = Cursor.Parse("Hand"); + var pos = e.GetPosition(this); + foreach (var link in GetCommitInfoLinks(view, view.CreateTypeface())) + { + var rect = new Rect(0, link.Top, link.ShaLink.Width, link.ShaLink.Height); + if (!rect.Contains(pos)) + continue; - if (DataContext is ViewModels.Blame blame) - { - var msg = blame.GetCommitMessage(info.CommitSHA); - ToolTip.SetTip(this, msg); - } + Cursor = Cursor.Parse("Hand"); - return; - } + if (DataContext is ViewModels.Blame blame) + { + var msg = blame.GetCommitMessage(link.Info.CommitSHA); + ToolTip.SetTip(this, msg); } - Cursor = Cursor.Default; - ToolTip.SetTip(this, null); + return; } + + Cursor = Cursor.Default; + ToolTip.SetTip(this, null); } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -203,44 +298,32 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) base.OnPointerPressed(e); var view = TextView; - if (!e.Handled && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && view is { VisualLinesValid: true }) + if (!e.Handled && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && view is { VisualLinesValid: true } && _editor.BlameData != null) { var pos = e.GetPosition(this); - var typeface = view.CreateTypeface(); - - foreach (var line in view.VisualLines) + foreach (var link in GetCommitInfoLinks(view, view.CreateTypeface())) { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + var rect = new Rect(0, link.Top, link.ShaLink.Width, link.ShaLink.Height); + if (!rect.Contains(pos)) continue; - var lineNumber = line.FirstDocumentLine.LineNumber; - if (lineNumber > _editor.BlameData.LineInfos.Count) - break; - - var info = _editor.BlameData.LineInfos[lineNumber - 1]; - var y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.TextTop) - view.VerticalOffset; - var shaLink = new FormattedText( - info.CommitSHA, - CultureInfo.CurrentCulture, - FlowDirection.LeftToRight, - typeface, - _editor.FontSize, - Brushes.DarkOrange); - - var rect = new Rect(8, y, shaLink.Width, shaLink.Height); - if (rect.Contains(pos)) - { - if (DataContext is ViewModels.Blame blame) - blame.NavigateToCommit(info.File, info.CommitSHA); + if (DataContext is ViewModels.Blame blame) + blame.NavigateToCommit(link.Info.File, link.Info.CommitSHA); - e.Handled = true; - break; - } + e.Handled = true; + break; } } } private readonly BlameTextEditor _editor = null; + private List _commitInfoLinks = null; + private Models.BlameData _layoutBlameData = null; + private double _layoutVerticalOffset = double.NaN; + private double _layoutWidth = double.NaN; + private double _layoutHeight = double.NaN; + private double _layoutLineHeight = double.NaN; + private int _layoutVisualLineCount = -1; } public class VerticalSeparatorMargin : AbstractMargin @@ -283,7 +366,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) return; var highlight = _owner._highlight; - if (string.IsNullOrEmpty(highlight)) + if (string.IsNullOrEmpty(highlight) || _owner.BlameData == null) return; var color = (Color)_owner.FindResource("SystemAccentColor")!; @@ -292,7 +375,7 @@ public void Draw(TextView textView, DrawingContext drawingContext) foreach (var line in textView.VisualLines) { - if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) + if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted || line.TextLines.Count == 0) continue; var lineNumber = line.FirstDocumentLine.LineNumber; @@ -362,14 +445,13 @@ public int TabWidth _textMate = Models.TextMateHelper.CreateForEditor(this); - TextArea.LeftMargins.Add(new CommitInfoMargin(this)); + TextArea.LeftMargins.Add(new CommitInfoMargin(this) { Margin = new Thickness(8, 0) }); TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); TextArea.LeftMargins.Add(new LineNumberMargin() { Margin = new Thickness(8, 0) }); TextArea.LeftMargins.Add(new VerticalSeparatorMargin(this)); TextArea.Caret.PositionChanged += OnTextAreaCaretPositionChanged; TextArea.TextView.BackgroundRenderers.Add(new LineBackgroundRenderer(this)); TextArea.TextView.ContextRequested += OnTextViewContextRequested; - TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.Margin = new Thickness(4, 0); } @@ -380,7 +462,6 @@ protected override void OnUnloaded(RoutedEventArgs e) TextArea.LeftMargins.Clear(); TextArea.Caret.PositionChanged -= OnTextAreaCaretPositionChanged; TextArea.TextView.ContextRequested -= OnTextViewContextRequested; - TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; if (_textMate != null) { @@ -398,17 +479,24 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang if (_file is { Length: > 0 }) Models.TextMateHelper.SetGrammarByFileName(_textMate, _file); } - if (change.Property == BlameDataProperty) + else if (change.Property == BlameDataProperty) { + _highlight = string.Empty; if (_blameData is { IsBinary: false } blame) Text = blame.Content; else Text = string.Empty; + + InvalidateCommitInfoMarginMeasure(); } else if (change.Property == TabWidthProperty) { Options.IndentationSize = _tabWidth; } + else if (change.Property.Name is nameof(FontFamily) or nameof(FontSize) or nameof(FontStyle) or nameof(FontWeight)) + { + InvalidateCommitInfoMarginMeasure(); + } else if (change.Property.Name == nameof(ActualThemeVariant) && change.NewValue != null) { Models.TextMateHelper.SetThemeByApp(_textMate); @@ -449,12 +537,13 @@ private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e.Handled = true; } - private void OnTextViewVisualLinesChanged(object sender, EventArgs e) + private void InvalidateCommitInfoMarginMeasure() { foreach (var margin in TextArea.LeftMargins) { if (margin is CommitInfoMargin commitInfo) { + commitInfo.InvalidateLayoutCache(); commitInfo.InvalidateMeasure(); break; }