feature: subject presenter supports inline codeblock

Signed-off-by: leo <longshuang@msn.cn>
This commit is contained in:
leo 2025-04-25 13:24:13 +08:00
parent 9efbc7dd7a
commit 8c4362a98d
No known key found for this signature in database
7 changed files with 289 additions and 119 deletions

View file

@ -117,6 +117,6 @@ namespace SourceGit.Models
public class CommitFullMessage public class CommitFullMessage
{ {
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
public List<Hyperlink> Links { get; set; } = []; public List<InlineElement> Inlines { get; set; } = [];
} }
} }

View file

@ -1,18 +1,27 @@
namespace SourceGit.Models 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 Start { get; set; } = 0;
public int Length { get; set; } = 0; public int Length { get; set; } = 0;
public string Link { get; set; } = ""; 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; Start = start;
Length = length; Length = length;
Link = link; Link = link;
IsCommitSHA = isCommitSHA;
} }
public bool Intersect(int start, int length) public bool Intersect(int start, int length)

View file

@ -46,7 +46,7 @@ namespace SourceGit.Models
set => SetProperty(ref _urlTemplate, value); set => SetProperty(ref _urlTemplate, value);
} }
public void Matches(List<Hyperlink> outs, string message) public void Matches(List<InlineElement> outs, string message)
{ {
if (_regex == null || string.IsNullOrEmpty(_urlTemplate)) if (_regex == null || string.IsNullOrEmpty(_urlTemplate))
return; return;
@ -81,8 +81,7 @@ namespace SourceGit.Models
link = link.Replace($"${j}", group.Value); link = link.Replace($"${j}", group.Value);
} }
var range = new Hyperlink(start, len, link); outs.Add(new InlineElement(InlineElementType.Link, start, len, link));
outs.Add(range);
} }
} }

View file

@ -578,10 +578,10 @@ namespace SourceGit.ViewModels
Task.Run(() => Task.Run(() =>
{ {
var message = new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA).Result(); var message = new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA).Result();
var links = ParseLinksInMessage(message); var inlines = ParseInlinesInMessage(message);
if (!token.IsCancellationRequested) 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(() => Task.Run(() =>
@ -633,13 +633,13 @@ namespace SourceGit.ViewModels
}); });
} }
private List<Models.Hyperlink> ParseLinksInMessage(string message) private List<Models.InlineElement> ParseInlinesInMessage(string message)
{ {
var links = new List<Models.Hyperlink>(); var inlines = new List<Models.InlineElement>();
if (_repo.Settings.IssueTrackerRules is { Count: > 0 } rules) if (_repo.Settings.IssueTrackerRules is { Count: > 0 } rules)
{ {
foreach (var rule in rules) foreach (var rule in rules)
rule.Matches(links, message); rule.Matches(inlines, message);
} }
var matches = REG_SHA_FORMAT().Matches(message); var matches = REG_SHA_FORMAT().Matches(message);
@ -652,7 +652,7 @@ namespace SourceGit.ViewModels
var start = match.Index; var start = match.Index;
var len = match.Length; var len = match.Length;
var intersect = false; var intersect = false;
foreach (var link in links) foreach (var link in inlines)
{ {
if (link.Intersect(start, len)) if (link.Intersect(start, len))
{ {
@ -667,13 +667,13 @@ namespace SourceGit.ViewModels
var sha = match.Groups[1].Value; var sha = match.Groups[1].Value;
var isCommitSHA = new Commands.IsCommitSHA(_repo.FullPath, sha).Result(); var isCommitSHA = new Commands.IsCommitSHA(_repo.FullPath, sha).Result();
if (isCommitSHA) 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) if (inlines.Count > 0)
links.Sort((l, r) => l.Start - r.Start); inlines.Sort((l, r) => l.Start - r.Start);
return links; return inlines;
} }
private void RefreshVisibleChanges() private void RefreshVisibleChanges()

View file

@ -39,7 +39,7 @@ namespace SourceGit.Views
if (string.IsNullOrEmpty(message)) if (string.IsNullOrEmpty(message))
return; return;
var links = FullMessage?.Links; var links = FullMessage?.Inlines;
if (links == null || links.Count == 0) if (links == null || links.Count == 0)
{ {
Inlines.Add(new Run(message)); Inlines.Add(new Run(message));
@ -54,7 +54,7 @@ namespace SourceGit.Views
inlines.Add(new Run(message.Substring(pos, link.Start - pos))); inlines.Add(new Run(message.Substring(pos, link.Start - pos)));
var run = new Run(message.Substring(link.Start, link.Length)); 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); inlines.Add(run);
pos = link.Start + link.Length; pos = link.Start + link.Length;
@ -87,7 +87,7 @@ namespace SourceGit.Views
scrollViewer.LineDown(); 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 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 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")); SetCurrentValue(CursorProperty, Cursor.Parse("Hand"));
_lastHover = link; _lastHover = link;
if (!link.IsCommitSHA) if (link.Type == Models.InlineElementType.Link)
ToolTip.SetTip(this, link.Link); ToolTip.SetTip(this, link.Link);
else else
ProcessHoverCommitLink(link); ProcessHoverCommitLink(link);
@ -127,7 +127,7 @@ namespace SourceGit.Views
var link = _lastHover.Link; var link = _lastHover.Link;
e.Pointer.Capture(null); e.Pointer.Capture(null);
if (_lastHover.IsCommitSHA) if (_lastHover.Type == Models.InlineElementType.CommitSHA)
{ {
var parentView = this.FindAncestorOfType<CommitBaseInfo>(); var parentView = this.FindAncestorOfType<CommitBaseInfo>();
if (parentView is { DataContext: ViewModels.CommitDetail detail }) if (parentView is { DataContext: ViewModels.CommitDetail detail })
@ -252,7 +252,7 @@ namespace SourceGit.Views
ClearHoveredIssueLink(); ClearHoveredIssueLink();
} }
private void ProcessHoverCommitLink(Models.Hyperlink link) private void ProcessHoverCommitLink(Models.InlineElement link)
{ {
var sha = link.Link; var sha = link.Link;
@ -301,7 +301,7 @@ namespace SourceGit.Views
} }
} }
private Models.Hyperlink _lastHover = null; private Models.InlineElement _lastHover = null;
private Dictionary<string, Models.Commit> _inlineCommits = new(); private Dictionary<string, Models.Commit> _inlineCommits = new();
} }
} }

