diff --git a/src/Views/CommitSubjectPresenter.cs b/src/Views/CommitSubjectPresenter.cs index 694907274..88454d2d8 100644 --- a/src/Views/CommitSubjectPresenter.cs +++ b/src/Views/CommitSubjectPresenter.cs @@ -3,17 +3,36 @@ using System.Collections.Specialized; using System.Globalization; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.VisualTree; namespace SourceGit.Views { public partial class CommitSubjectPresenter : Control { + public static readonly StyledProperty FullMessageProperty = + AvaloniaProperty.Register(nameof(FullMessage)); + + public string FullMessage + { + get => GetValue(FullMessageProperty); + set => SetValue(FullMessageProperty, value); + } + + public CommitSubjectPresenter() + { + ToolTip.SetShowDelay(this, 1000); + ToolTip.SetBetweenShowDelay(this, -1); + ToolTip.AddToolTipOpeningHandler(this, OnToolTipOpening); + } + public static readonly StyledProperty FontFamilyProperty = AvaloniaProperty.Register(nameof(FontFamily)); @@ -175,6 +194,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang _needRebuildInlines = true; GenerateInlineElements(); InvalidateVisual(); + UpdateMessageToolTip(); + } + else if (change.Property == FullMessageProperty) + { + UpdateMessageToolTip(); } else if (change.Property == IssueTrackersProperty) { @@ -239,6 +263,73 @@ protected override void OnPointerExited(PointerEventArgs e) ClearHoveredIssueLink(); } + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + ToolTip.SetIsOpen(this, false); + ToolTip.SetIsOpen(_messageToolTipTarget ?? this, false); + _fullMessageTask = null; + ClearHoveredIssueLink(); + UpdateMessageToolTip(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + // The presenter can be narrower than its cell (for example, with refs after the subject). + // Keep the message tooltip on the whole cell, and issue-link tooltips on the presenter. + _messageToolTipTarget = this.FindAncestorOfType(); + if (_messageToolTipTarget != null) + { + ToolTip.SetIsOpen(this, false); + ToolTip.SetTip(this, null); + ToolTip.SetShowDelay(_messageToolTipTarget, 1000); + ToolTip.SetBetweenShowDelay(_messageToolTipTarget, -1); + ToolTip.AddToolTipOpeningHandler(_messageToolTipTarget, OnToolTipOpening); + } + + UpdateMessageToolTip(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + ToolTip.SetIsOpen(this, false); + ClearHoveredIssueLink(); + if (_messageToolTipTarget != null) + { + ToolTip.SetIsOpen(_messageToolTipTarget, false); + ToolTip.RemoveToolTipOpeningHandler(_messageToolTipTarget, OnToolTipOpening); + ToolTip.SetTip(_messageToolTipTarget, null); + _messageToolTipTarget = null; + } + + base.OnDetachedFromVisualTree(e); + } + + private async void OnToolTipOpening(object sender, CancelRoutedEventArgs e) + { + if (e.Cancel || _lastHover != null || !string.IsNullOrEmpty(FullMessage)) + return; + + if (DataContext is not Models.Commit commit || + this.FindAncestorOfType()?.DataContext is not ViewModels.Histories histories) + return; + + var task = _fullMessageTask ??= histories.GetCommitFullMessageAsync(commit); + var message = await task; + if (_fullMessageTask == task && !string.IsNullOrEmpty(message)) + _messageToolTip.Text = message; + } + + private void UpdateMessageToolTip() + { + _messageToolTip.Text = string.IsNullOrEmpty(FullMessage) ? Subject : FullMessage; + if (_messageToolTipTarget != null || _lastHover == null) + ToolTip.SetTip(_messageToolTipTarget ?? this, string.IsNullOrEmpty(Subject) ? null : _messageToolTip); + } + private void OnIssueTrackersChanged(object sender, NotifyCollectionChangedEventArgs e) { _needRebuildInlines = true; @@ -401,10 +492,14 @@ private void ClearHoveredIssueLink() { if (_lastHover != null) { + ToolTip.SetIsOpen(this, false); ToolTip.SetTip(this, null); SetCurrentValue(CursorProperty, Cursor.Parse("Arrow")); _lastHover = null; } + + if (!ReferenceEquals(ToolTip.GetTip(_messageToolTipTarget ?? this), _messageToolTip)) + UpdateMessageToolTip(); } [GeneratedRegex(@"`.*?`")] @@ -425,6 +520,9 @@ public Inline(double x, FormattedText text, Models.InlineElement elem) } private string _subject = string.Empty; + private readonly TextBlock _messageToolTip = new() { MaxWidth = 640, TextWrapping = TextWrapping.Wrap }; + private Control _messageToolTipTarget = null; + private Task _fullMessageTask = null; private AvaloniaList _issueTrackers = null; private Models.InlineElementCollector _elements = new(); private Models.InlineElement _lastHover = null; diff --git a/src/Views/InteractiveRebase.axaml b/src/Views/InteractiveRebase.axaml index af9acc49c..04a556664 100644 --- a/src/Views/InteractiveRebase.axaml +++ b/src/Views/InteractiveRebase.axaml @@ -170,6 +170,7 @@ Foreground="{DynamicResource Brush.FG1}" LinkForeground="{DynamicResource Brush.Link}" Subject="{Binding Subject}" + FullMessage="{Binding FullMessage}" IssueTrackers="{Binding $parent[v:InteractiveRebase].((vm:InteractiveRebase)DataContext).IssueTrackers}" FontWeight="Normal" Opacity="{Binding Action, Mode=OneWay, Converter={x:Static c:InteractiveRebaseActionConverters.ToOpacity}}" diff --git a/tests/CommitSubjectTooltipChecks/CommitSubjectTooltipChecks.csproj b/tests/CommitSubjectTooltipChecks/CommitSubjectTooltipChecks.csproj new file mode 100644 index 000000000..cbfcb000e --- /dev/null +++ b/tests/CommitSubjectTooltipChecks/CommitSubjectTooltipChecks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + + diff --git a/tests/CommitSubjectTooltipChecks/Program.cs b/tests/CommitSubjectTooltipChecks/Program.cs new file mode 100644 index 000000000..ea862d0de --- /dev/null +++ b/tests/CommitSubjectTooltipChecks/Program.cs @@ -0,0 +1,97 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Headless; +using Avalonia.Media; +using Avalonia.Threading; +using SourceGit.Views; + +AppBuilder.Configure() + .UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) + .SetupWithoutStarting(); +Application.Current.Styles.Add(new Avalonia.Themes.Fluent.FluentTheme()); + +var subject = new CommitSubjectPresenter +{ + Subject = "Fix #123", + FullMessage = "Fix #123\n\nThe complete commit message body.", + FontFamily = FontFamily.Default, + CodeFontFamily = FontFamily.Default, + FontSize = 14, + Width = 120, + Height = 26, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, + IssueTrackers = new AvaloniaList + { + new() { RegexString = @"#(\d+)", URLTemplate = "https://example.com/issues/$1" } + } +}; +var cell = new DataGridCell +{ + Content = subject, + Template = new FuncControlTemplate((_, _) => new Border + { + Background = Brushes.Transparent, + Child = new Avalonia.Controls.Presenters.ContentPresenter { Name = "PART_ContentPresenter", Content = subject } + }) +}; +var row = new Grid { ColumnDefinitions = new ColumnDefinitions("600,100"), Height = 30 }; +row.Children.Add(cell); +var neighbor = new Border { Background = Brushes.Transparent }; +Grid.SetColumn(neighbor, 1); +row.Children.Add(neighbor); +var window = new Window { Width = 700, Height = 100, Content = row }; +window.Show(); +Pump(100); +AvaloniaHeadlessPlatform.ForceRenderTimerTick(); +var y = row.Bounds.Y + 15; +window.MouseMove(new Point(650, y)); +Pump(50); +window.MouseMove(new Point(580, y)); +Pump(300); +Check(!ToolTip.GetIsOpen(cell), "short hover over blank cell space does not open tooltip"); +Pump(900); +Check(ToolTip.GetIsOpen(cell), "blank space at the far end of SUBJECT opens tooltip"); +Check(ToolTip.GetTip(cell) is TextBlock { Text: var message } && message == subject.FullMessage, + "cell tooltip contains the full multiline commit message"); + +window.MouseMove(new Point(32, y)); +Pump(1200); +Check(ToolTip.GetIsOpen(subject), "issue-link URL tooltip opens over the link"); +Check(ToolTip.GetTip(subject) is string link && link == "https://example.com/issues/123", + "issue links retain their URL tooltip"); +window.MouseMove(new Point(580, y)); +Pump(1200); +Check(ToolTip.GetTip(subject) == null && ToolTip.GetIsOpen(cell), + "moving from an issue link back to blank space restores the message tooltip"); + +window.MouseMove(new Point(650, y)); +Pump(100); +Check(!ToolTip.GetIsOpen(cell), "moving into another column closes the message tooltip"); +window.MouseMove(new Point(580, y)); +Pump(1200); +subject.DataContext = new object(); +subject.Subject = "Another commit"; +subject.FullMessage = "Another commit\n\nAnother body"; +Check(!ToolTip.GetIsOpen(cell), "recycling a row closes the cell tooltip"); +Check(((TextBlock)ToolTip.GetTip(cell)).Text == subject.FullMessage, "recycled row replaces previous message"); + +row.Children.Remove(cell); +Check(ToolTip.GetTip(cell) == null, "detaching the presenter clears the cell tooltip"); +window.Close(); + +static void Check(bool value, string description) +{ + if (!value) + throw new InvalidOperationException(description); + Console.WriteLine($"PASS: {description}"); +} + +static void Pump(int milliseconds) +{ + var frame = new DispatcherFrame(); + DispatcherTimer.RunOnce(() => frame.Continue = false, TimeSpan.FromMilliseconds(milliseconds)); + Dispatcher.UIThread.PushFrame(frame); +} diff --git a/tests/CommitSubjectTooltipChecks/README.md b/tests/CommitSubjectTooltipChecks/README.md new file mode 100644 index 000000000..a66c46736 --- /dev/null +++ b/tests/CommitSubjectTooltipChecks/README.md @@ -0,0 +1,5 @@ +# Commit subject tooltip checks + +Run `dotnet run --project tests/CommitSubjectTooltipChecks/CommitSubjectTooltipChecks.csproj`. + +These headless UI checks use a deliberately narrow subject presenter inside a wider SUBJECT cell, matching layouts with badges after the subject. They verify delayed full-message tooltips over blank cell space, issue-link URL tooltips, dismissal when entering another column, and cleanup when a row is recycled or detached.