From 8c4362a98d757c4dab53a1a4f48a1b09e39c2448 Mon Sep 17 00:00:00 2001 From: leo Date: Fri, 25 Apr 2025 13:24:13 +0800 Subject: [PATCH] feature: subject presenter supports inline codeblock Signed-off-by: leo --- src/Models/Commit.cs | 2 +- src/Models/{Hyperlink.cs => InlineElement.cs} | 17 +- src/Models/IssueTrackerRule.cs | 5 +- src/ViewModels/CommitDetail.cs | 20 +- src/Views/CommitMessagePresenter.cs | 14 +- src/Views/CommitSubjectPresenter.cs | 345 +++++++++++++----- src/Views/Histories.axaml | 5 +- 7 files changed, 289 insertions(+), 119 deletions(-) rename src/Models/{Hyperlink.cs => InlineElement.cs} (60%) diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index 0bad8376..1980e622 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -117,6 +117,6 @@ namespace SourceGit.Models public class CommitFullMessage { public string Message { get; set; } = string.Empty; - public List Links { get; set; } = []; + public List Inlines { get; set; } = []; } } diff --git a/src/Models/Hyperlink.cs b/src/Models/InlineElement.cs similarity index 60% rename from src/Models/Hyperlink.cs rename to src/Models/InlineElement.cs index 81dc980e..53761403 100644 --- a/src/Models/Hyperlink.cs +++ b/src/Models/InlineElement.cs @@ -1,18 +1,27 @@ namespace SourceGit.Models { - public class Hyperlink + public enum InlineElementType { + None = 0, + Keyword, + Link, + CommitSHA, + Code, + } + + public class InlineElement + { + public InlineElementType Type { get; set; } = InlineElementType.None; public int Start { get; set; } = 0; public int Length { get; set; } = 0; public string Link { get; set; } = ""; - public bool IsCommitSHA { get; set; } = false; - public Hyperlink(int start, int length, string link, bool isCommitSHA = false) + public InlineElement(InlineElementType type, int start, int length, string link) { + Type = type; Start = start; Length = length; Link = link; - IsCommitSHA = isCommitSHA; } public bool Intersect(int start, int length) diff --git a/src/Models/IssueTrackerRule.cs b/src/Models/IssueTrackerRule.cs index 29487a16..fe0fe8e0 100644 --- a/src/Models/IssueTrackerRule.cs +++ b/src/Models/IssueTrackerRule.cs @@ -46,7 +46,7 @@ namespace SourceGit.Models set => SetProperty(ref _urlTemplate, value); } - public void Matches(List outs, string message) + public void Matches(List outs, string message) { if (_regex == null || string.IsNullOrEmpty(_urlTemplate)) return; @@ -81,8 +81,7 @@ namespace SourceGit.Models link = link.Replace($"${j}", group.Value); } - var range = new Hyperlink(start, len, link); - outs.Add(range); + outs.Add(new InlineElement(InlineElementType.Link, start, len, link)); } } diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 69b2c53d..6581d7bb 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -578,10 +578,10 @@ namespace SourceGit.ViewModels Task.Run(() => { var message = new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA).Result(); - var links = ParseLinksInMessage(message); + var inlines = ParseInlinesInMessage(message); if (!token.IsCancellationRequested) - Dispatcher.UIThread.Invoke(() => FullMessage = new Models.CommitFullMessage { Message = message, Links = links }); + Dispatcher.UIThread.Invoke(() => FullMessage = new Models.CommitFullMessage { Message = message, Inlines = inlines }); }); Task.Run(() => @@ -633,13 +633,13 @@ namespace SourceGit.ViewModels }); } - private List ParseLinksInMessage(string message) + private List ParseInlinesInMessage(string message) { - var links = new List(); + var inlines = new List(); if (_repo.Settings.IssueTrackerRules is { Count: > 0 } rules) { foreach (var rule in rules) - rule.Matches(links, message); + rule.Matches(inlines, message); } var matches = REG_SHA_FORMAT().Matches(message); @@ -652,7 +652,7 @@ namespace SourceGit.ViewModels var start = match.Index; var len = match.Length; var intersect = false; - foreach (var link in links) + foreach (var link in inlines) { if (link.Intersect(start, len)) { @@ -667,13 +667,13 @@ namespace SourceGit.ViewModels var sha = match.Groups[1].Value; var isCommitSHA = new Commands.IsCommitSHA(_repo.FullPath, sha).Result(); if (isCommitSHA) - links.Add(new Models.Hyperlink(start, len, sha, true)); + inlines.Add(new Models.InlineElement(Models.InlineElementType.CommitSHA, start, len, sha)); } - if (links.Count > 0) - links.Sort((l, r) => l.Start - r.Start); + if (inlines.Count > 0) + inlines.Sort((l, r) => l.Start - r.Start); - return links; + return inlines; } private void RefreshVisibleChanges() diff --git a/src/Views/CommitMessagePresenter.cs b/src/Views/CommitMessagePresenter.cs index c71c9687..0858640b 100644 --- a/src/Views/CommitMessagePresenter.cs +++ b/src/Views/CommitMessagePresenter.cs @@ -39,7 +39,7 @@ namespace SourceGit.Views if (string.IsNullOrEmpty(message)) return; - var links = FullMessage?.Links; + var links = FullMessage?.Inlines; if (links == null || links.Count == 0) { Inlines.Add(new Run(message)); @@ -54,7 +54,7 @@ namespace SourceGit.Views inlines.Add(new Run(message.Substring(pos, link.Start - pos))); var run = new Run(message.Substring(link.Start, link.Length)); - run.Classes.Add(link.IsCommitSHA ? "commit_link" : "issue_link"); + run.Classes.Add(link.Type == Models.InlineElementType.CommitSHA ? "commit_link" : "issue_link"); inlines.Add(run); pos = link.Start + link.Length; @@ -87,7 +87,7 @@ namespace SourceGit.Views scrollViewer.LineDown(); } } - else if (FullMessage is { Links: { Count: > 0 } links }) + else if (FullMessage is { Inlines: { Count: > 0 } links }) { var point = e.GetPosition(this) - new Point(Padding.Left, Padding.Top); var x = Math.Min(Math.Max(point.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); @@ -106,7 +106,7 @@ namespace SourceGit.Views SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); _lastHover = link; - if (!link.IsCommitSHA) + if (link.Type == Models.InlineElementType.Link) ToolTip.SetTip(this, link.Link); else ProcessHoverCommitLink(link); @@ -127,7 +127,7 @@ namespace SourceGit.Views var link = _lastHover.Link; e.Pointer.Capture(null); - if (_lastHover.IsCommitSHA) + if (_lastHover.Type == Models.InlineElementType.CommitSHA) { var parentView = this.FindAncestorOfType(); if (parentView is { DataContext: ViewModels.CommitDetail detail }) @@ -252,7 +252,7 @@ namespace SourceGit.Views ClearHoveredIssueLink(); } - private void ProcessHoverCommitLink(Models.Hyperlink link) + private void ProcessHoverCommitLink(Models.InlineElement link) { var sha = link.Link; @@ -301,7 +301,7 @@ namespace SourceGit.Views } } - private Models.Hyperlink _lastHover = null; + private Models.InlineElement _lastHover = null; private Dictionary _inlineCommits = new(); } } diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs index 83d79fe4..bfa2d9ea 100644 --- a/src/Views/CommitSubjectPresenter.cs +++ b/src/Views/CommitSubjectPresenter.cs @@ -1,18 +1,72 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.RegularExpressions; + using Avalonia; using Avalonia.Collections; using Avalonia.Controls; -using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Media; -using Avalonia.Media.TextFormatting; namespace SourceGit.Views { - public partial class CommitSubjectPresenter : TextBlock + public partial class CommitSubjectPresenter : Control { + public static readonly StyledProperty FontFamilyProperty = + AvaloniaProperty.Register(nameof(FontFamily)); + + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty CodeFontFamilyProperty = + AvaloniaProperty.Register(nameof(CodeFontFamily)); + + public FontFamily CodeFontFamily + { + get => GetValue(CodeFontFamilyProperty); + set => SetValue(CodeFontFamilyProperty, value); + } + + public static readonly StyledProperty FontSizeProperty = + TextBlock.FontSizeProperty.AddOwner(); + + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + public static readonly StyledProperty FontWeightProperty = + TextBlock.FontWeightProperty.AddOwner(); + + public FontWeight FontWeight + { + get => GetValue(FontWeightProperty); + set => SetValue(FontWeightProperty, value); + } + + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.White); + + public IBrush Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + public static readonly StyledProperty LinkForegroundProperty = + AvaloniaProperty.Register(nameof(LinkForeground), Brushes.White); + + public IBrush LinkForeground + { + get => GetValue(LinkForegroundProperty); + set => SetValue(LinkForegroundProperty, value); + } + public static readonly StyledProperty SubjectProperty = AvaloniaProperty.Register(nameof(Subject)); @@ -31,7 +85,33 @@ namespace SourceGit.Views set => SetValue(IssueTrackerRulesProperty, value); } - protected override Type StyleKeyOverride => typeof(TextBlock); + public override void Render(DrawingContext context) + { + if (_needRebuildInlines) + { + _needRebuildInlines = false; + GenerateFormattedTextElements(); + } + + if (_inlines.Count == 0) + return; + + var height = Bounds.Height; + foreach (var inline in _inlines) + { + if (inline.Element is { Type: Models.InlineElementType.Code}) + { + var rect = new Rect(inline.X, (height - inline.Text.Height - 2) * 0.5, inline.Text.WidthIncludingTrailingWhitespace + 8, inline.Text.Height + 2); + var roundedRect = new RoundedRect(rect, new CornerRadius(4)); + context.DrawRectangle(new SolidColorBrush(new Color(52, 101, 108, 118)), null, roundedRect); + context.DrawText(inline.Text, new Point(inline.X + 4, (height - inline.Text.Height) * 0.5)); + } + else + { + context.DrawText(inline.Text, new Point(inline.X, (height - inline.Text.Height) * 0.5)); + } + } + } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { @@ -39,85 +119,65 @@ namespace SourceGit.Views if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) { - Inlines!.Clear(); - _matches = null; + _elements.Clear(); ClearHoveredIssueLink(); var subject = Subject; if (string.IsNullOrEmpty(subject)) + { + _inlines.Clear(); + InvalidateVisual(); return; + } var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); if (!keywordMatch.Success) keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); + if (keywordMatch.Success) + _elements.Add(new Models.InlineElement(Models.InlineElementType.Keyword, 0, keywordMatch.Length, string.Empty)); + + var codeMatches = REG_INLINECODE_FORMAT().Matches(subject); + for (var i = 0; i < codeMatches.Count; i++) + { + var match = codeMatches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + var intersect = false; + foreach (var exist in _elements) + { + if (exist.Intersect(start, len)) + { + intersect = true; + break; + } + } + + if (intersect) + continue; + + _elements.Add(new Models.InlineElement(Models.InlineElementType.Code, start, len, string.Empty)); + } + var rules = IssueTrackerRules ?? []; - var matches = new List(); foreach (var rule in rules) - rule.Matches(matches, subject); + rule.Matches(_elements, subject); - if (matches.Count == 0) - { - if (keywordMatch.Success) - { - Inlines.Add(new Run(subject.Substring(0, keywordMatch.Length)) { FontWeight = FontWeight.Bold }); - Inlines.Add(new Run(subject.Substring(keywordMatch.Length))); - } - else - { - Inlines.Add(new Run(subject)); - } - return; - } - - matches.Sort((l, r) => l.Start - r.Start); - _matches = matches; - - var inlines = new List(); - var pos = 0; - foreach (var match in matches) - { - if (match.Start > pos) - { - if (keywordMatch.Success && pos < keywordMatch.Length) - { - if (keywordMatch.Length < match.Start) - { - inlines.Add(new Run(subject.Substring(pos, keywordMatch.Length - pos)) { FontWeight = FontWeight.Bold }); - inlines.Add(new Run(subject.Substring(keywordMatch.Length, match.Start - keywordMatch.Length))); - } - else - { - inlines.Add(new Run(subject.Substring(pos, match.Start - pos)) { FontWeight = FontWeight.Bold }); - } - } - else - { - inlines.Add(new Run(subject.Substring(pos, match.Start - pos))); - } - } - - var link = new Run(subject.Substring(match.Start, match.Length)); - link.Classes.Add("issue_link"); - inlines.Add(link); - - pos = match.Start + match.Length; - } - - if (pos < subject.Length) - { - if (keywordMatch.Success && pos < keywordMatch.Length) - { - inlines.Add(new Run(subject.Substring(pos, keywordMatch.Length - pos)) { FontWeight = FontWeight.Bold }); - inlines.Add(new Run(subject.Substring(keywordMatch.Length))); - } - else - { - inlines.Add(new Run(subject.Substring(pos))); - } - } - - Inlines.AddRange(inlines); + _needRebuildInlines = true; + InvalidateVisual(); + } + else if (change.Property == FontFamilyProperty || + change.Property == CodeFontFamilyProperty || + change.Property == FontSizeProperty || + change.Property == FontWeightProperty || + change.Property == ForegroundProperty || + change.Property == LinkForegroundProperty) + { + _needRebuildInlines = true; + InvalidateVisual(); } } @@ -125,31 +185,23 @@ namespace SourceGit.Views { base.OnPointerMoved(e); - if (_matches != null) + var point = e.GetPosition(this); + foreach (var inline in _inlines) { - var point = e.GetPosition(this) - new Point(Padding.Left, Padding.Top); - var x = Math.Min(Math.Max(point.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); - var y = Math.Min(Math.Max(point.Y, 0), Math.Max(TextLayout.Height, 0)); - point = new Point(x, y); + if (inline.Element is not { Type: Models.InlineElementType.Link } link) + continue; - var textPosition = TextLayout.HitTestPoint(point).TextPosition; - foreach (var match in _matches) - { - if (!match.Intersect(textPosition, 1)) - continue; + if (inline.X > point.X || inline.X + inline.Text.WidthIncludingTrailingWhitespace < point.X) + continue; - if (match == _lastHover) - return; - - _lastHover = match; - SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); - ToolTip.SetTip(this, match.Link); - e.Handled = true; - return; - } - - ClearHoveredIssueLink(); + _lastHover = link; + SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); + ToolTip.SetTip(this, link.Link); + e.Handled = true; + return; } + + ClearHoveredIssueLink(); } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -166,6 +218,94 @@ namespace SourceGit.Views ClearHoveredIssueLink(); } + private void GenerateFormattedTextElements() + { + _inlines.Clear(); + + var subject = Subject; + if (string.IsNullOrEmpty(subject)) + return; + + var fontFamily = FontFamily; + var codeFontFamily = CodeFontFamily; + var fontSize = FontSize; + var foreground = Foreground; + var linkForeground = LinkForeground; + var typeface = new Typeface(fontFamily, FontStyle.Normal, FontWeight); + var codeTypeface = new Typeface(codeFontFamily, FontStyle.Normal, FontWeight); + var pos = 0; + var x = 0.0; + foreach (var elem in _elements) + { + if (elem.Start > pos) + { + var normal = new FormattedText( + subject.Substring(pos, elem.Start - pos), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + foreground); + + _inlines.Add(new Inline(x, normal, null)); + x += normal.WidthIncludingTrailingWhitespace; + } + + if (elem.Type == Models.InlineElementType.Keyword) + { + var keyword = new FormattedText( + subject.Substring(elem.Start, elem.Length), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + new Typeface(fontFamily, FontStyle.Normal, FontWeight.Bold), + fontSize, + foreground); + _inlines.Add(new Inline(x, keyword, elem)); + x += keyword.WidthIncludingTrailingWhitespace; + } + else if (elem.Type == Models.InlineElementType.Link) + { + var link = new FormattedText( + subject.Substring(elem.Start, elem.Length), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + linkForeground); + _inlines.Add(new Inline(x, link, elem)); + x += link.WidthIncludingTrailingWhitespace; + } + else if (elem.Type == Models.InlineElementType.Code) + { + var link = new FormattedText( + subject.Substring(elem.Start + 1, elem.Length - 2), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + codeTypeface, + fontSize, + foreground); + _inlines.Add(new Inline(x, link, elem)); + x += link.WidthIncludingTrailingWhitespace + 8; + } + + pos = elem.Start + elem.Length; + } + + if (pos < subject.Length) + { + var normal = new FormattedText( + subject.Substring(pos), + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + fontSize, + foreground); + + _inlines.Add(new Inline(x, normal, null)); + x += normal.WidthIncludingTrailingWhitespace; + } + } + private void ClearHoveredIssueLink() { if (_lastHover != null) @@ -176,13 +316,32 @@ namespace SourceGit.Views } } + [GeneratedRegex(@"`.*?`")] + private static partial Regex REG_INLINECODE_FORMAT(); + [GeneratedRegex(@"^\[[\w\s]+\]")] private static partial Regex REG_KEYWORD_FORMAT1(); [GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")] private static partial Regex REG_KEYWORD_FORMAT2(); - private List _matches = null; - private Models.Hyperlink _lastHover = null; + private class Inline + { + public double X { get; set; } = 0; + public FormattedText Text { get; set; } = null; + public Models.InlineElement Element { get; set; } = null; + + public Inline(double x, FormattedText text, Models.InlineElement elem) + { + X = x; + Text = text; + Element = elem; + } + } + + private List _elements = []; + private List _inlines = []; + private Models.InlineElement _lastHover = null; + private bool _needRebuildInlines = false; } } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index c6c43aa9..2f5e2ffd 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -160,7 +160,10 @@