View file

@ -1,18 +1,72 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Avalonia; using Avalonia;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.TextFormatting;
namespace SourceGit.Views namespace SourceGit.Views
{ {
public partial class CommitSubjectPresenter : TextBlock public partial class CommitSubjectPresenter : Control
{ {
public static readonly StyledProperty<FontFamily> FontFamilyProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, FontFamily>(nameof(FontFamily));
public FontFamily FontFamily
{
get => GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
public static readonly StyledProperty<FontFamily> CodeFontFamilyProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, FontFamily>(nameof(CodeFontFamily));
public FontFamily CodeFontFamily
{
get => GetValue(CodeFontFamilyProperty);
set => SetValue(CodeFontFamilyProperty, value);
}
public static readonly StyledProperty<double> FontSizeProperty =
TextBlock.FontSizeProperty.AddOwner<CommitSubjectPresenter>();
public double FontSize
{
get => GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
public static readonly StyledProperty<FontWeight> FontWeightProperty =
TextBlock.FontWeightProperty.AddOwner<CommitSubjectPresenter>();
public FontWeight FontWeight
{
get => GetValue(FontWeightProperty);
set => SetValue(FontWeightProperty, value);
}
public static readonly StyledProperty<IBrush> ForegroundProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, IBrush>(nameof(Foreground), Brushes.White);
public IBrush Foreground
{
get => GetValue(ForegroundProperty);
set => SetValue(ForegroundProperty, value);
}
public static readonly StyledProperty<IBrush> LinkForegroundProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, IBrush>(nameof(LinkForeground), Brushes.White);
public IBrush LinkForeground
{
get => GetValue(LinkForegroundProperty);
set => SetValue(LinkForegroundProperty, value);
}
public static readonly StyledProperty<string> SubjectProperty = public static readonly StyledProperty<string> SubjectProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, string>(nameof(Subject)); AvaloniaProperty.Register<CommitSubjectPresenter, string>(nameof(Subject));
@ -31,7 +85,33 @@ namespace SourceGit.Views
set => SetValue(IssueTrackerRulesProperty, value); 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) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
@ -39,85 +119,65 @@ namespace SourceGit.Views
if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty)
{ {
Inlines!.Clear(); _elements.Clear();
_matches = null;
ClearHoveredIssueLink(); ClearHoveredIssueLink();
var subject = Subject; var subject = Subject;
if (string.IsNullOrEmpty(subject)) if (string.IsNullOrEmpty(subject))
{
_inlines.Clear();
InvalidateVisual();
return; return;
}
var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject);
if (!keywordMatch.Success) if (!keywordMatch.Success)
keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); 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 rules = IssueTrackerRules ?? [];
var matches = new List<Models.Hyperlink>();
foreach (var rule in rules) foreach (var rule in rules)
rule.Matches(matches, subject); rule.Matches(_elements, subject);
if (matches.Count == 0) _needRebuildInlines = true;
{ InvalidateVisual();
if (keywordMatch.Success) }
{ else if (change.Property == FontFamilyProperty ||
Inlines.Add(new Run(subject.Substring(0, keywordMatch.Length)) { FontWeight = FontWeight.Bold }); change.Property == CodeFontFamilyProperty ||
Inlines.Add(new Run(subject.Substring(keywordMatch.Length))); change.Property == FontSizeProperty ||
} change.Property == FontWeightProperty ||
else change.Property == ForegroundProperty ||
{ change.Property == LinkForegroundProperty)
Inlines.Add(new Run(subject)); {
} _needRebuildInlines = true;
return; InvalidateVisual();
}
matches.Sort((l, r) => l.Start - r.Start);
_matches = matches;
var inlines = new List<Inline>();
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);
} }
} }
@ -125,31 +185,23 @@ namespace SourceGit.Views
{ {
base.OnPointerMoved(e); 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); if (inline.Element is not { Type: Models.InlineElementType.Link } link)
var x = Math.Min(Math.Max(point.X, 0), Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)); continue;
var y = Math.Min(Math.Max(point.Y, 0), Math.Max(TextLayout.Height, 0));
point = new Point(x, y);
var textPosition = TextLayout.HitTestPoint(point).TextPosition; if (inline.X > point.X || inline.X + inline.Text.WidthIncludingTrailingWhitespace < point.X)
foreach (var match in _matches) continue;
{
if (!match.Intersect(textPosition, 1))
continue;
if (match == _lastHover) _lastHover = link;
return; SetCurrentValue(CursorProperty, Cursor.Parse("Hand"));
ToolTip.SetTip(this, link.Link);
_lastHover = match; e.Handled = true;
SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); return;
ToolTip.SetTip(this, match.Link);
e.Handled = true;
return;
}
ClearHoveredIssueLink();
} }
ClearHoveredIssueLink();
} }
protected override void OnPointerPressed(PointerPressedEventArgs e) protected override void OnPointerPressed(PointerPressedEventArgs e)
@ -166,6 +218,94 @@ namespace SourceGit.Views
ClearHoveredIssueLink(); 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() private void ClearHoveredIssueLink()
{ {
if (_lastHover != null) if (_lastHover != null)
@ -176,13 +316,32 @@ namespace SourceGit.Views
} }
} }
[GeneratedRegex(@"`.*?`")]
private static partial Regex REG_INLINECODE_FORMAT();
[GeneratedRegex(@"^\[[\w\s]+\]")] [GeneratedRegex(@"^\[[\w\s]+\]")]
private static partial Regex REG_KEYWORD_FORMAT1(); private static partial Regex REG_KEYWORD_FORMAT1();
[GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")] [GeneratedRegex(@"^\S+([\<\(][\w\s_\-\*,]+[\>\)])?\!?\s?:\s")]
private static partial Regex REG_KEYWORD_FORMAT2(); private static partial Regex REG_KEYWORD_FORMAT2();
private List<Models.Hyperlink> _matches = null; private class Inline
private Models.Hyperlink _lastHover = null; {
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<Models.InlineElement> _elements = [];
private List<Inline> _inlines = [];
private Models.InlineElement _lastHover = null;
private bool _needRebuildInlines = false;
} }
} }

View file

@ -160,7 +160,10 @@
</v:CommitRefsPresenter> </v:CommitRefsPresenter>
<v:CommitSubjectPresenter Grid.Column="3" <v:CommitSubjectPresenter Grid.Column="3"
Classes="primary" FontFamily="{DynamicResource Fonts.Primary}"
CodeFontFamily="{DynamicResource Fonts.Monospace}"
Foreground="{DynamicResource Brush.FG1}"
LinkForeground="{DynamicResource Brush.Link}"
Subject="{Binding Subject}" Subject="{Binding Subject}"
IssueTrackerRules="{Binding $parent[v:Histories].IssueTrackerRules}" IssueTrackerRules="{Binding $parent[v:Histories].IssueTrackerRules}"
FontWeight="{Binding FontWeight}" FontWeight="{Binding FontWeight}"