diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 603d223a9..5323b51bd 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -691,6 +691,7 @@ Monospace Font Theme Theme Overrides + Shorten leading paths in file lists Use auto-hide scrollbars Use fixed tab width in titlebar Use native window frame diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 9d9fb3f9f..aad981812 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -295,6 +295,12 @@ public int ImageDiffActiveIdx set => SetProperty(ref _imageDiffActiveIdx, value); } + public bool TrimLeadingPathsInChanges + { + get => _trimLeadingPathsInChanges; + set => SetProperty(ref _trimLeadingPathsInChanges, value); + } + public bool EnableCompactFoldersInChangesTree { get => _enableCompactFoldersInChangesTree; @@ -823,6 +829,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _useFullTextDiff = false; private int _lfsImageActiveIdx = 0; private int _imageDiffActiveIdx = 0; + private bool _trimLeadingPathsInChanges = false; private bool _enableCompactFoldersInChangesTree = false; private Models.ChangeViewMode _changeViewMode = Models.ChangeViewMode.List; diff --git a/src/Views/ChangeCollectionView.axaml b/src/Views/ChangeCollectionView.axaml index be9143d1b..ba8a5abe6 100644 --- a/src/Views/ChangeCollectionView.axaml +++ b/src/Views/ChangeCollectionView.axaml @@ -122,7 +122,8 @@ SelectionChanged="OnRowSelectionChanged"> - - - - + + diff --git a/src/Views/ChangePathTextBlock.cs b/src/Views/ChangePathTextBlock.cs new file mode 100644 index 000000000..697ff92c5 --- /dev/null +++ b/src/Views/ChangePathTextBlock.cs @@ -0,0 +1,167 @@ +using System; +using System.Globalization; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Utilities; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + // Measure against the viewport while allowing an exceptionally long filename + // to contribute its full width to the horizontal scroll extent. + public class ChangeListRow : Grid + { + public static readonly StyledProperty TrimLeadingPathsProperty = + AvaloniaProperty.Register(nameof(TrimLeadingPaths), true); + + public bool TrimLeadingPaths + { + get => GetValue(TrimLeadingPathsProperty); + set => SetValue(TrimLeadingPathsProperty, value); + } + public static readonly StyledProperty ViewportWidthProperty = + AvaloniaProperty.Register(nameof(ViewportWidth), double.PositiveInfinity); + + public double ViewportWidth + { + get => GetValue(ViewportWidthProperty); + set => SetValue(ViewportWidthProperty, value); + } + + static ChangeListRow() + { + AffectsMeasure(ViewportWidthProperty, TrimLeadingPathsProperty); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _scrollViewer = this.FindAncestorOfType(); + if (_scrollViewer != null) + { + _scrollViewer.PropertyChanged += OnScrollViewerPropertyChanged; + SetCurrentValue(ViewportWidthProperty, _scrollViewer.Viewport.Width); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (_scrollViewer != null) + _scrollViewer.PropertyChanged -= OnScrollViewerPropertyChanged; + _scrollViewer = null; + base.OnDetachedFromVisualTree(e); + } + + private void OnScrollViewerPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ScrollViewer.ViewportProperty) + SetCurrentValue(ViewportWidthProperty, _scrollViewer.Viewport.Width); + } + + private ScrollViewer _scrollViewer; + protected override Size MeasureOverride(Size availableSize) + { + if (!TrimLeadingPaths) + return base.MeasureOverride(availableSize); + + var size = base.MeasureOverride(new Size(Math.Min(availableSize.Width, Math.Max(0, ViewportWidth)), availableSize.Height)); + var minimum = 0.0; + foreach (var child in Children) + { + if (child.IsVisible) + minimum += child is ChangePathTextBlock path ? path.MinWidth + path.Margin.Left + path.Margin.Right : child.DesiredSize.Width; + } + return new Size(Math.Max(size.Width, minimum), size.Height); + } + } + + public class ChangePathTextBlock : TextBlock + { + public static readonly StyledProperty DirectoryForegroundProperty = + AvaloniaProperty.Register(nameof(DirectoryForeground), Brushes.Gray); + + public IBrush DirectoryForeground + { + get => GetValue(DirectoryForegroundProperty); + set => SetValue(DirectoryForegroundProperty, value); + } + + public static readonly StyledProperty PathProperty = + AvaloniaProperty.Register(nameof(Path), string.Empty); + + public string Path + { + get => GetValue(PathProperty); + set => SetValue(PathProperty, value); + } + + static ChangePathTextBlock() + { + AffectsMeasure(PathProperty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == DirectoryForegroundProperty) + InvalidateTextLayout(); + } + + protected override TextLayout CreateTextLayout(string text) + { + var path = Path ?? string.Empty; + var filenameLength = path.Length - (path.LastIndexOf('/') + 1); + var directoryLength = text.Length - filenameLength; + if (directoryLength <= 0) + return base.CreateTextLayout(text); + + var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + var properties = new GenericTextRunProperties(typeface, FontSize, foregroundBrush: DirectoryForeground); + return new TextLayout(text, typeface, FontSize, Foreground, + textAlignment: TextAlignment, flowDirection: FlowDirection, + lineHeight: LineHeight, letterSpacing: LetterSpacing, + textStyleOverrides: [new ValueSpan(0, directoryLength, properties)]); + } + + private Size MeasureText(string text) + { + var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection, + new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), FontSize, Foreground); + return new Size(formatted.WidthIncludingTrailingWhitespace, formatted.Height); + } + + protected override Size MeasureOverride(Size availableSize) + { + var path = Path ?? string.Empty; + var fileStart = path.LastIndexOf('/') + 1; + Text = (fileStart > 0 ? "…" : string.Empty) + path[fileStart..]; + MinWidth = MeasureText(Text).Width; + Text = path; + var fullSize = MeasureText(Text); + var separator = path.LastIndexOf('/'); + if (fullSize.Width <= availableSize.Width || separator < 0) + return base.MeasureOverride(Size.Infinity); + + // Search text-element boundaries so trimming cannot split a surrogate + // pair or a combining sequence. Only the directory may be removed. + var starts = StringInfo.ParseCombiningCharacters(path[..(separator + 1)]); + var low = 1; + var high = starts.Length; + while (low < high) + { + var mid = low + (high - low) / 2; + Text = "…" + path[starts[mid]..]; + if (MeasureText(Text).Width <= availableSize.Width) + high = mid; + else + low = mid + 1; + } + + Text = "…" + path[(low < starts.Length ? starts[low] : separator + 1)..]; + return base.MeasureOverride(Size.Infinity); + } + } +} diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 6b3aa863c..e1af0a73d 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -188,7 +188,7 @@ - + + + + Exe + net10.0 + enable + + + + + diff --git a/tests/ChangePathChecks/Program.cs b/tests/ChangePathChecks/Program.cs new file mode 100644 index 000000000..3ae3c16cc --- /dev/null +++ b/tests/ChangePathChecks/Program.cs @@ -0,0 +1,113 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit.Views; + +internal static class Program +{ + [STAThread] + private static void Main() + { + SourceGit.App.BuildAvaloniaApp().SetupWithoutStarting(); + var text = new ChangePathTextBlock { Foreground = Brushes.White, Path = "very/long/directory/structure/filename.cs" }; + Measure(text, 1000); + Check(text.Text == text.Path, "Full path fits"); + text.DirectoryForeground = Brushes.DarkGray; + Measure(text, 1000); + Measure(text, 180); + Check(text.Text.StartsWith("…") && text.Text.EndsWith("filename.cs"), "Leading ellipsis preserves filename"); + Check(text.DesiredSize.Width <= 180, "Trimmed path fits"); + Measure(text, 1); + Check(text.Text == "…filename.cs" && text.MinWidth > 1, "Narrow viewport preserves whole filename"); + Measure(text, 1000); + Check(text.Text == text.Path, "Widening restores path"); + text.Path = "filename-without-directory.cs"; + Measure(text, 1); + Check(text.Text == text.Path, "Root filename never trimmed"); + text.Path = "a/😀e\u0301/very/long/filename.cs"; + Measure(text, 160); + Check(text.Text.EndsWith("filename.cs") && !text.Text.Contains('\uFFFD'), "Unicode filename preserved"); + text.Path = ""; + Measure(text, 1); + Check(string.IsNullOrEmpty(text.Text), "Empty path"); + + var row = new ChangeListRow { ColumnDefinitions = ColumnDefinitions.Parse("Auto,Auto,*"), ViewportWidth = 180 }; + var icon = new Border { Width = 18 }; + text.Path = "very/long/directory/structure/filename.cs"; + Grid.SetColumn(text, 2); + row.Children.Add(icon); + row.Children.Add(text); + row.Measure(Size.Infinity); + Check(text.Text.StartsWith("…") && text.DesiredSize.Width <= 162, "Row constrains path to viewport"); + row.ViewportWidth = 25; + row.Measure(Size.Infinity); + Check(text.Text == "…filename.cs" && row.DesiredSize.Width > 25, "Long filename keeps horizontal scroll extent"); + SourceGit.ViewModels.Preferences.Instance.SetCanModify(); + SourceGit.ViewModels.Preferences.Instance.TrimLeadingPathsInChanges = true; + foreach (var unstaged in new[] { true, false }) + { + var view = new ChangeCollectionView + { + IsUnstagedChange = unstaged, + ViewMode = SourceGit.Models.ChangeViewMode.List, + Changes = [new SourceGit.Models.Change { Path = "very/long/directory/structure/filename.cs" }], + }; + var window = new Window { Width = 180, Height = 150, Content = view }; + window.Show(); + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + var label = view.GetVisualDescendants().OfType().Single(); + Check(label.Text.StartsWith("…") && label.Text.EndsWith("filename.cs"), "Actual staged/unstaged template truncates correctly"); + CheckPathColors(label); + var preferences = SourceGit.ViewModels.Preferences.Instance; + preferences.SetCanModify(); + preferences.TrimLeadingPathsInChanges = false; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text == label.Path, "Preference disables trimming immediately"); + CheckPathColors(label); + preferences.TrimLeadingPathsInChanges = true; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text.StartsWith("…"), "Preference enables trimming immediately"); + window.Width = 800; + for (var i = 0; i < 10; i++) + Dispatcher.UIThread.RunJobs(); + Check(label.Text == label.Path, "Actual template responds to resize"); + window.Close(); + } + Console.WriteLine("PASS: 18 changed-path layout checks and directory/filename color checks"); + } + + private static void Measure(Control control, double width) + { + control.InvalidateMeasure(); + control.Measure(new Size(width, 24)); + if (control is ChangePathTextBlock path) + CheckPathColors(path); + } + + private static void CheckPathColors(ChangePathTextBlock label) + { + var filenameLength = label.Path.Length - (label.Path.LastIndexOf('/') + 1); + var directoryLength = label.Text.Length - filenameLength; + var position = 0; + foreach (var run in label.TextLayout.TextLines.SelectMany(line => line.TextRuns)) + { + if (position >= label.Text.Length) + break; + var expected = position < directoryLength ? label.DirectoryForeground : label.Foreground; + Check(Equals(run.Properties.ForegroundBrush, expected), "Directory is gray and filename keeps normal foreground"); + Check(position >= directoryLength || position + run.Length <= directoryLength, "Color boundary preserves full filename"); + position += run.Length; + } + Check(position >= label.Text.Length, "Color checks cover displayed text"); + } + private static void Check(bool condition, string message) + { + if (!condition) + throw new InvalidOperationException(message); + } +}