Browse Source

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 <julien@lebosquain.net>
release/11.3.10
Benedikt Stebner 2 months ago
committed by Julien Lebosquain
parent
commit
b30254ae4a
No known key found for this signature in database GPG Key ID: 1833CAD10ACC46FD
  1. 22
      src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
  2. 6
      src/Avalonia.Base/Media/TextFormatting/SplitResult.cs
  3. 35
      src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
  4. 3
      src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs
  5. 525
      src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs
  6. 38
      src/Avalonia.Base/Media/TextPathSegmentTrimming.cs
  7. 12
      src/Avalonia.Base/Media/TextTrimming.cs
  8. 184
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

22
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<ShapedTextRun>(null, first);
}
throw new InvalidOperationException($"Cannot split: requested length {length} consumes entire run.");
}
var second = new ShapedTextRun(splitBuffer.Second, Properties);
if (isReversed)
{

6
src/Avalonia.Base/Media/TextFormatting/SplitResult.cs

@ -4,7 +4,7 @@
public readonly struct SplitResult<T>
#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 @@
/// <value>
/// The first part.
/// </value>
public T First { get; }
public T? First { get; }
/// <summary>
/// Gets the second part.
@ -32,7 +32,7 @@
/// </summary>
/// <param name="first">On return, contains the first part.</param>
/// <param name="second">On return, contains the second part.</param>
public void Deconstruct(out T first, out T? second)
public void Deconstruct(out T? first, out T? second)
{
first = First;
second = Second;

35
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@ -93,6 +93,18 @@ namespace Avalonia.Media.TextFormatting
internal static SplitResult<RentedList<TextRun>> SplitTextRuns(IReadOnlyList<TextRun> 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<RentedList<TextRun>>(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);
}
}

3
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);
}

525
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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>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.
/// </remarks>
public sealed class TextPathSegmentEllipsis : TextCollapsingProperties
{
private readonly char[] _separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, '/', '\\' };
/// <summary>
/// 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.
/// </summary>
/// <param name="ellipsis">The string to use as the ellipsis symbol in the text path segment. Cannot be null.</param>
/// <param name="width">The width.</param>
/// <param name="textRunProperties">The text formatting properties to apply to the ellipsis symbol. Cannot be null.</param>
/// <param name="flowDirection">The flow direction for rendering the ellipsis segment. Specifies whether text flows left-to-right or
/// right-to-left.</param>
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<TextRun>? 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<int>();
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<int>();
// 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<TextRun>? first = null;
FormattingObjectPool.RentedList<TextRun>? remainder = null;
FormattingObjectPool.RentedList<TextRun>? middle = null;
FormattingObjectPool.RentedList<TextRun>? 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<TextRun>? first = null;
FormattingObjectPool.RentedList<TextRun>? 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;
}
/// <summary>
/// Finds the index of the text run and the offset within that run corresponding to the specified character
/// index.
/// </summary>
/// <remarks>If the specified character index does not fall within any run, the method returns
/// <summary>
/// Calculates the total width of a specified segment within a sequence of text runs.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="runs">The collection of text runs to measure. Each run represents a contiguous sequence of formatted text.</param>
/// <param name="segmentStart">The zero-based index of the first character in the segment to measure, relative to the combined text runs.</param>
/// <param name="segmentLength">The number of characters in the segment to measure. Must be non-negative.</param>
/// <returns>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.</returns>
private static double MeasureSegmentWidth(IReadOnlyList<TextRun> 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;
}
}
}

38
src/Avalonia.Base/Media/TextPathSegmentTrimming.cs

@ -0,0 +1,38 @@
using Avalonia.Media.TextFormatting;
namespace Avalonia.Media
{
/// <summary>
/// Provides a text trimming strategy that collapses overflowing text by replacing path segments with an ellipsis
/// string.
/// </summary>
/// <remarks>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.</remarks>
public sealed class TextPathSegmentTrimming : TextTrimming
{
private readonly string _ellipsis;
/// <summary>
/// Initializes a new instance of the TextPathSegmentTrimming class with the specified ellipsis string to
/// indicate trimmed text.
/// </summary>
/// <param name="ellipsis">The string to use as an ellipsis when text is trimmed. This value is displayed at the end of truncated
/// segments.</param>
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);
}
}
}

12
src/Avalonia.Base/Media/TextTrimming.cs

@ -35,6 +35,14 @@ namespace Avalonia.Media
/// </summary>
public static TextTrimming LeadingCharacterEllipsis { get; } = new TextLeadingPrefixTrimming(DefaultEllipsisChar, 0);
/// <summary>
/// Gets a text trimming strategy that inserts an ellipsis to indicate omitted segments in a path string.
/// </summary>
/// <remarks>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.</remarks>
public static TextTrimming PathSegmentEllipsis { get; } = new TextPathSegmentTrimming(DefaultEllipsisChar);
/// <summary>
/// Creates properties that will be used for collapsing lines of text.
/// </summary>
@ -69,6 +77,10 @@ namespace Avalonia.Media
{
return PrefixCharacterEllipsis;
}
else if (Matches(nameof(PathSegmentEllipsis)))
{
return PathSegmentEllipsis;
}
throw new FormatException($"Invalid text trimming string: '{s}'.");
}

184
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<ShapedTextRun>()
.Select(r => r.Text.ToString()));
}
private class FixedRunsTextSource : ITextSource
{
private readonly IReadOnlyList<TextRun> _textRuns;

Loading…
Cancel
Save