Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/Views/CommitSubjectPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> FullMessageProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, string>(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<FontFamily> FontFamilyProperty =
AvaloniaProperty.Register<CommitSubjectPresenter, FontFamily>(nameof(FontFamily));

Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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<DataGridCell>();
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<Histories>()?.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;
Expand Down Expand Up @@ -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(@"`.*?`")]
Expand All @@ -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<string> _fullMessageTask = null;
private AvaloniaList<Models.IssueTracker> _issueTrackers = null;
private Models.InlineElementCollector _elements = new();
private Models.InlineElement _lastHover = null;
Expand Down
1 change: 1 addition & 0 deletions src/Views/InteractiveRebase.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Expand Down
11 changes: 11 additions & 0 deletions tests/CommitSubjectTooltipChecks/CommitSubjectTooltipChecks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SourceGit.csproj" />
<PackageReference Include="Avalonia.Headless" Version="11.3.22" />
</ItemGroup>
</Project>
97 changes: 97 additions & 0 deletions tests/CommitSubjectTooltipChecks/Program.cs
Original file line number Diff line number Diff line change
@@ -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<Application>()
.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<SourceGit.Models.IssueTracker>
{
new() { RegexString = @"#(\d+)", URLTemplate = "https://example.com/issues/$1" }
}
};
var cell = new DataGridCell
{
Content = subject,
Template = new FuncControlTemplate<DataGridCell>((_, _) => 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);
}
5 changes: 5 additions & 0 deletions tests/CommitSubjectTooltipChecks/README.md
Original file line number Diff line number Diff line change
@@ -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.