From 981a0106a68d43baa648380a4e06af5123bf06a3 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Fri, 5 Dec 2025 11:36:58 +0100 Subject: [PATCH] Introduce PathSegmentEllipsis TextTrimming (#20077) * Introduce PathSegmentEllipsis TextTrimming * Adjust fallback * Second rework * Add some tests * Minor adjustments * Adjust SplitResult usage * Update MainWindow.axaml Remove sample * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * More adjustments * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Use the correct width --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Julien Lebosquain --- .../Media/TextFormatting/ShapedTextRun.cs | 22 +- .../Media/TextFormatting/SplitResult.cs | 6 +- .../Media/TextFormatting/TextFormatterImpl.cs | 35 +- .../TextLeadingPrefixCharacterEllipsis.cs | 3 +- .../Media/TextPathSegmentEllipsis.cs | 525 ++++++++++++++++++ .../Media/TextPathSegmentTrimming.cs | 38 ++ src/Avalonia.Base/Media/TextTrimming.cs | 12 + .../Media/TextFormatting/TextLineTests.cs | 184 +++++- 8 files changed, 809 insertions(+), 16 deletions(-) create mode 100644 src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs create mode 100644 src/Avalonia.Base/Media/TextPathSegmentTrimming.cs diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs index 637109e7ef..4b38a58cc6 100644 --- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs +++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs @@ -170,24 +170,32 @@ namespace Avalonia.Media.TextFormatting length = Length - length; } -#if DEBUG + if (length == 0) { throw new ArgumentOutOfRangeException(nameof(length), "length must be greater than zero."); } -#endif + var splitBuffer = ShapedBuffer.Split(length); - var first = new ShapedTextRun(splitBuffer.First, Properties); - -#if DEBUG + // first cannot be null as length > 0 + var first = new ShapedTextRun(splitBuffer.First!, Properties); if (first.Length < length) { throw new InvalidOperationException("Split length too small."); } -#endif - var second = new ShapedTextRun(splitBuffer.Second!, Properties); + + if (splitBuffer.Second == null) + { + // If there's no second part, return the entire run as the second in reversed mode, or throw + if (isReversed) + { + return new SplitResult(null, first); + } + throw new InvalidOperationException($"Cannot split: requested length {length} consumes entire run."); + } + var second = new ShapedTextRun(splitBuffer.Second, Properties); if (isReversed) { diff --git a/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs b/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs index c1ac57ce46..aee433b150 100644 --- a/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs +++ b/src/Avalonia.Base/Media/TextFormatting/SplitResult.cs @@ -4,7 +4,7 @@ public readonly struct SplitResult #pragma warning restore CA1815 // Override equals and operator equals on value types { - public SplitResult(T first, T? second) + public SplitResult(T? first, T? second) { First = first; @@ -17,7 +17,7 @@ /// /// The first part. /// - public T First { get; } + public T? First { get; } /// /// Gets the second part. @@ -32,7 +32,7 @@ /// /// On return, contains the first part. /// On return, contains the second part. - public void Deconstruct(out T first, out T? second) + public void Deconstruct(out T? first, out T? second) { first = First; second = Second; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 3e7500c307..0e9308b6ed 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -93,6 +93,18 @@ namespace Avalonia.Media.TextFormatting internal static SplitResult> SplitTextRuns(IReadOnlyList textRuns, int length, FormattingObjectPool objectPool) { + if(length == 0) + { + var second = objectPool.TextRunLists.Rent(); + + for (var i = 0; i < textRuns.Count; i++) + { + second.Add(textRuns[i]); + } + + return new SplitResult>(null, second); + } + var first = objectPool.TextRunLists.Rent(); var currentLength = 0; @@ -148,9 +160,15 @@ namespace Avalonia.Media.TextFormatting { var split = shapedTextCharacters.Split(length - currentLength); - first.Add(split.First); + if(split.First is not null) + { + first.Add(split.First); + } - second.Add(split.Second!); + if (split.Second != null) + { + second.Add(split.Second); + } } for (var j = 1; j < secondCount; j++) @@ -379,7 +397,7 @@ namespace Avalonia.Media.TextFormatting var splitResult = shapedBuffer.Split(previousLength + currentRun.Length); - if(splitResult.First.Length == 0) + if (splitResult.First is null || splitResult.First.Length == 0) { previousLength += currentRun.Length; } @@ -932,6 +950,11 @@ namespace Avalonia.Media.TextFormatting textLineBreak = null; } + if(preSplitRuns is null) + { + return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties); + } + if (postSplitRuns?.Count > 0) { ResetTrailingWhitespaceBidiLevels(preSplitRuns, paragraphProperties.FlowDirection, objectPool); @@ -1013,7 +1036,11 @@ namespace Avalonia.Media.TextFormatting lineTextRuns.RemoveAt(lastTextRunIndex); - lineTextRuns.AddRange(textRuns); + if(textRuns is not null) + { + lineTextRuns.AddRange(textRuns); + } + lineTextRuns.AddRange(trailingWhitespaceRuns); } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index 1b11470f5e..e1f36b5f4c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -98,7 +98,8 @@ namespace Avalonia.Media.TextFormatting effectivePostSplitRuns = rentedPostSplitRuns; - foreach (var preSplitRun in rentedPreSplitRuns) + // rentedPreSplitRuns cannot be null here as _prefixLength > 0 and measuredLength > 0 + foreach (var preSplitRun in rentedPreSplitRuns!) { collapsedRuns.Add(preSplitRun); } diff --git a/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs new file mode 100644 index 0000000000..b3e4189ff9 --- /dev/null +++ b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using Avalonia.Media.TextFormatting; + +namespace Avalonia.Media +{ + /// + /// Provides text collapsing properties that replace the middle segments of a file path with an ellipsis symbol when + /// the rendered width exceeds a specified limit. + /// + /// This class is typically used to display file paths in a compact form by collapsing segments + /// near the center and inserting an ellipsis, ensuring that the most significant parts of the path remain visible. + /// + public sealed class TextPathSegmentEllipsis : TextCollapsingProperties + { + private readonly char[] _separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, '/', '\\' }; + + /// + /// Initializes a new instance of the TextPathSegmentEllipsis class that represents an ellipsis segment in a + /// text path with the specified symbol, width, text formatting properties, and flow direction. + /// + /// The string to use as the ellipsis symbol in the text path segment. Cannot be null. + /// The width. + /// The text formatting properties to apply to the ellipsis symbol. Cannot be null. + /// The flow direction for rendering the ellipsis segment. Specifies whether text flows left-to-right or + /// right-to-left. + public TextPathSegmentEllipsis(string ellipsis, double width, TextRunProperties textRunProperties, FlowDirection flowDirection) + { + Width = width; + Symbol = new TextCharacters(ellipsis, textRunProperties); + FlowDirection = flowDirection; + } + + public override double Width { get; } + + public override TextRun Symbol { get; } + + public override FlowDirection FlowDirection { get; } + + public override TextRun[]? Collapse(TextLine textLine) + { + if (textLine.TextRuns.Count == 0) + { + return null; + } + + var objectPool = FormattingObjectPool.Instance; + + var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection); + + if (Width < shapedSymbol.Size.Width) + { + // Nothing to collapse + return null; + } + + double totalWidth = textLine.Width; + + if (totalWidth <= Width) + { + // Nothing to collapse + return null; + } + + // Extract logical runs from the line + FormattingObjectPool.RentedList? logicalRuns = null; + + try + { + logicalRuns = objectPool.TextRunLists.Rent(); + + var enumerator = new LogicalTextRunEnumerator(textLine); + + while (enumerator.MoveNext(out var r)) + { + logicalRuns.Add(r); + } + + // Segment ranges + var segments = new List<(int Start, int Length, double Width, bool IsSeparator)>(); + var candidateSegmentIndices = new List(); + var globalIndex = 0; + var currentSegStart = 0; + var inSeparator = false; + + for (var i = 0; i < logicalRuns.Count; i++) + { + var run = logicalRuns[i]; + + if (run is ShapedTextRun shaped) + { + var span = shaped.Text.Span; + var localPos = 0; + + while (localPos < span.Length) + { + var ch = span[localPos]; + + var isSep = IsSeparator(ch); + + if (isSep) + { + // finish previous non-separator segment + if (!inSeparator && globalIndex - currentSegStart > 0) + { + var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart); + + segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false)); + } + + var separatorWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, globalIndex, 1); + + // separator as its own segment + segments.Add((globalIndex, 1, separatorWidth, true)); + + // next segment starts after separator + currentSegStart = globalIndex + 1; + inSeparator = true; + } + else + { + if (inSeparator) + { + // start of a non-separator segment + currentSegStart = globalIndex; + inSeparator = false; + } + } + + localPos++; + globalIndex++; + } + } + else + { + // Non shaped run is treated as non-separator + if (inSeparator) + { + currentSegStart = globalIndex; + inSeparator = false; + } + + globalIndex += run.Length; + } + } + + // Add last pending segment if any + if (globalIndex - currentSegStart > 0) + { + var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart); + + segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false)); + } + + if (segments.Count == 0) + { + // Nothing to collapse + return null; + } + + var prefix = new double[segments.Count + 1]; + + // Measure segment widths + for (int i = 0; i < segments.Count; i++) + { + var (Start, Length, SegmentWidth, IsSeparator) = segments[i]; + + if (!IsSeparator) + { + candidateSegmentIndices.Add(i); + } + + prefix[i + 1] = prefix[i] + SegmentWidth; + } + + // Determine center character index to prefer collapsing ranges near the middle. + var midChar = globalIndex / 2; + + // Find candidate whose center is closest to midChar + int centerCandidateIdx = 0; + long bestDist = long.MaxValue; + + for (int i = 0; i < candidateSegmentIndices.Count; ++i) + { + var (Start, Length, SegmentWidth, IsSeparator) = segments[candidateSegmentIndices[i]]; + var segCenter = Start + Length / 2; + var dist = Math.Abs(segCenter - midChar); + + if (dist < bestDist) + { + bestDist = dist; + centerCandidateIdx = i; + } + } + + // Expand windows around centerCandidateIdx, up to _maxCollapsedSegments segments. + var candidateCount = candidateSegmentIndices.Count; + + if (candidateCount > 0) + { + for (int windowSize = 1; windowSize <= candidateCount; windowSize++) + { + // For a given windowSize, try all windows of that size centered as close as possible to centerCandidateIdx. + // Compute start index of window such that center is as near as possible. + int half = (windowSize - 1) / 2; + int start = centerCandidateIdx - half; + // For even window sizes, prefer left-leaning start, also try shifting the window across the center. + var windowStarts = new List(); + + // clamp start range + int minStart = Math.Max(0, centerCandidateIdx - (windowSize - 1)); + int maxStart = Math.Min(candidateCount - windowSize, centerCandidateIdx + (windowSize - 1)); + + // Left side first + for (int s = start; s >= minStart; s--) + { + windowStarts.Add(s); + } + + // Right side next + for (int s = start + 1; s <= maxStart; s++) + { + windowStarts.Add(s); + } + + foreach (var windowStart in windowStarts) + { + if (windowStart < 0 || windowStart + windowSize > candidateCount) + { + continue; + } + + int leftCand = windowStart; + int rightCand = windowStart + windowSize - 1; + + // Map candidate window to segments range (in segments list) + int segStartIndex = candidateSegmentIndices[leftCand]; + int segEndIndex = candidateSegmentIndices[rightCand]; + + // Ensure that we leave at least one character on each side (prefer middle-only removal) + var leftRemaining = segments[segStartIndex].Start; + var rightRemaining = globalIndex - (segments[segEndIndex].Start + segments[segEndIndex].Length); + + if (leftRemaining <= 0 || rightRemaining <= 0) + { + continue; + } + + var trimmedWidth = prefix[segEndIndex + 1] - prefix[segStartIndex]; + + if (totalWidth - trimmedWidth + shapedSymbol.Size.Width <= Width) + { + // perform split using character indices + var removeStart = segments[segStartIndex].Start; + var removeLength = (segments[segEndIndex].Start + segments[segEndIndex].Length) - removeStart; + + FormattingObjectPool.RentedList? first = null; + FormattingObjectPool.RentedList? remainder = null; + FormattingObjectPool.RentedList? middle = null; + FormattingObjectPool.RentedList? last = null; + + try + { + (first, remainder) = TextFormatterImpl.SplitTextRuns(logicalRuns, removeStart, objectPool); + + if (remainder == null) + { + // We reached the end + return null; + } + + (middle, last) = TextFormatterImpl.SplitTextRuns(remainder, removeLength, objectPool); + + // Build resulting runs + // first + shapedSymbol + last + var result = new TextRun[(first?.Count ?? 0) + 1 + (last?.Count ?? 0)]; + var index = 0; + + if (first != null) + { + foreach (var run in first) + { + result[index++] = run; + } + } + + result[index++] = shapedSymbol; + + if (last != null) + { + foreach (var run in last) + { + result[index++] = run; + } + } + + return result; + } + finally + { + // Return rented lists + objectPool.TextRunLists.Return(ref first); + objectPool.TextRunLists.Return(ref remainder); + objectPool.TextRunLists.Return(ref middle); + objectPool.TextRunLists.Return(ref last); + } + } + } + } + } + + // Fallback - try to trim at segment boundaries from start + var currentLength = 0; + var remainingWidth = textLine.WidthIncludingTrailingWhitespace; + + for (var segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++) + { + var segment = segments[segmentIndex]; + + if (segmentIndex < segments.Count - 1 && remainingWidth - segment.Width > Width) + { + remainingWidth -= segment.Width; + currentLength += segment.Length; + + continue; + } + + FormattingObjectPool.RentedList? first = null; + FormattingObjectPool.RentedList? second = null; + + try + { + // Split before current segment + (first, second) = TextFormatterImpl.SplitTextRuns(logicalRuns, currentLength, objectPool); + + TextRun? trimmedRun = null; + var remainingRunCount = 0; + + if (second != null && second.Count > 0) + { + remainingRunCount = Math.Max(0, second.Count - 1); + + var run = second[0]; + + if (run is ShapedTextRun shapedRun) + { + var measureWidth = Width - shapedSymbol.Size.Width; + + if (shapedRun.TryMeasureCharactersBackwards(measureWidth, out var length, out _)) + { + var splitAt = shapedRun.Length - length; + + (_, trimmedRun) = shapedRun.Split(splitAt); + } + } + } + + var runCount = (trimmedRun != null ? 1 : 0) + 1 + remainingRunCount; + + var result = new TextRun[runCount]; + var index = 0; + + // Append symbol + result[index++] = shapedSymbol; + + // Append trimmed run if any + if (trimmedRun != null) + { + result[index++] = trimmedRun; + } + + // Append remaining runs + if (second != null) + { + for(var i = 1; i < second.Count; i++) + { + var run = second[i]; + + result[index++] = run; + } + } + + return result; + + } + finally + { + // Return rented lists + objectPool.TextRunLists.Return(ref first); + objectPool.TextRunLists.Return(ref second); + } + } + + // No suitable segment found + return null; + } + finally + { + objectPool.TextRunLists.Return(ref logicalRuns); + } + } + + private bool IsSeparator(char ch) + { + foreach (var s in _separators) + { + if (s == ch) + { + return true; + } + } + + return false; + } + + /// + /// Finds the index of the text run and the offset within that run corresponding to the specified character + /// index. + /// + /// If the specified character index does not fall within any run, the method returns + + /// + /// Calculates the total width of a specified segment within a sequence of text runs. + /// + /// The method accounts for partial overlaps between the segment and individual text + /// runs. Drawable runs are measured as a whole if any part overlaps the segment. + /// The collection of text runs to measure. Each run represents a contiguous sequence of formatted text. + /// The zero-based index of the first character in the segment to measure, relative to the combined text runs. + /// The number of characters in the segment to measure. Must be non-negative. + /// The total width, in device-independent units, of the specified text segment. Returns 0.0 if the segment is + /// empty or does not overlap any runs. + private static double MeasureSegmentWidth(IReadOnlyList runs, int segmentStart, int segmentLength) + { + // segment range in global character indices + var segmentEnd = segmentStart + segmentLength; + var currentChar = 0; + double width = 0.0; + + for (var i = 0; i < runs.Count; i++) + { + var run = runs[i]; + var runStart = currentChar; + var runEnd = runStart + run.Length; + + // no overlap with requested segment + if (runEnd <= segmentStart) + { + currentChar = runEnd; + continue; + } + + if (runStart >= segmentEnd) + { + break; + } + + // overlap range within this run [overlapStart, overlapEnd) + var overlapStart = Math.Max(segmentStart, runStart); + var overlapEnd = Math.Min(segmentEnd, runEnd); + var overlapLen = overlapEnd - overlapStart; + + if (overlapLen <= 0) + { + currentChar = runEnd; + continue; + } + + switch (run) + { + case ShapedTextRun shaped: + { + var buffer = shaped.ShapedBuffer; + if (buffer.Length == 0) + { + break; + } + + // local char offsets inside this run + var localStart = overlapStart - runStart; + var localEnd = overlapEnd - runStart; + + // base cluster used by this buffer (see ShapedBuffer.Split logic) + var baseCluster = buffer[0].GlyphCluster; + + // glyph clusters are increasing — stop once we passed localEnd + for (var gi = 0; gi < buffer.Length; gi++) + { + var g = buffer[gi]; + var clusterLocal = g.GlyphCluster - baseCluster; + + if (clusterLocal < localStart) + continue; + + if (clusterLocal >= localEnd) + break; + + width += g.GlyphAdvance; + } + + break; + } + case DrawableTextRun d: + { + // For drawable runs, count full width if they completely overlap + if (overlapLen >= d.Length) + { + width += d.Size.Width; + } + break; + } + default: + { + break; + } + } + + currentChar = runEnd; + } + + return width; + } + } +} diff --git a/src/Avalonia.Base/Media/TextPathSegmentTrimming.cs b/src/Avalonia.Base/Media/TextPathSegmentTrimming.cs new file mode 100644 index 0000000000..9596f65216 --- /dev/null +++ b/src/Avalonia.Base/Media/TextPathSegmentTrimming.cs @@ -0,0 +1,38 @@ +using Avalonia.Media.TextFormatting; + +namespace Avalonia.Media +{ + /// + /// Provides a text trimming strategy that collapses overflowing text by replacing path segments with an ellipsis + /// string. + /// + /// Use this class to trim text representing file or URI paths, replacing intermediate segments + /// with a specified ellipsis when the text exceeds the available width. This approach helps preserve the most + /// relevant parts of the path, such as the filename or endpoint, while indicating omitted segments. The ellipsis + /// string can be customized to match application requirements. + public sealed class TextPathSegmentTrimming : TextTrimming + { + private readonly string _ellipsis; + + /// + /// Initializes a new instance of the TextPathSegmentTrimming class with the specified ellipsis string to + /// indicate trimmed text. + /// + /// The string to use as an ellipsis when text is trimmed. This value is displayed at the end of truncated + /// segments. + public TextPathSegmentTrimming(string ellipsis) + { + _ellipsis = ellipsis; + } + + public override TextCollapsingProperties CreateCollapsingProperties(TextCollapsingCreateInfo createInfo) + { + return new TextPathSegmentEllipsis(_ellipsis, createInfo.Width, createInfo.TextRunProperties, createInfo.FlowDirection); + } + + public override string ToString() + { + return nameof(PathSegmentEllipsis); + } + } +} diff --git a/src/Avalonia.Base/Media/TextTrimming.cs b/src/Avalonia.Base/Media/TextTrimming.cs index 34642c11df..b85035920a 100644 --- a/src/Avalonia.Base/Media/TextTrimming.cs +++ b/src/Avalonia.Base/Media/TextTrimming.cs @@ -35,6 +35,14 @@ namespace Avalonia.Media /// public static TextTrimming LeadingCharacterEllipsis { get; } = new TextLeadingPrefixTrimming(DefaultEllipsisChar, 0); + /// + /// Gets a text trimming strategy that inserts an ellipsis to indicate omitted segments in a path string. + /// + /// Use this property to display long file or directory paths in a shortened form, with + /// an ellipsis representing omitted segments. This is useful for UI scenarios where space is limited and the + /// full path cannot be shown. + public static TextTrimming PathSegmentEllipsis { get; } = new TextPathSegmentTrimming(DefaultEllipsisChar); + /// /// Creates properties that will be used for collapsing lines of text. /// @@ -69,6 +77,10 @@ namespace Avalonia.Media { return PrefixCharacterEllipsis; } + else if (Matches(nameof(PathSegmentEllipsis))) + { + return PathSegmentEllipsis; + } throw new FormatException($"Invalid text trimming string: '{s}'."); } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 92a175be78..1ac54721c4 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -2042,7 +2042,6 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var defaultProperties = new GenericTextRunProperties(typeface); var text = "a\u202C\u202C\u202C\u202Cb"; - var shaperOption = new TextShaperOptions(typeface.GlyphTypeface); var textSource = new SingleBufferTextSource(text, defaultProperties); @@ -2308,6 +2307,189 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Should_Collapse_With_TextPathSegmentTrimming_Without_PathSegment() + { + var text = "foo"; + + using (Start()) + { + var typeface = Typeface.Default; + + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var trimming = new TextPathSegmentTrimming("*"); + + var collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(15, defaultProperties, FlowDirection.LeftToRight)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var result = ExtractTextFromRuns(collapsedLine); + + Assert.Equal("*o", result); + } + } + + [Fact] + public void Should_Collapse_With_TextPathSegmentTrimming_NoSpace() + { + var text = "foo"; + + using (Start()) + { + var typeface = Typeface.Default; + + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var trimming = new TextPathSegmentTrimming("*"); + + var collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(8, defaultProperties, FlowDirection.LeftToRight)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var result = ExtractTextFromRuns(collapsedLine); + + Assert.Equal("*", result); + } + } + + [Theory] + [InlineData("somedirectory\\")] + [InlineData("somedirectory/")] + public void TruncatePath_PathEndingWithSlash_ReturnsNonEmpty(string path) + { + var typeface = Typeface.Default; + + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource(path, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var trimming = new TextPathSegmentTrimming("*"); + + var collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(50, defaultProperties, FlowDirection.LeftToRight)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var result = ExtractTextFromRuns(collapsedLine); + + Assert.True(result.Contains("ory")); + } + } + + [Theory] + [InlineData("directory\\file.txt")] + [InlineData("directory/file.txt")] + public void Should_Collapse_With_Ellipsis(string path) + { + var typeface = Typeface.Default; + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource(path, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var trimming = new TextPathSegmentTrimming("*"); + + var collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(8, defaultProperties, FlowDirection.LeftToRight)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var result = ExtractTextFromRuns(collapsedLine); + + Assert.Equal("*", result); + } + } + + + [Fact] + public void Should_Trim_Path_At_The_End() + { + string text = "verylongdirectory\\file.txt"; + + using (Start()) + { + var typeface = Typeface.Default; + + var defaultProperties = new GenericTextRunProperties(typeface); + + var textSource = new SingleBufferTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new GenericTextParagraphProperties(defaultProperties)); + + Assert.NotNull(textLine); + + var trimming = new TextPathSegmentTrimming("*"); + + var collapsingProperties = trimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(40, defaultProperties, FlowDirection.LeftToRight)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var result = ExtractTextFromRuns(collapsedLine); + + Assert.Equal("*.txt", result); + } + } + + public static string ExtractTextFromRuns(TextLine textLine) + { + // Only extract text for ShapedTextRun instances. + return string.Concat(textLine.TextRuns + .OfType() + .Select(r => r.Text.ToString())); + } + private class FixedRunsTextSource : ITextSource { private readonly IReadOnlyList _textRuns;