diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs new file mode 100644 index 00000000..015eaca5 --- /dev/null +++ b/src/Views/CommitGraph.cs @@ -0,0 +1,228 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class CommitGraph : Control + { + public static readonly StyledProperty GraphProperty = + AvaloniaProperty.Register(nameof(Graph)); + + public Models.CommitGraph Graph + { + get => GetValue(GraphProperty); + set => SetValue(GraphProperty, value); + } + + public static readonly StyledProperty DotBrushProperty = + AvaloniaProperty.Register(nameof(DotBrush), Brushes.Transparent); + + public IBrush DotBrush + { + get => GetValue(DotBrushProperty); + set => SetValue(DotBrushProperty, value); + } + + public static readonly StyledProperty OnlyHighlightCurrentBranchProperty = + AvaloniaProperty.Register(nameof(OnlyHighlightCurrentBranch), true); + + public bool OnlyHighlightCurrentBranch + { + get => GetValue(OnlyHighlightCurrentBranchProperty); + set => SetValue(OnlyHighlightCurrentBranchProperty, value); + } + + static CommitGraph() + { + AffectsRender(GraphProperty, DotBrushProperty, OnlyHighlightCurrentBranchProperty); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var graph = Graph; + if (graph == null) + return; + + var histories = this.FindAncestorOfType(); + if (histories == null) + return; + + var list = histories.CommitListContainer; + if (list == null) + return; + + // Calculate drawing area. + double width = Bounds.Width - 273 - histories.AuthorNameColumnWidth.Value; + double height = Bounds.Height; + double startY = list.Scroll?.Offset.Y ?? 0; + double endY = startY + height + 28; + + // Apply scroll offset and clip. + using (context.PushClip(new Rect(0, 0, width, height))) + using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) + { + // Draw contents + DrawCurves(context, graph, startY, endY); + DrawAnchors(context, graph, startY, endY); + } + } + + private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom) + { + var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); + var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; + + if (onlyHighlightCurrentBranch) + { + foreach (var link in graph.Links) + { + if (link.IsMerged) + continue; + if (link.End.Y < top) + continue; + if (link.Start.Y > bottom) + break; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(link.Start, false); + ctx.QuadraticBezierTo(link.Control, link.End); + } + + context.DrawGeometry(null, grayedPen, geo); + } + } + + foreach (var line in graph.Paths) + { + var last = line.Points[0]; + var size = line.Points.Count; + + if (line.Points[size - 1].Y < top) + continue; + if (last.Y > bottom) + break; + + var geo = new StreamGeometry(); + var pen = Models.CommitGraph.Pens[line.Color]; + + using (var ctx = geo.Open()) + { + var started = false; + var ended = false; + for (int i = 1; i < size; i++) + { + var cur = line.Points[i]; + if (cur.Y < top) + { + last = cur; + continue; + } + + if (!started) + { + ctx.BeginFigure(last, false); + started = true; + } + + if (cur.Y > bottom) + { + cur = new Point(cur.X, bottom); + ended = true; + } + + if (cur.X > last.X) + { + ctx.QuadraticBezierTo(new Point(cur.X, last.Y), cur); + } + else if (cur.X < last.X) + { + if (i < size - 1) + { + var midY = (last.Y + cur.Y) / 2; + ctx.CubicBezierTo(new Point(last.X, midY + 4), new Point(cur.X, midY - 4), cur); + } + else + { + ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur); + } + } + else + { + ctx.LineTo(cur); + } + + if (ended) + break; + last = cur; + } + } + + if (!line.IsMerged && onlyHighlightCurrentBranch) + context.DrawGeometry(null, grayedPen, geo); + else + context.DrawGeometry(null, pen, geo); + } + + foreach (var link in graph.Links) + { + if (onlyHighlightCurrentBranch && !link.IsMerged) + continue; + if (link.End.Y < top) + continue; + if (link.Start.Y > bottom) + break; + + var geo = new StreamGeometry(); + using (var ctx = geo.Open()) + { + ctx.BeginFigure(link.Start, false); + ctx.QuadraticBezierTo(link.Control, link.End); + } + + context.DrawGeometry(null, Models.CommitGraph.Pens[link.Color], geo); + } + } + + private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom) + { + var dotFill = DotBrush; + var dotFillPen = new Pen(dotFill, 2); + var grayedPen = new Pen(Brushes.Gray, Models.CommitGraph.Pens[0].Thickness); + var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; + + foreach (var dot in graph.Dots) + { + if (dot.Center.Y < top) + continue; + if (dot.Center.Y > bottom) + break; + + var pen = Models.CommitGraph.Pens[dot.Color]; + if (!dot.IsMerged && onlyHighlightCurrentBranch) + pen = grayedPen; + + switch (dot.Type) + { + case Models.CommitGraph.DotType.Head: + context.DrawEllipse(dotFill, pen, dot.Center, 6, 6); + context.DrawEllipse(pen.Brush, null, dot.Center, 3, 3); + break; + case Models.CommitGraph.DotType.Merge: + context.DrawEllipse(pen.Brush, null, dot.Center, 6, 6); + context.DrawLine(dotFillPen, new Point(dot.Center.X, dot.Center.Y - 3), new Point(dot.Center.X, dot.Center.Y + 3)); + context.DrawLine(dotFillPen, new Point(dot.Center.X - 3, dot.Center.Y), new Point(dot.Center.X + 3, dot.Center.Y)); + break; + default: + context.DrawEllipse(dotFill, pen, dot.Center, 3, 3); + break; + } + } + } + } +} diff --git a/src/Views/CommitStatusIndicator.cs b/src/Views/CommitStatusIndicator.cs new file mode 100644 index 00000000..c2f4184e --- /dev/null +++ b/src/Views/CommitStatusIndicator.cs @@ -0,0 +1,90 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit.Views +{ + public class CommitStatusIndicator : Control + { + public static readonly StyledProperty CurrentBranchProperty = + AvaloniaProperty.Register(nameof(CurrentBranch)); + + public Models.Branch CurrentBranch + { + get => GetValue(CurrentBranchProperty); + set => SetValue(CurrentBranchProperty, value); + } + + public static readonly StyledProperty AheadBrushProperty = + AvaloniaProperty.Register(nameof(AheadBrush)); + + public IBrush AheadBrush + { + get => GetValue(AheadBrushProperty); + set => SetValue(AheadBrushProperty, value); + } + + public static readonly StyledProperty BehindBrushProperty = + AvaloniaProperty.Register(nameof(BehindBrush)); + + public IBrush BehindBrush + { + get => GetValue(BehindBrushProperty); + set => SetValue(BehindBrushProperty, value); + } + + enum Status + { + Normal, + Ahead, + Behind, + } + + public override void Render(DrawingContext context) + { + if (_status == Status.Normal) + return; + + context.DrawEllipse(_status == Status.Ahead ? AheadBrush : BehindBrush, null, new Rect(0, 0, 5, 5)); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (DataContext is Models.Commit commit && CurrentBranch is not null) + { + var sha = commit.SHA; + var track = CurrentBranch.TrackStatus; + + if (track.Ahead.Contains(sha)) + _status = Status.Ahead; + else if (track.Behind.Contains(sha)) + _status = Status.Behind; + else + _status = Status.Normal; + } + else + { + _status = Status.Normal; + } + + return _status == Status.Normal ? new Size(0, 0) : new Size(9, 5); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + InvalidateMeasure(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == CurrentBranchProperty) + InvalidateMeasure(); + } + + private Status _status = Status.Normal; + } +} diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs new file mode 100644 index 00000000..32f6838d --- /dev/null +++ b/src/Views/CommitSubjectPresenter.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +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 static readonly StyledProperty SubjectProperty = + AvaloniaProperty.Register(nameof(Subject)); + + public string Subject + { + get => GetValue(SubjectProperty); + set => SetValue(SubjectProperty, value); + } + + public static readonly StyledProperty> IssueTrackerRulesProperty = + AvaloniaProperty.Register>(nameof(IssueTrackerRules)); + + public AvaloniaList IssueTrackerRules + { + get => GetValue(IssueTrackerRulesProperty); + set => SetValue(IssueTrackerRulesProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) + { + Inlines!.Clear(); + _matches = null; + ClearHoveredIssueLink(); + + var subject = Subject; + if (string.IsNullOrEmpty(subject)) + return; + + var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); + if (!keywordMatch.Success) + keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); + + var rules = IssueTrackerRules ?? []; + var matches = new List(); + foreach (var rule in rules) + rule.Matches(matches, 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); + } + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_matches != null) + { + 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); + + var textPosition = TextLayout.HitTestPoint(point).TextPosition; + foreach (var match in _matches) + { + if (!match.Intersect(textPosition, 1)) + continue; + + if (match == _lastHover) + return; + + _lastHover = match; + SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); + ToolTip.SetTip(this, match.Link); + ToolTip.SetIsOpen(this, true); + e.Handled = true; + return; + } + + ClearHoveredIssueLink(); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (_lastHover != null) + Native.OS.OpenBrowser(_lastHover.Link); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + ClearHoveredIssueLink(); + } + + private void ClearHoveredIssueLink() + { + if (_lastHover != null) + { + ToolTip.SetTip(this, null); + SetCurrentValue(CursorProperty, Cursor.Parse("Arrow")); + _lastHover = null; + } + } + + [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; + } +} diff --git a/src/Views/CommitTimeTextBlock.cs b/src/Views/CommitTimeTextBlock.cs new file mode 100644 index 00000000..6947f7f2 --- /dev/null +++ b/src/Views/CommitTimeTextBlock.cs @@ -0,0 +1,163 @@ +using System; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace SourceGit.Views +{ + public class CommitTimeTextBlock : TextBlock + { + public static readonly StyledProperty ShowAsDateTimeProperty = + AvaloniaProperty.Register(nameof(ShowAsDateTime), true); + + public bool ShowAsDateTime + { + get => GetValue(ShowAsDateTimeProperty); + set => SetValue(ShowAsDateTimeProperty, value); + } + + public static readonly StyledProperty DateTimeFormatProperty = + AvaloniaProperty.Register(nameof(DateTimeFormat), 0); + + public int DateTimeFormat + { + get => GetValue(DateTimeFormatProperty); + set => SetValue(DateTimeFormatProperty, value); + } + + public static readonly StyledProperty UseAuthorTimeProperty = + AvaloniaProperty.Register(nameof(UseAuthorTime), true); + + public bool UseAuthorTime + { + get => GetValue(UseAuthorTimeProperty); + set => SetValue(UseAuthorTimeProperty, value); + } + + protected override Type StyleKeyOverride => typeof(TextBlock); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseAuthorTimeProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + } + else if (change.Property == ShowAsDateTimeProperty) + { + SetCurrentValue(TextProperty, GetDisplayText()); + + if (ShowAsDateTime) + StopTimer(); + else + StartTimer(); + } + else if (change.Property == DateTimeFormatProperty) + { + if (ShowAsDateTime) + SetCurrentValue(TextProperty, GetDisplayText()); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + if (!ShowAsDateTime) + StartTimer(); + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + base.OnUnloaded(e); + StopTimer(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + SetCurrentValue(TextProperty, GetDisplayText()); + } + + private void StartTimer() + { + if (_refreshTimer != null) + return; + + _refreshTimer = DispatcherTimer.Run(() => + { + Dispatcher.UIThread.Invoke(() => + { + var text = GetDisplayText(); + if (!text.Equals(Text, StringComparison.Ordinal)) + Text = text; + }); + + return true; + }, TimeSpan.FromSeconds(10)); + } + + private void StopTimer() + { + if (_refreshTimer != null) + { + _refreshTimer.Dispose(); + _refreshTimer = null; + } + } + + private string GetDisplayText() + { + var commit = DataContext as Models.Commit; + if (commit == null) + return string.Empty; + + if (ShowAsDateTime) + return UseAuthorTime ? commit.AuthorTimeStr : commit.CommitterTimeStr; + + var timestamp = UseAuthorTime ? commit.AuthorTime : commit.CommitterTime; + var now = DateTime.Now; + var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); + var span = now - localTime; + if (span.TotalMinutes < 1) + return App.Text("Period.JustNow"); + + if (span.TotalHours < 1) + return App.Text("Period.MinutesAgo", (int)span.TotalMinutes); + + if (span.TotalDays < 1) + return App.Text("Period.HoursAgo", (int)span.TotalHours); + + var lastDay = now.AddDays(-1).Date; + if (localTime >= lastDay) + return App.Text("Period.Yesterday"); + + if ((localTime.Year == now.Year && localTime.Month == now.Month) || span.TotalDays < 28) + { + var diffDay = now.Date - localTime.Date; + return App.Text("Period.DaysAgo", (int)diffDay.TotalDays); + } + + var lastMonth = now.AddMonths(-1).Date; + if (localTime.Year == lastMonth.Year && localTime.Month == lastMonth.Month) + return App.Text("Period.LastMonth"); + + if (localTime.Year == now.Year || localTime > now.AddMonths(-11)) + { + var diffMonth = (12 + now.Month - localTime.Month) % 12; + return App.Text("Period.MonthsAgo", diffMonth); + } + + var diffYear = now.Year - localTime.Year; + if (diffYear == 1) + return App.Text("Period.LastYear"); + + return App.Text("Period.YearsAgo", diffYear); + } + + private IDisposable _refreshTimer = null; + } +} diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 40b2636f..583e17c1 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -10,18 +10,18 @@ x:Class="SourceGit.Views.Histories" x:DataType="vm:Histories" x:Name="ThisControl"> - - + + - + - + - + @@ -264,5 +264,5 @@ - + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 8b5142ad..62c283e5 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -1,24 +1,18 @@ using System; -using System.Collections.Generic; using System.Text; -using System.Text.RegularExpressions; using Avalonia; using Avalonia.Collections; using Avalonia.Controls; -using Avalonia.Controls.Documents; using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Media; -using Avalonia.Threading; using Avalonia.VisualTree; namespace SourceGit.Views { - public class LayoutableGrid : Grid + public class HistoriesLayout : Grid { public static readonly StyledProperty UseHorizontalProperty = - AvaloniaProperty.Register(nameof(UseHorizontal)); + AvaloniaProperty.Register(nameof(UseHorizontal)); public bool UseHorizontal { @@ -28,17 +22,20 @@ namespace SourceGit.Views protected override Type StyleKeyOverride => typeof(Grid); - static LayoutableGrid() - { - UseHorizontalProperty.Changed.AddClassHandler((o, _) => o.RefreshLayout()); - } - public override void ApplyTemplate() { base.ApplyTemplate(); RefreshLayout(); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == UseHorizontalProperty) + RefreshLayout(); + } + private void RefreshLayout() { if (UseHorizontal) @@ -74,639 +71,6 @@ namespace SourceGit.Views } } - public class CommitStatusIndicator : Control - { - public static readonly StyledProperty CurrentBranchProperty = - AvaloniaProperty.Register(nameof(CurrentBranch)); - - public Models.Branch CurrentBranch - { - get => GetValue(CurrentBranchProperty); - set => SetValue(CurrentBranchProperty, value); - } - - public static readonly StyledProperty AheadBrushProperty = - AvaloniaProperty.Register(nameof(AheadBrush)); - - public IBrush AheadBrush - { - get => GetValue(AheadBrushProperty); - set => SetValue(AheadBrushProperty, value); - } - - public static readonly StyledProperty BehindBrushProperty = - AvaloniaProperty.Register(nameof(BehindBrush)); - - public IBrush BehindBrush - { - get => GetValue(BehindBrushProperty); - set => SetValue(BehindBrushProperty, value); - } - - enum Status - { - Normal, - Ahead, - Behind, - } - - public override void Render(DrawingContext context) - { - if (_status == Status.Normal) - return; - - context.DrawEllipse(_status == Status.Ahead ? AheadBrush : BehindBrush, null, new Rect(0, 0, 5, 5)); - } - - protected override Size MeasureOverride(Size availableSize) - { - if (DataContext is Models.Commit commit && CurrentBranch is not null) - { - var sha = commit.SHA; - var track = CurrentBranch.TrackStatus; - - if (track.Ahead.Contains(sha)) - _status = Status.Ahead; - else if (track.Behind.Contains(sha)) - _status = Status.Behind; - else - _status = Status.Normal; - } - else - { - _status = Status.Normal; - } - - return _status == Status.Normal ? new Size(0, 0) : new Size(9, 5); - } - - protected override void OnDataContextChanged(EventArgs e) - { - base.OnDataContextChanged(e); - InvalidateMeasure(); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - if (change.Property == CurrentBranchProperty) - InvalidateMeasure(); - } - - private Status _status = Status.Normal; - } - - public partial class CommitSubjectPresenter : TextBlock - { - public static readonly StyledProperty SubjectProperty = - AvaloniaProperty.Register(nameof(Subject)); - - public string Subject - { - get => GetValue(SubjectProperty); - set => SetValue(SubjectProperty, value); - } - - public static readonly StyledProperty> IssueTrackerRulesProperty = - AvaloniaProperty.Register>(nameof(IssueTrackerRules)); - - public AvaloniaList IssueTrackerRules - { - get => GetValue(IssueTrackerRulesProperty); - set => SetValue(IssueTrackerRulesProperty, value); - } - - protected override Type StyleKeyOverride => typeof(TextBlock); - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == SubjectProperty || change.Property == IssueTrackerRulesProperty) - { - Inlines!.Clear(); - _matches = null; - ClearHoveredIssueLink(); - - var subject = Subject; - if (string.IsNullOrEmpty(subject)) - return; - - var keywordMatch = REG_KEYWORD_FORMAT1().Match(subject); - if (!keywordMatch.Success) - keywordMatch = REG_KEYWORD_FORMAT2().Match(subject); - - var rules = IssueTrackerRules ?? []; - var matches = new List(); - foreach (var rule in rules) - rule.Matches(matches, 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); - } - } - - protected override void OnPointerMoved(PointerEventArgs e) - { - base.OnPointerMoved(e); - - if (_matches != null) - { - 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); - - var textPosition = TextLayout.HitTestPoint(point).TextPosition; - foreach (var match in _matches) - { - if (!match.Intersect(textPosition, 1)) - continue; - - if (match == _lastHover) - return; - - _lastHover = match; - SetCurrentValue(CursorProperty, Cursor.Parse("Hand")); - ToolTip.SetTip(this, match.Link); - ToolTip.SetIsOpen(this, true); - e.Handled = true; - return; - } - - ClearHoveredIssueLink(); - } - } - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - - if (_lastHover != null) - Native.OS.OpenBrowser(_lastHover.Link); - } - - protected override void OnPointerExited(PointerEventArgs e) - { - base.OnPointerExited(e); - ClearHoveredIssueLink(); - } - - private void ClearHoveredIssueLink() - { - if (_lastHover != null) - { - ToolTip.SetTip(this, null); - SetCurrentValue(CursorProperty, Cursor.Parse("Arrow")); - _lastHover = null; - } - } - - [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; - } - - public class CommitTimeTextBlock : TextBlock - { - public static readonly StyledProperty ShowAsDateTimeProperty = - AvaloniaProperty.Register(nameof(ShowAsDateTime), true); - - public bool ShowAsDateTime - { - get => GetValue(ShowAsDateTimeProperty); - set => SetValue(ShowAsDateTimeProperty, value); - } - - public static readonly StyledProperty DateTimeFormatProperty = - AvaloniaProperty.Register(nameof(DateTimeFormat), 0); - - public int DateTimeFormat - { - get => GetValue(DateTimeFormatProperty); - set => SetValue(DateTimeFormatProperty, value); - } - - public static readonly StyledProperty UseAuthorTimeProperty = - AvaloniaProperty.Register(nameof(UseAuthorTime), true); - - public bool UseAuthorTime - { - get => GetValue(UseAuthorTimeProperty); - set => SetValue(UseAuthorTimeProperty, value); - } - - protected override Type StyleKeyOverride => typeof(TextBlock); - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == UseAuthorTimeProperty) - { - SetCurrentValue(TextProperty, GetDisplayText()); - } - else if (change.Property == ShowAsDateTimeProperty) - { - SetCurrentValue(TextProperty, GetDisplayText()); - - if (ShowAsDateTime) - StopTimer(); - else - StartTimer(); - } - else if (change.Property == DateTimeFormatProperty) - { - if (ShowAsDateTime) - SetCurrentValue(TextProperty, GetDisplayText()); - } - } - - protected override void OnLoaded(RoutedEventArgs e) - { - base.OnLoaded(e); - - if (!ShowAsDateTime) - StartTimer(); - } - - protected override void OnUnloaded(RoutedEventArgs e) - { - base.OnUnloaded(e); - StopTimer(); - } - - protected override void OnDataContextChanged(EventArgs e) - { - base.OnDataContextChanged(e); - SetCurrentValue(TextProperty, GetDisplayText()); - } - - private void StartTimer() - { - if (_refreshTimer != null) - return; - - _refreshTimer = DispatcherTimer.Run(() => - { - Dispatcher.UIThread.Invoke(() => - { - var text = GetDisplayText(); - if (!text.Equals(Text, StringComparison.Ordinal)) - Text = text; - }); - - return true; - }, TimeSpan.FromSeconds(10)); - } - - private void StopTimer() - { - if (_refreshTimer != null) - { - _refreshTimer.Dispose(); - _refreshTimer = null; - } - } - - private string GetDisplayText() - { - var commit = DataContext as Models.Commit; - if (commit == null) - return string.Empty; - - if (ShowAsDateTime) - return UseAuthorTime ? commit.AuthorTimeStr : commit.CommitterTimeStr; - - var timestamp = UseAuthorTime ? commit.AuthorTime : commit.CommitterTime; - var now = DateTime.Now; - var localTime = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime(); - var span = now - localTime; - if (span.TotalMinutes < 1) - return App.Text("Period.JustNow"); - - if (span.TotalHours < 1) - return App.Text("Period.MinutesAgo", (int)span.TotalMinutes); - - if (span.TotalDays < 1) - return App.Text("Period.HoursAgo", (int)span.TotalHours); - - var lastDay = now.AddDays(-1).Date; - if (localTime >= lastDay) - return App.Text("Period.Yesterday"); - - if ((localTime.Year == now.Year && localTime.Month == now.Month) || span.TotalDays < 28) - { - var diffDay = now.Date - localTime.Date; - return App.Text("Period.DaysAgo", (int)diffDay.TotalDays); - } - - var lastMonth = now.AddMonths(-1).Date; - if (localTime.Year == lastMonth.Year && localTime.Month == lastMonth.Month) - return App.Text("Period.LastMonth"); - - if (localTime.Year == now.Year || localTime > now.AddMonths(-11)) - { - var diffMonth = (12 + now.Month - localTime.Month) % 12; - return App.Text("Period.MonthsAgo", diffMonth); - } - - var diffYear = now.Year - localTime.Year; - if (diffYear == 1) - return App.Text("Period.LastYear"); - - return App.Text("Period.YearsAgo", diffYear); - } - - private IDisposable _refreshTimer = null; - } - - public class CommitGraph : Control - { - public static readonly StyledProperty GraphProperty = - AvaloniaProperty.Register(nameof(Graph)); - - public Models.CommitGraph Graph - { - get => GetValue(GraphProperty); - set => SetValue(GraphProperty, value); - } - - public static readonly StyledProperty DotBrushProperty = - AvaloniaProperty.Register(nameof(DotBrush), Brushes.Transparent); - - public IBrush DotBrush - { - get => GetValue(DotBrushProperty); - set => SetValue(DotBrushProperty, value); - } - - public static readonly StyledProperty OnlyHighlightCurrentBranchProperty = - AvaloniaProperty.Register(nameof(OnlyHighlightCurrentBranch), true); - - public bool OnlyHighlightCurrentBranch - { - get => GetValue(OnlyHighlightCurrentBranchProperty); - set => SetValue(OnlyHighlightCurrentBranchProperty, value); - } - - static CommitGraph() - { - AffectsRender(GraphProperty, DotBrushProperty, OnlyHighlightCurrentBranchProperty); - } - - public override void Render(DrawingContext context) - { - base.Render(context); - - var graph = Graph; - if (graph == null) - return; - - var histories = this.FindAncestorOfType(); - if (histories == null) - return; - - var list = histories.CommitListContainer; - if (list == null) - return; - - // Calculate drawing area. - double width = Bounds.Width - 273 - histories.AuthorNameColumnWidth.Value; - double height = Bounds.Height; - double startY = list.Scroll?.Offset.Y ?? 0; - double endY = startY + height + 28; - - // Apply scroll offset and clip. - using (context.PushClip(new Rect(0, 0, width, height))) - using (context.PushTransform(Matrix.CreateTranslation(0, -startY))) - { - // Draw contents - DrawCurves(context, graph, startY, endY); - DrawAnchors(context, graph, startY, endY); - } - } - - private void DrawCurves(DrawingContext context, Models.CommitGraph graph, double top, double bottom) - { - var grayedPen = new Pen(new SolidColorBrush(Colors.Gray, 0.4), Models.CommitGraph.Pens[0].Thickness); - var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; - - if (onlyHighlightCurrentBranch) - { - foreach (var link in graph.Links) - { - if (link.IsMerged) - continue; - if (link.End.Y < top) - continue; - if (link.Start.Y > bottom) - break; - - var geo = new StreamGeometry(); - using (var ctx = geo.Open()) - { - ctx.BeginFigure(link.Start, false); - ctx.QuadraticBezierTo(link.Control, link.End); - } - - context.DrawGeometry(null, grayedPen, geo); - } - } - - foreach (var line in graph.Paths) - { - var last = line.Points[0]; - var size = line.Points.Count; - - if (line.Points[size - 1].Y < top) - continue; - if (last.Y > bottom) - break; - - var geo = new StreamGeometry(); - var pen = Models.CommitGraph.Pens[line.Color]; - - using (var ctx = geo.Open()) - { - var started = false; - var ended = false; - for (int i = 1; i < size; i++) - { - var cur = line.Points[i]; - if (cur.Y < top) - { - last = cur; - continue; - } - - if (!started) - { - ctx.BeginFigure(last, false); - started = true; - } - - if (cur.Y > bottom) - { - cur = new Point(cur.X, bottom); - ended = true; - } - - if (cur.X > last.X) - { - ctx.QuadraticBezierTo(new Point(cur.X, last.Y), cur); - } - else if (cur.X < last.X) - { - if (i < size - 1) - { - var midY = (last.Y + cur.Y) / 2; - ctx.CubicBezierTo(new Point(last.X, midY + 4), new Point(cur.X, midY - 4), cur); - } - else - { - ctx.QuadraticBezierTo(new Point(last.X, cur.Y), cur); - } - } - else - { - ctx.LineTo(cur); - } - - if (ended) - break; - last = cur; - } - } - - if (!line.IsMerged && onlyHighlightCurrentBranch) - context.DrawGeometry(null, grayedPen, geo); - else - context.DrawGeometry(null, pen, geo); - } - - foreach (var link in graph.Links) - { - if (onlyHighlightCurrentBranch && !link.IsMerged) - continue; - if (link.End.Y < top) - continue; - if (link.Start.Y > bottom) - break; - - var geo = new StreamGeometry(); - using (var ctx = geo.Open()) - { - ctx.BeginFigure(link.Start, false); - ctx.QuadraticBezierTo(link.Control, link.End); - } - - context.DrawGeometry(null, Models.CommitGraph.Pens[link.Color], geo); - } - } - - private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, double top, double bottom) - { - var dotFill = DotBrush; - var dotFillPen = new Pen(dotFill, 2); - var grayedPen = new Pen(Brushes.Gray, Models.CommitGraph.Pens[0].Thickness); - var onlyHighlightCurrentBranch = OnlyHighlightCurrentBranch; - - foreach (var dot in graph.Dots) - { - if (dot.Center.Y < top) - continue; - if (dot.Center.Y > bottom) - break; - - var pen = Models.CommitGraph.Pens[dot.Color]; - if (!dot.IsMerged && onlyHighlightCurrentBranch) - pen = grayedPen; - - switch (dot.Type) - { - case Models.CommitGraph.DotType.Head: - context.DrawEllipse(dotFill, pen, dot.Center, 6, 6); - context.DrawEllipse(pen.Brush, null, dot.Center, 3, 3); - break; - case Models.CommitGraph.DotType.Merge: - context.DrawEllipse(pen.Brush, null, dot.Center, 6, 6); - context.DrawLine(dotFillPen, new Point(dot.Center.X, dot.Center.Y - 3), new Point(dot.Center.X, dot.Center.Y + 3)); - context.DrawLine(dotFillPen, new Point(dot.Center.X - 3, dot.Center.Y), new Point(dot.Center.X + 3, dot.Center.Y)); - break; - default: - context.DrawEllipse(dotFill, pen, dot.Center, 3, 3); - break; - } - } - } - } - public partial class Histories : UserControl { public static readonly StyledProperty AuthorNameColumnWidthProperty = @@ -754,36 +118,34 @@ namespace SourceGit.Views set => SetValue(NavigationIdProperty, value); } - static Histories() - { - NavigationIdProperty.Changed.AddClassHandler((h, _) => - { - if (h.DataContext == null) - return; - - // Force scroll selected item (current head) into view. see issue #58 - var list = h.CommitListContainer; - if (list != null && list.SelectedItems.Count == 1) - list.ScrollIntoView(list.SelectedIndex); - }); - - AuthorNameColumnWidthProperty.Changed.AddClassHandler((h, _) => - { - h.CommitGraph.InvalidateVisual(); - }); - } - public Histories() { InitializeComponent(); } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == NavigationIdProperty) + { + if (DataContext is ViewModels.Histories) + { + var list = CommitListContainer; + if (list != null && list.SelectedItems.Count == 1) + list.ScrollIntoView(list.SelectedIndex); + } + } + } + private void OnCommitListLayoutUpdated(object _1, EventArgs _2) { var y = CommitListContainer.Scroll?.Offset.Y ?? 0; - if (y != _lastScrollY) + var authorNameColumnWidth = AuthorNameColumnWidth.Value; + if (y != _lastScrollY || authorNameColumnWidth != _lastAuthorNameColumnWidth) { _lastScrollY = y; + _lastAuthorNameColumnWidth = authorNameColumnWidth; CommitGraph.InvalidateVisual(); } } @@ -863,5 +225,6 @@ namespace SourceGit.Views } private double _lastScrollY = 0; + private double _lastAuthorNameColumnWidth = 0; } }