diff --git a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs
index 7b391801a6..514c39edd4 100644
--- a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs
@@ -3,6 +3,21 @@ using System.Diagnostics.CodeAnalysis;
namespace Avalonia.Media.TextFormatting;
+///
+/// Walks the runs of a in logical (source-text)
+/// order. This is the order that splits and length-based offsets are defined
+/// in, and is what every
+/// implementation needs to see — unlike ,
+/// which exposes the post-BiDi visual ordering used for rendering.
+///
+///
+/// When the line has been finalized (the normal case after
+/// TextLineImpl.FinalizeLine), the enumerator iterates over
+/// _indexedTextRuns — a level-resolved table that maps each run back
+/// to its original logical position. If the line hasn't been finalized (or
+/// the line is not a TextLineImpl), it falls back to the raw
+/// list.
+///
internal ref struct LogicalTextRunEnumerator
{
private readonly IReadOnlyList? _textRuns;
@@ -61,7 +76,7 @@ internal ref struct LogicalTextRunEnumerator
}
else if (_textRuns != null)
{
- run = _textRuns[0];
+ run = _textRuns[_index];
}
else
{
diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
index 581ed901d0..7be486717e 100644
--- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
@@ -307,57 +307,6 @@ namespace Avalonia.Media.TextFormatting
}
}
- ///
- /// Finds how many text characters from the start of this buffer fit within
- /// , walking in logical cluster order.
- /// Returns the character count and the width consumed (always <= availableWidth
- /// unless the very first cluster overflows, in which case the caller is expected
- /// to honour the overflow contract documented in TextFormatterImpl.MeasureLength).
- ///
- internal int MeasureCharactersThatFit(double availableWidth, out double widthConsumed)
- {
- var prefix = EnsureClusterCache();
- var startIdx = _clusterStartIdx;
- var count = _clusterCount;
-
- if (count == 0)
- {
- widthConsumed = 0d;
- return 0;
- }
-
- // Find the largest k such that prefix[startIdx + k] - prefix[startIdx] <=
- // availableWidth. Standard binary search on the prefix-sum array, with the
- // sub-buffer's base width subtracted out.
- var basePrefix = prefix[startIdx];
-
- var lo = 0;
- var hi = count;
- while (lo < hi)
- {
- var mid = (lo + hi + 1) >> 1;
- if (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth))
- {
- lo = mid;
- }
- else
- {
- hi = mid - 1;
- }
- }
-
- widthConsumed = prefix[startIdx + lo] - basePrefix;
-
- // Simple-mode fast path: 1 char per cluster, so the consumed char count
- // equals the cluster offset we just resolved (no start-chars table needed).
- var starts = _clusterStartChars;
- if (starts is null)
- {
- return lo;
- }
- return starts[startIdx + lo] - starts[startIdx];
- }
-
///
/// Returns the character length of the first logical cluster in this buffer.
/// Used by MeasureLength to satisfy the "include at least one cluster"
@@ -799,5 +748,187 @@ namespace Avalonia.Media.TextFormatting
return new SplitResult(first, second);
}
+
+ ///
+ /// Returns the cumulative glyph advance for the logical character range
+ /// [, )
+ /// within this sub-buffer. Uses the cluster cache via binary search, so
+ /// each call is O(log clusters) regardless of how big the buffer is or
+ /// where the range sits inside it.
+ ///
+ ///
+ /// The cluster cache is built in logical order for both LTR and
+ /// RTL buffers (see ), so callers pass
+ /// logical char offsets and the same code path serves both directions.
+ /// Out-of-range arguments are clamped to [0, Text.Length].
+ ///
+ internal double GetCharRangeWidth(int startChar, int endChar)
+ {
+ if (endChar <= startChar)
+ {
+ return 0d;
+ }
+
+ var prefix = EnsureClusterCache();
+ var startIdx = _clusterStartIdx;
+ var count = _clusterCount;
+
+ var starts = _clusterStartChars;
+ int startBoundary;
+ int endBoundary;
+ if (starts is null)
+ {
+ // Simple mode: one char per cluster, so the local char offset equals
+ // the local cluster index. Clamp to [0, count] to mirror the
+ // out-of-range handling of FindLargestClusterAtOrBefore.
+ startBoundary = Math.Clamp(startChar, 0, count);
+ endBoundary = Math.Clamp(endChar, 0, count);
+ }
+ else
+ {
+ var baseChar = starts[startIdx];
+ startBoundary = FindLargestClusterAtOrBefore(starts, startIdx, count, baseChar, startChar);
+ endBoundary = FindLargestClusterAtOrBefore(starts, startIdx, count, baseChar, endChar);
+ }
+
+ return prefix[startIdx + endBoundary] - prefix[startIdx + startBoundary];
+ }
+
+ ///
+ /// Binary-search the largest cluster boundary index i ∈ [0, count]
+ /// such that starts[startIdx + i] - baseChar ≤ charPos. Cluster
+ /// starts are non-decreasing within the sub-buffer range, so a standard
+ /// upper-bound search works in both LTR and RTL buffers (the cache is
+ /// always built in logical order).
+ ///
+ private static int FindLargestClusterAtOrBefore(int[] starts, int startIdx, int count, int baseChar, int charPos)
+ {
+ if (charPos < 0)
+ {
+ return 0;
+ }
+
+ // Standard "rightmost <= charPos" upper-bound shape.
+ var lo = 0;
+ var hi = count;
+ while (lo < hi)
+ {
+ var mid = (lo + hi + 1) >> 1;
+ if (starts[startIdx + mid] - baseChar <= charPos)
+ {
+ lo = mid;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+ return lo;
+ }
+
+ ///
+ /// Finds the largest N such that the first N logical
+ /// characters of this sub-buffer fit within .
+ /// Cluster-atomic: a multi-glyph cluster either fits completely or not at
+ /// all. Returns 0 if is non-positive
+ /// or the first cluster's width already exceeds it.
+ ///
+ ///
+ /// Walks the cluster cache (built in logical order for both LTR and RTL
+ /// buffers) via binary search, so each call is O(log clusters) and the
+ /// returned count is the correct logical-leading char count regardless
+ /// of the buffer's visual direction.
+ ///
+ internal int FindLeadingCharCountWithinWidth(double availableWidth)
+ {
+ if (availableWidth <= 0)
+ {
+ return 0;
+ }
+
+ var prefix = EnsureClusterCache();
+ var startIdx = _clusterStartIdx;
+ var count = _clusterCount;
+
+ var basePrefix = prefix[startIdx];
+
+ // Largest k in [0, count] with prefix[startIdx + k] - basePrefix <= availableWidth.
+ var lo = 0;
+ var hi = count;
+ while (lo < hi)
+ {
+ var mid = (lo + hi + 1) >> 1;
+ if (MathUtilities.LessThanOrClose(prefix[startIdx + mid] - basePrefix, availableWidth))
+ {
+ lo = mid;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+
+ var starts = _clusterStartChars;
+ if (starts is null)
+ {
+ // Simple mode: char count == cluster count that fits.
+ return lo;
+ }
+ return starts[startIdx + lo] - starts[startIdx];
+ }
+
+ ///
+ /// Finds the largest N such that the last N logical
+ /// characters of this sub-buffer fit within .
+ /// Cluster-atomic; reports the actual
+ /// cumulative advance of those N chars.
+ ///
+ ///
+ /// O(log clusters) via the cluster cache; direction-agnostic (cache is
+ /// always in logical order). The returned count is the logical-trailing
+ /// char count regardless of whether the buffer is LTR or RTL.
+ ///
+ internal int FindTrailingCharCountWithinWidth(double availableWidth, out double consumedWidth)
+ {
+ consumedWidth = 0;
+
+ if (availableWidth <= 0)
+ {
+ return 0;
+ }
+
+ var prefix = EnsureClusterCache();
+ var startIdx = _clusterStartIdx;
+ var count = _clusterCount;
+
+ var endPrefix = prefix[startIdx + count];
+
+ // Smallest k in [0, count] with endPrefix - prefix[startIdx + k] <= availableWidth.
+ // (That cluster index marks where the trailing-fitting suffix starts.)
+ var lo = 0;
+ var hi = count;
+ while (lo < hi)
+ {
+ var mid = (lo + hi) >> 1;
+ if (MathUtilities.LessThanOrClose(endPrefix - prefix[startIdx + mid], availableWidth))
+ {
+ hi = mid;
+ }
+ else
+ {
+ lo = mid + 1;
+ }
+ }
+
+ consumedWidth = endPrefix - prefix[startIdx + lo];
+
+ var starts = _clusterStartChars;
+ if (starts is null)
+ {
+ // Simple mode: char count == cluster count in the trailing suffix.
+ return count - lo;
+ }
+ return starts[startIdx + count] - starts[startIdx + lo];
+ }
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
index a75734f356..e8f9e37700 100644
--- a/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/ShapedTextRun.cs
@@ -103,97 +103,33 @@ namespace Avalonia.Media.TextFormatting
}
///
- /// Measures the number of characters that fit into available width.
+ /// Returns the largest count of logical leading characters of this
+ /// run that fit within . Cluster-atomic
+ /// and direction-agnostic — for RTL runs the result is the count of chars
+ /// from the logical start (not the visually-leftmost chars, which would
+ /// be the logical tail).
///
/// The available width.
/// The count of fitting characters.
///
- /// true if characters fit into the available width; otherwise, false.
+ /// true if at least one character fits within
+ /// ; otherwise false.
///
public bool TryMeasureCharacters(double availableWidth, out int length)
{
- length = 0;
-
- if (ShapedBuffer.Length == 0)
- {
- return false;
- }
-
- var currentWidth = 0.0;
- var charactersSpan = GlyphRun.Characters.Span;
- var isLeftToRight = ShapedBuffer.IsLeftToRight;
- var bufferLength = ShapedBuffer.Length;
- var textLength = Text.Length;
-
- // Previous visual glyph's cluster — used in RTL mode to compute the char count
- // contributed by the current glyph (which spans [currentCluster, prevCluster) logically).
- var previousCluster = 0;
-
- for (var i = 0; i < bufferLength; i++)
- {
- var advance = ShapedBuffer[i].GlyphAdvance;
- var currentCluster = ShapedBuffer[i].GlyphCluster;
-
- if (currentWidth + advance > availableWidth)
- {
- break;
- }
-
- int count;
-
- if (isLeftToRight)
- {
- if (i + 1 < bufferLength)
- {
- var nextCluster = ShapedBuffer[i + 1].GlyphCluster;
- count = nextCluster - currentCluster;
- }
- else
- {
- Codepoint.ReadAt(charactersSpan, length, out count);
- }
- }
- else
- {
- if (i == 0)
- {
- count = textLength - currentCluster;
- }
- else
- {
- count = previousCluster - currentCluster;
- }
- }
-
- length += count;
- currentWidth += advance;
- previousCluster = currentCluster;
- }
-
+ length = ShapedBuffer.FindLeadingCharCountWithinWidth(availableWidth);
return length > 0;
}
+ ///
+ /// Returns the largest count of logical trailing characters of
+ /// this run that fit within , along
+ /// with the cumulative advance they consume. Cluster-atomic and
+ /// direction-agnostic.
+ ///
internal bool TryMeasureCharactersBackwards(double availableWidth, out int length, out double width)
{
- length = 0;
- width = 0;
- var charactersSpan = GlyphRun.Characters.Span;
-
- for (var i = ShapedBuffer.Length - 1; i >= 0; i--)
- {
- var advance = ShapedBuffer[i].GlyphAdvance;
-
- if (width + advance > availableWidth)
- {
- break;
- }
-
- Codepoint.ReadAt(charactersSpan, length, out var count);
-
- length += count;
- width += advance;
- }
-
+ length = ShapedBuffer.FindTrailingCharCountWithinWidth(availableWidth, out width);
return length > 0;
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs
index b621dada54..e3796fc8c5 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs
@@ -21,9 +21,26 @@
public abstract FlowDirection FlowDirection { get; }
///
- /// Collapses given text line.
+ /// Collapses the given text line and returns the resulting runs, or
+ /// if no collapse is needed (the consumer
+ /// then keeps the original line unchanged).
///
/// Text line to collapse.
+ ///
+ /// Implementations MUST return runs in logical order. The
+ /// consumer (TextLineImpl.Collapse) wraps the returned array
+ /// in a new and re-runs the BiDi reorderer
+ /// via FinalizeLine, so pre-applying visual order here would
+ /// be reordered a second time and produce garbled output on RTL or
+ /// mixed-bidi lines.
+ ///
+ /// Iterate the source line's runs via
+ /// LogicalTextRunEnumerator, not
+ /// (which is post-bidi visual order). Use
+ /// when an implementation only
+ /// needs the standard "logical prefix + symbol" shape.
+ ///
+ ///
public abstract TextRun[]? Collapse(TextLine textLine);
///
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
index b402525891..9736b5ea78 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs
@@ -787,7 +787,7 @@ namespace Avalonia.Media.TextFormatting
}
// Some part of the buffer overflows: find the cluster boundary.
- var runLength = buffer.MeasureCharactersThatFit(remaining, out _);
+ var runLength = buffer.FindLeadingCharCountWithinWidth(remaining);
// "Include at least one cluster" rule preserves the existing
// contract that the caller always advances by at least one
@@ -810,7 +810,7 @@ namespace Avalonia.Media.TextFormatting
case DrawableTextRun drawableTextRun:
{
- if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth)
+ if (MathUtilities.GreaterThan(currentWidth + drawableTextRun.Size.Width, paragraphWidth))
{
return measuredLength;
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs
index e1f36b5f4c..10bd3e13d9 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs
@@ -1,6 +1,7 @@
// ReSharper disable ForCanBeConvertedToForeach
using System;
using System.Collections.Generic;
+using Avalonia.Utilities;
using static Avalonia.Media.TextFormatting.FormattingObjectPool;
namespace Avalonia.Media.TextFormatting
@@ -27,7 +28,7 @@ namespace Avalonia.Media.TextFormatting
TextRunProperties textRunProperties,
FlowDirection flowDirection)
{
- if (_prefixLength < 0)
+ if (prefixLength < 0)
{
throw new ArgumentOutOfRangeException(nameof(prefixLength));
}
@@ -49,142 +50,205 @@ namespace Avalonia.Media.TextFormatting
///
public override TextRun[]? Collapse(TextLine textLine)
{
- var textRuns = textLine.TextRuns;
-
- var runIndex = 0;
- var currentWidth = 0.0;
- var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection.LeftToRight);
-
- if (Width < shapedSymbol.GlyphRun.Bounds.Width)
+ // Materialize runs in LOGICAL order. The consumer (TextLineImpl.Collapse)
+ // wraps our result in a new TextLine and runs the BiDi reorderer via
+ // FinalizeLine, so we must hand back runs in logical order — not the
+ // visual order exposed via textLine.TextRuns.
+ var objectPool = FormattingObjectPool.Instance;
+ var logicalRuns = objectPool.TextRunLists.Rent();
+
+ try
{
- return Array.Empty();
- }
+ var enumerator = new LogicalTextRunEnumerator(textLine);
+ while (enumerator.MoveNext(out var r))
+ {
+ logicalRuns.Add(r);
+ }
- // Overview of ellipsis structure
- // Prefix length run | Ellipsis symbol | Post split run growing from the end |
- var availableWidth = Width - shapedSymbol.Size.Width;
+ var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection);
- while (runIndex < textRuns.Count)
- {
- var currentRun = textRuns[runIndex];
+ if (MathUtilities.LessThan(Width, shapedSymbol.GlyphRun.Bounds.Width))
+ {
+ return Array.Empty();
+ }
+
+ // Overview of ellipsis structure
+ // Prefix length run | Ellipsis symbol | Post split run growing from the end |
+ var totalBudget = Width - shapedSymbol.Size.Width;
+ var availableWidth = totalBudget;
+ var charsBeforeCurrentRun = 0;
- switch (currentRun)
+ for (var runIndex = 0; runIndex < logicalRuns.Count; runIndex++)
{
- case ShapedTextRun shapedRun:
- {
- currentWidth += shapedRun.Size.Width;
+ var currentRun = logicalRuns[runIndex];
- if (currentWidth > availableWidth)
+ switch (currentRun)
+ {
+ case ShapedTextRun shapedRun:
{
- shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength);
-
- if (measuredLength > 0)
+ // Per-run check: does THIS run alone exceed what's left?
+ // (The earlier `currentWidth +=` / `currentWidth > availableWidth`
+ // pattern was comparing cumulative-so-far against budget-remaining,
+ // which double-counted and tripped overflow far too early on
+ // multi-run lines.)
+ if (MathUtilities.GreaterThan(shapedRun.Size.Width, availableWidth))
{
- var objectPool = FormattingObjectPool.Instance;
-
- var collapsedRuns = objectPool.TextRunLists.Rent();
+ shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength);
- RentedList? rentedPreSplitRuns = null;
- RentedList? rentedPostSplitRuns = null;
+ var totalFitChars = charsBeforeCurrentRun + measuredLength;
- try
+ if (totalFitChars > 0)
{
- IReadOnlyList? effectivePostSplitRuns;
+ var collapsedRuns = objectPool.TextRunLists.Rent();
- if (_prefixLength > 0)
+ RentedList? rentedPreSplitRuns = null;
+ RentedList? rentedPostSplitRuns = null;
+ RentedList? reversedSuffix = null;
+
+ try
{
- (rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns(
- textRuns, Math.Min(_prefixLength, measuredLength), objectPool);
+ IReadOnlyList? effectivePostSplitRuns;
- effectivePostSplitRuns = rentedPostSplitRuns;
+ // Split at GLOBAL character index totalFitChars-capped-by-prefixLength.
+ // (Previously this used `Math.Min(_prefixLength, measuredLength)`
+ // treating per-run `measuredLength` as a global offset, which
+ // produced a prefix from the wrong characters on multi-run lines.)
+ var prefixCutoff = Math.Min(_prefixLength, totalFitChars);
- // rentedPreSplitRuns cannot be null here as _prefixLength > 0 and measuredLength > 0
- foreach (var preSplitRun in rentedPreSplitRuns!)
+ if (prefixCutoff > 0)
{
- collapsedRuns.Add(preSplitRun);
+ (rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns(
+ logicalRuns, prefixCutoff, objectPool);
+
+ effectivePostSplitRuns = rentedPostSplitRuns;
+
+ if (rentedPreSplitRuns is not null)
+ {
+ foreach (var preSplitRun in rentedPreSplitRuns)
+ {
+ collapsedRuns.Add(preSplitRun);
+ }
+ }
+ }
+ else
+ {
+ effectivePostSplitRuns = logicalRuns;
}
- }
- else
- {
- effectivePostSplitRuns = textRuns;
- }
- collapsedRuns.Add(shapedSymbol);
+ collapsedRuns.Add(shapedSymbol);
- if (measuredLength <= _prefixLength || effectivePostSplitRuns is null)
- {
- return collapsedRuns.ToArray();
- }
+ if (totalFitChars <= _prefixLength || effectivePostSplitRuns is null)
+ {
+ return collapsedRuns.ToArray();
+ }
- var availableSuffixWidth = availableWidth;
+ // Suffix budget = total budget minus the actual prefix width.
+ // (Previously this used the loop's `availableWidth` which had
+ // over-subtracted: it assumed entire fully-fitting runs went
+ // to the prefix, even when prefixLength capped the prefix
+ // partway through one of them. Deriving from the actual
+ // preSplit run widths gives the correct remaining budget.)
+ var availableSuffixWidth = totalBudget;
- if (rentedPreSplitRuns is not null)
- {
- foreach (var run in rentedPreSplitRuns)
+ if (rentedPreSplitRuns is not null)
{
- if (run is DrawableTextRun drawableTextRun)
+ foreach (var run in rentedPreSplitRuns)
{
- availableSuffixWidth -= drawableTextRun.Size.Width;
+ switch (run)
+ {
+ case ShapedTextRun preShaped:
+ availableSuffixWidth -= preShaped.Size.Width;
+ break;
+ case DrawableTextRun preDrawable:
+ availableSuffixWidth -= preDrawable.Size.Width;
+ break;
+ }
}
}
- }
- for (var i = effectivePostSplitRuns.Count - 1; i >= 0; i--)
- {
- var run = effectivePostSplitRuns[i];
+ // Walk the post-split runs from the logical tail back toward the
+ // prefix, fitting trailing characters into availableSuffixWidth.
+ // We collect each split into reversedSuffix here (so the LAST
+ // logical run lands at index 0) and then drain reversedSuffix
+ // backwards when appending to collapsedRuns, which restores
+ // LOGICAL order. FinalizeLine handles the visual re-bidi.
+ reversedSuffix = objectPool.TextRunLists.Rent();
- switch (run)
+ for (var i = effectivePostSplitRuns.Count - 1; i >= 0; i--)
{
- case ShapedTextRun endShapedRun:
+ var run = effectivePostSplitRuns[i];
+
+ switch (run)
{
- if (endShapedRun.TryMeasureCharactersBackwards(availableSuffixWidth,
- out var suffixCount, out var suffixWidth))
+ case ShapedTextRun endShapedRun:
{
- availableSuffixWidth -= suffixWidth;
-
- if (suffixCount > 0)
+ if (endShapedRun.TryMeasureCharactersBackwards(availableSuffixWidth,
+ out var suffixCount, out var suffixWidth))
{
- var splitSuffix =
- endShapedRun.Split(run.Length - suffixCount);
-
- collapsedRuns.Add(splitSuffix.Second!);
+ availableSuffixWidth -= suffixWidth;
+
+ var splitAt = run.Length - suffixCount;
+
+ if (splitAt > 0)
+ {
+ var splitSuffix = endShapedRun.Split(splitAt);
+
+ reversedSuffix.Add(splitSuffix.Second!);
+ }
+ else if (suffixCount > 0)
+ {
+ // The whole run fits in the remaining suffix budget, so no
+ // split is needed; use the run as-is. (Calling Split(0) throws.)
+ reversedSuffix.Add(endShapedRun);
+ }
+ // else: suffixCount == 0, nothing of this run survives.
}
- }
- break;
+ break;
+ }
}
}
- }
- return collapsedRuns.ToArray();
- }
- finally
- {
- objectPool.TextRunLists.Return(ref rentedPreSplitRuns);
- objectPool.TextRunLists.Return(ref rentedPostSplitRuns);
- objectPool.TextRunLists.Return(ref collapsedRuns);
+ for (var i = reversedSuffix.Count - 1; i >= 0; i--)
+ {
+ collapsedRuns.Add(reversedSuffix[i]);
+ }
+
+ return collapsedRuns.ToArray();
+ }
+ finally
+ {
+ objectPool.TextRunLists.Return(ref rentedPreSplitRuns);
+ objectPool.TextRunLists.Return(ref rentedPostSplitRuns);
+ objectPool.TextRunLists.Return(ref reversedSuffix);
+ objectPool.TextRunLists.Return(ref collapsedRuns);
+ }
}
+
+ return new TextRun[] { shapedSymbol };
}
- return new TextRun[] { shapedSymbol };
- }
+ availableWidth -= shapedRun.Size.Width;
- availableWidth -= shapedRun.Size.Width;
+ break;
+ }
+ case DrawableTextRun drawableTextRun:
+ {
+ availableWidth -= drawableTextRun.Size.Width;
- break;
- }
- case DrawableTextRun drawableTextRun:
- {
- availableWidth -= drawableTextRun.Size.Width;
+ break;
+ }
+ }
- break;
- }
- }
+ charsBeforeCurrentRun += currentRun.Length;
+ }
- runIndex++;
+ return null;
+ }
+ finally
+ {
+ objectPool.TextRunLists.Return(ref logicalRuns);
}
-
- return null;
}
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
index 8809a3fc48..6437d04298 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
@@ -1171,9 +1171,9 @@ namespace Avalonia.Media.TextFormatting
public override void Dispose()
{
- for (int i = 0; i < _textRuns.Length; i++)
+ foreach (var textRun in _textRuns)
{
- if (_textRuns[i] is ShapedTextRun shapedTextRun)
+ if (textRun is ShapedTextRun shapedTextRun)
{
shapedTextRun.Dispose();
}
@@ -1298,9 +1298,9 @@ namespace Avalonia.Media.TextFormatting
var lineHeight = _paragraphProperties.LineHeight;
var lineSpacing = _paragraphProperties.LineSpacing;
- for (var index = 0; index < _textRuns.Length; index++)
+ foreach (var run in _textRuns)
{
- switch (_textRuns[index])
+ switch (run)
{
case ShapedTextRun textRun:
{
@@ -1345,9 +1345,9 @@ namespace Avalonia.Media.TextFormatting
var inkBounds = new Rect();
- for (var index = 0; index < _textRuns.Length; index++)
+ foreach (var run in _textRuns)
{
- switch (_textRuns[index])
+ switch (run)
{
case ShapedTextRun textRun:
{
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
index d2545f42e2..13cad83d0f 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextRunCache.cs
@@ -171,9 +171,9 @@ namespace Avalonia.Media.TextFormatting
private static void AddRefShapedRuns(TextRun[] runs)
{
- for (var i = 0; i < runs.Length; i++)
+ foreach (var run in runs)
{
- if (runs[i] is ShapedTextRun shaped)
+ if (run is ShapedTextRun shaped)
{
shaped.AddRef();
}
@@ -189,11 +189,9 @@ namespace Avalonia.Media.TextFormatting
private static void DisposeCachedRuns(CachedShapingResult result)
{
- var runs = result.ShapedRuns;
-
- for (var i = 0; i < runs.Length; i++)
+ foreach (var run in result.ShapedRuns)
{
- if (runs[i] is ShapedTextRun shaped)
+ if (run is ShapedTextRun shaped)
{
shaped.Dispose();
}
diff --git a/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs
index 700f861d4d..99693089a9 100644
--- a/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs
+++ b/src/Avalonia.Base/Media/TextPathSegmentEllipsis.cs
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using Avalonia.Media.TextFormatting;
+using Avalonia.Utilities;
namespace Avalonia.Media
{
@@ -50,7 +51,7 @@ namespace Avalonia.Media
var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection);
- if (Width < shapedSymbol.Size.Width)
+ if (MathUtilities.LessThan(Width, shapedSymbol.Size.Width))
{
// Nothing to collapse
return null;
@@ -58,7 +59,7 @@ namespace Avalonia.Media
double totalWidth = textLine.Width;
- if (totalWidth <= Width)
+ if (MathUtilities.LessThanOrClose(totalWidth, Width))
{
// Nothing to collapse
return null;
@@ -78,6 +79,18 @@ namespace Avalonia.Media
logicalRuns.Add(r);
}
+ // Pre-compute cumulative run start char positions so that
+ // MeasureSegmentWidth can binary-search to the first overlapping
+ // run instead of re-scanning from index 0 on every call. Built
+ // once per Collapse; reused by every segment-width measurement.
+ // runStartChars[i] = sum of lengths of runs 0..i-1;
+ // runStartChars[Count] = total char length (sentinel).
+ var runStartChars = new int[logicalRuns.Count + 1];
+ for (var i = 0; i < logicalRuns.Count; i++)
+ {
+ runStartChars[i + 1] = runStartChars[i] + logicalRuns[i].Length;
+ }
+
// Segment ranges
var segments = new List<(int Start, int Length, double Width, bool IsSeparator)>();
var candidateSegmentIndices = new List();
@@ -105,12 +118,12 @@ namespace Avalonia.Media
// finish previous non-separator segment
if (!inSeparator && globalIndex - currentSegStart > 0)
{
- var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart);
+ var segmentWidth = MeasureSegmentWidth(logicalRuns, runStartChars, currentSegStart, globalIndex - currentSegStart);
segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false));
}
- var separatorWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, globalIndex, 1);
+ var separatorWidth = MeasureSegmentWidth(logicalRuns, runStartChars, globalIndex, 1);
// separator as its own segment
segments.Add((globalIndex, 1, separatorWidth, true));
@@ -149,7 +162,7 @@ namespace Avalonia.Media
// Add last pending segment if any
if (globalIndex - currentSegStart > 0)
{
- var segmentWidth = TextPathSegmentEllipsis.MeasureSegmentWidth(logicalRuns, currentSegStart, globalIndex - currentSegStart);
+ var segmentWidth = MeasureSegmentWidth(logicalRuns, runStartChars, currentSegStart, globalIndex - currentSegStart);
segments.Add((currentSegStart, globalIndex - currentSegStart, segmentWidth, false));
}
@@ -250,7 +263,7 @@ namespace Avalonia.Media
var trimmedWidth = prefix[segEndIndex + 1] - prefix[segStartIndex];
- if (totalWidth - trimmedWidth + shapedSymbol.Size.Width <= Width)
+ if (MathUtilities.LessThanOrClose(totalWidth - trimmedWidth + shapedSymbol.Size.Width, Width))
{
// perform split using character indices
var removeStart = segments[segStartIndex].Start;
@@ -319,7 +332,7 @@ namespace Avalonia.Media
{
var segment = segments[segmentIndex];
- if (segmentIndex < segments.Count - 1 && remainingWidth - segment.Width > Width)
+ if (segmentIndex < segments.Count - 1 && MathUtilities.GreaterThan(remainingWidth - segment.Width, Width))
{
remainingWidth -= segment.Width;
currentLength += segment.Length;
@@ -352,7 +365,17 @@ namespace Avalonia.Media
{
var splitAt = shapedRun.Length - length;
- (_, trimmedRun) = shapedRun.Split(splitAt);
+ if (splitAt > 0)
+ {
+ (_, trimmedRun) = shapedRun.Split(splitAt);
+ }
+ else if (length > 0)
+ {
+ // The whole run fits in the remaining budget — no split needed,
+ // use the run as-is. (Calling Split(0) throws.)
+ trimmedRun = shapedRun;
+ }
+ // else: length == 0 → nothing of this run survives; trimmedRun stays null.
}
}
}
@@ -418,46 +441,51 @@ namespace Avalonia.Media
///
/// 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)
+ ///
+ /// Uses the pre-computed cumulative-offset table
+ /// to binary-search the first overlapping run (O(log N)) instead of re-scanning all
+ /// runs from index 0 on every call. For each shaped overlap, delegates to
+ /// , which uses the cluster-width cache —
+ /// O(log clusters) per call and direction-agnostic (the cache is built in logical
+ /// order for both LTR and RTL buffers). Drawable runs are measured as a whole if
+ /// they fully overlap the segment, matching the original behavior.
+ ///
+ /// The collection of text runs to measure.
+ /// Cumulative char-offset table; entry i is the
+ /// total length of runs 0..i-1, entry Count is the total char length.
+ /// Zero-based start index of the segment, relative to the combined text runs.
+ /// Number of characters in the segment. Must be non-negative.
+ /// The segment width in device-independent units, or 0 if the segment is empty or out of range.
+ private static double MeasureSegmentWidth(IReadOnlyList runs, int[] runStartChars, int segmentStart, int segmentLength)
{
- // segment range in global character indices
+ if (segmentLength <= 0)
+ {
+ return 0.0;
+ }
+
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;
+ // Binary search runStartChars for the largest i with runStartChars[i] <= segmentStart.
+ // That's the first run whose range can overlap the segment.
+ var i = FindFirstOverlappingRun(runStartChars, segmentStart);
- // no overlap with requested segment
- if (runEnd <= segmentStart)
- {
- currentChar = runEnd;
- continue;
- }
+ double width = 0.0;
+ for (; i < runs.Count; i++)
+ {
+ var runStart = runStartChars[i];
if (runStart >= segmentEnd)
{
break;
}
- // overlap range within this run [overlapStart, overlapEnd)
+ var run = runs[i];
+ var runEnd = runStart + run.Length;
+
var overlapStart = Math.Max(segmentStart, runStart);
var overlapEnd = Math.Min(segmentEnd, runEnd);
- var overlapLen = overlapEnd - overlapStart;
-
- if (overlapLen <= 0)
+ if (overlapEnd <= overlapStart)
{
- currentChar = runEnd;
continue;
}
@@ -465,55 +493,58 @@ namespace Avalonia.Media
{
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;
- }
-
+ // ShapedBuffer.GetCharRangeWidth uses the cluster cache; O(log clusters).
+ width += shaped.ShapedBuffer.GetCharRangeWidth(overlapStart - runStart, overlapEnd - runStart);
break;
}
case DrawableTextRun d:
{
- // For drawable runs, count full width if they completely overlap
- if (overlapLen >= d.Length)
+ // Drawables are atomic: count full width when they completely overlap.
+ if (overlapEnd - overlapStart >= d.Length)
{
width += d.Size.Width;
}
break;
}
- default:
- {
- break;
- }
}
-
- currentChar = runEnd;
}
return width;
}
+
+ ///
+ /// Binary-search for the largest index
+ /// i such that runStartChars[i] <= charIndex. That index
+ /// is the first run that can contain or precede .
+ ///
+ private static int FindFirstOverlappingRun(int[] runStartChars, int charIndex)
+ {
+ if (charIndex <= 0)
+ {
+ return 0;
+ }
+
+ var lo = 0;
+ // Upper bound excludes the sentinel entry; we want a run index, not a boundary.
+ var hi = runStartChars.Length - 2;
+ if (hi < 0)
+ {
+ return 0;
+ }
+
+ while (lo < hi)
+ {
+ var mid = (lo + hi + 1) >> 1;
+ if (runStartChars[mid] <= charIndex)
+ {
+ lo = mid;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+ return lo;
+ }
}
}
diff --git a/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs b/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs
index 255ea6aeb6..33c5a6d76a 100644
--- a/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs
+++ b/tests/Avalonia.Benchmarks/Text/ShapedBufferOps.cs
@@ -10,7 +10,7 @@ namespace Avalonia.Benchmarks.Text;
///
/// Micro-benchmark for the cluster-cache hot paths
-/// (, ,
+/// (, ,
/// and the cached chain). Compares the
/// simple-mode fast path (1 char per cluster) against complex clusters by
/// shaping random ASCII vs. random-from-extended-Latin so the buffers exercise
@@ -77,7 +77,7 @@ public class ShapedBufferOps : IDisposable
}
///
- /// Repeated targeting
+ /// Repeated targeting
/// half the buffer's total width. Exercises the binary search across the
/// prefix table.
///
@@ -88,7 +88,7 @@ public class ShapedBufferOps : IDisposable
var sum = 0;
for (var i = 0; i < 64; i++)
{
- sum += _primed.MeasureCharactersThatFit(halfWidth, out _);
+ sum += _primed.FindLeadingCharCountWithinWidth(halfWidth);
}
return sum;
}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs
new file mode 100644
index 0000000000..d0963470d5
--- /dev/null
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextCollapsingBidiTests.cs
@@ -0,0 +1,596 @@
+#nullable enable
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Avalonia.Media;
+using Avalonia.Media.TextFormatting;
+using Xunit;
+
+namespace Avalonia.Skia.UnitTests.Media.TextFormatting
+{
+ ///
+ /// Characterization tests for
+ /// implementations, with emphasis on BiDi correctness.
+ ///
+ public class TextCollapsingBidiTests
+ {
+ [Fact]
+ public void Ltr_TrailingCharacter_Trims_From_End()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("Hello world", FlowDirection.LeftToRight);
+ var collapsing = TrailingChar(line.Width / 2, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var text = LogicalText(collapsed);
+ Assert.Contains("…", text);
+ Assert.StartsWith("H", text);
+ }
+ }
+
+ [Fact]
+ public void Ltr_TrailingWord_Trims_On_Word_Boundary()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("Hello world foo", FlowDirection.LeftToRight);
+ var collapsing = TrailingWord(line.Width / 2, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ Assert.Contains("…", LogicalText(collapsed));
+ }
+ }
+
+ [Fact]
+ public void Ltr_PrefixCharacterEllipsis_Preserves_Prefix_And_Suffix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("01234 01234 01234", FlowDirection.LeftToRight);
+ var collapsing = LeadingPrefix(prefixLength: 8, width: 120.0, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var text = LogicalText(collapsed);
+ Assert.StartsWith("01234 01", text);
+ Assert.Contains("…", text);
+ // Suffix must reappear after the symbol.
+ Assert.EndsWith("4 01234", text);
+ }
+ }
+
+ [Fact]
+ public void Ltr_PathSegmentEllipsis_Collapses_Middle()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("verylongdirectory\\file.txt", FlowDirection.LeftToRight);
+ var collapsing = new TextPathSegmentEllipsis(
+ "…", line.Width / 2,
+ new GenericTextRunProperties(Typeface.Default),
+ FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var text = LogicalText(collapsed);
+ Assert.Contains("…", text);
+ // Last segment ("file.txt") should be preserved on at least
+ // some prefix; we don't assert exact width because Width math
+ // depends on the font.
+ Assert.Contains(".txt", text);
+ }
+ }
+
+ [Fact]
+ public void Width_Greater_Than_Line_Returns_Same_Line()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("abc", FlowDirection.LeftToRight);
+ var collapsing = TrailingChar(line.Width + 100, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ // Collapse returns null → TextLineImpl.Collapse returns `this`.
+ Assert.Same(line, collapsed);
+ Assert.False(collapsed.HasCollapsed);
+ }
+ }
+
+ [Fact]
+ public void Width_Less_Than_Symbol_Returns_Empty_Collapsed_Line()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var line = BuildLine("abcdef", FlowDirection.LeftToRight);
+
+ // Width below symbol width → implementation returns [] → line
+ // gets HasCollapsed = true but no runs.
+ var collapsing = TrailingChar(width: 0.001, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ Assert.True(collapsed.HasCollapsed);
+ Assert.Empty(collapsed.TextRuns);
+ }
+ }
+
+ [Fact]
+ public void Rtl_TrailingCharacter_Preserves_Logical_Prefix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "السلام عليكم ورحمة الله وبركاته";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var collapsing = TrailingChar(line.Width / 2, FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.StartsWith(text.Substring(0, 1), logical);
+ }
+ }
+
+ [Fact]
+ public void Rtl_TrailingWord_Preserves_Logical_Prefix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "السلام عليكم ورحمة الله وبركاته";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var collapsing = TrailingWord(line.Width / 2, FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ Assert.Contains("…", LogicalText(collapsed));
+ }
+ }
+
+ [Fact]
+ public void Rtl_PrefixCharacterEllipsis_Preserves_Logical_Prefix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "السلام عليكم ورحمة الله وبركاته";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var collapsing = LeadingPrefix(prefixLength: 4, width: line.Width / 2, FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.StartsWith(text.Substring(0, 4), logical);
+ Assert.Contains("…", logical);
+ }
+ }
+
+ [Fact]
+ public void Mixed_TrailingCharacter_Preserves_Logical_Prefix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "Hello مرحبا world";
+ var line = BuildLine(text, FlowDirection.LeftToRight);
+ var collapsing = TrailingChar(line.Width * 0.6, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ Assert.StartsWith("Hello", LogicalText(collapsed));
+ }
+ }
+
+ [Fact]
+ public void Mixed_PrefixCharacterEllipsis_Preserves_Logical_Prefix_And_Suffix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "Hello مرحبا world";
+ var line = BuildLine(text, FlowDirection.LeftToRight);
+ var collapsing = LeadingPrefix(prefixLength: 5, width: line.Width * 0.6, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.StartsWith("Hello", logical);
+ Assert.Contains("…", logical);
+ }
+ }
+
+ [Fact]
+ public void Mixed_TrailingWord_Preserves_Logical_Prefix()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "Hello مرحبا world";
+ var line = BuildLine(text, FlowDirection.LeftToRight);
+ var collapsing = TrailingWord(line.Width * 0.6, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.StartsWith("Hello", logical);
+ }
+ }
+
+ [Fact]
+ public void Mixed_PathSegmentEllipsis_Preserves_Last_Segment()
+ {
+ using (TextFormatterTests.Start())
+ {
+ // Mixed-bidi path: ASCII-only separators with an RTL directory
+ // name embedded. Segmentation is separator-driven, so the
+ // logical-tail segment ("file.txt") must survive.
+ const string text = "C:\\folder\\مجلد\\file.txt";
+ var line = BuildLine(text, FlowDirection.LeftToRight);
+ var collapsing = new TextPathSegmentEllipsis(
+ "…", line.Width / 2,
+ new GenericTextRunProperties(Typeface.Default),
+ FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.Contains("file.txt", logical);
+ }
+ }
+
+ [Fact]
+ public void Rtl_PathSegmentEllipsis_Preserves_Last_Segment()
+ {
+ using (TextFormatterTests.Start())
+ {
+ // Pure-RTL path. Avalonia's font fallback may render Arabic as
+ // .notdef glyphs in the test environment, but segmentation is
+ // character-driven (separators are ASCII '/' and '\\') so the
+ // logical-tail segment "ملف.txt" must still be detected and
+ // preserved.
+ const string text = "مجلد/مجلد2/ملف.txt";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var collapsing = new TextPathSegmentEllipsis(
+ "…", line.Width / 2,
+ new GenericTextRunProperties(Typeface.Default),
+ FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.Contains("ملف.txt", logical);
+ }
+ }
+
+ [Fact]
+ public void Ltr_PathSegmentEllipsis_Middle_Collapse_Preserves_First_And_Last_Segments()
+ {
+ using (TextFormatterTests.Start())
+ {
+ // 3-segment path; middle is intentionally long so collapsing
+ // it alone produces a fitting result.
+ const string text = "a/middlemiddlemiddlemiddlemiddlemiddlemiddlemiddlemiddlemiddle/c.txt";
+ var line = BuildLine(text, FlowDirection.LeftToRight);
+ var budget = line.Width * 0.3;
+ var collapsing = new TextPathSegmentEllipsis(
+ "…", budget,
+ new GenericTextRunProperties(Typeface.Default),
+ FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.Contains("a", logical);
+ Assert.Contains("c.txt", logical);
+ }
+ }
+
+ [Fact]
+ public void Rtl_PathSegmentEllipsis_Middle_Collapse_Preserves_First_And_Last_Segments()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "اول/منتصفمنتصفمنتصفمنتصفمنتصفمنتصفمنتصفمنتصف/اخر.txt";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var budget = line.Width * 0.3;
+ var collapsing = new TextPathSegmentEllipsis(
+ "…", budget,
+ new GenericTextRunProperties(Typeface.Default),
+ FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var logical = LogicalText(collapsed);
+ Assert.Contains("…", logical);
+ Assert.Contains("اول", logical);
+ Assert.Contains("اخر.txt", logical);
+ }
+ }
+
+ [Theory]
+ [InlineData("Hello world abcdef", true)]
+ [InlineData("السلام عليكم ورحمة", false)]
+ public void TryMeasureCharacters_Returned_Length_Fits_Logical_Leading_In_Budget(string text, bool ltr)
+ {
+ using (TextFormatterTests.Start())
+ {
+ var dir = ltr ? FlowDirection.LeftToRight : FlowDirection.RightToLeft;
+ var line = BuildLine(text, dir);
+ var shapedRun = line.TextRuns.OfType().FirstOrDefault();
+ Assert.NotNull(shapedRun);
+
+ var buffer = shapedRun!.ShapedBuffer;
+ var totalWidth = shapedRun.Size.Width;
+
+ // Probe at several budget points; the contract must hold at all of them.
+ for (var i = 1; i < 10; i++)
+ {
+ var budget = totalWidth * i / 10;
+ if (!shapedRun.TryMeasureCharacters(budget, out var measured) || measured <= 0)
+ {
+ continue;
+ }
+
+ // The width of the LOGICAL leading `measured` characters must fit
+ // in `budget`. GetCharRangeWidth uses the cluster cache, which is
+ // built in logical order for both directions.
+ var actualLeadingWidth = buffer.GetCharRangeWidth(0, measured);
+
+ Assert.True(actualLeadingWidth <= budget + 0.5,
+ $"{dir}: budget={budget:F2}, measured={measured}, " +
+ $"actual logical-leading width={actualLeadingWidth:F2}");
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("Hello world abcdef", true)]
+ [InlineData("السلام عليكم ورحمة", false)]
+ public void TryMeasureCharactersBackwards_Returned_Length_Fits_Logical_Trailing_In_Budget(string text, bool ltr)
+ {
+ using (TextFormatterTests.Start())
+ {
+ var dir = ltr ? FlowDirection.LeftToRight : FlowDirection.RightToLeft;
+ var line = BuildLine(text, dir);
+ var shapedRun = line.TextRuns.OfType().FirstOrDefault();
+ Assert.NotNull(shapedRun);
+
+ var buffer = shapedRun!.ShapedBuffer;
+ var totalWidth = shapedRun.Size.Width;
+ var textLength = shapedRun.Length;
+
+ for (var i = 1; i < 10; i++)
+ {
+ var budget = totalWidth * i / 10;
+ if (!shapedRun.TryMeasureCharactersBackwards(budget, out var measured, out _) || measured <= 0)
+ {
+ continue;
+ }
+
+ var actualTrailingWidth = buffer.GetCharRangeWidth(textLength - measured, textLength);
+
+ Assert.True(actualTrailingWidth <= budget + 0.5,
+ $"{dir}: budget={budget:F2}, measured={measured}, " +
+ $"actual logical-trailing width={actualTrailingWidth:F2}");
+ }
+ }
+ }
+
+ [Fact]
+ public void LogicalTextRunEnumerator_Without_IndexedRuns_Returns_Distinct_Runs()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var props = new GenericTextRunProperties(Typeface.Default);
+ var runs = new TextRun[]
+ {
+ new TextCharacters("AAA", props),
+ new TextCharacters("BBB", props),
+ new TextCharacters("CCC", props),
+ };
+
+ // Construct TextLineImpl directly and SKIP FinalizeLine so that
+ // _indexedTextRuns stays null. This is exactly the branch
+ // LogicalTextRunEnumerator handles incorrectly today.
+ var paragraphProps = new GenericTextParagraphProperties(props);
+ var line = new TextLineImpl(runs, 0, 9, double.PositiveInfinity, paragraphProps);
+
+ var enumerator = new LogicalTextRunEnumerator(line);
+ var seen = new List();
+ while (enumerator.MoveNext(out var run))
+ {
+ seen.Add(run!);
+ }
+
+ Assert.Equal(3, seen.Count);
+ Assert.Same(runs[0], seen[0]);
+ Assert.Same(runs[1], seen[1]);
+ Assert.Same(runs[2], seen[2]);
+ }
+ }
+
+ [Fact]
+ public void LeadingPrefix_Negative_PrefixLength_Throws()
+ {
+ using (TextFormatterTests.Start())
+ {
+ var props = new GenericTextRunProperties(Typeface.Default);
+ Assert.Throws(
+ () => new TextLeadingPrefixCharacterEllipsis(
+ "…", prefixLength: -1, width: 100, props, FlowDirection.LeftToRight));
+ }
+ }
+
+ [Fact]
+ public void LeadingPrefix_Honours_FlowDirection_For_Symbol()
+ {
+ using (TextFormatterTests.Start())
+ {
+ const string text = "السلام عليكم ورحمة";
+ var line = BuildLine(text, FlowDirection.RightToLeft);
+ var collapsing = LeadingPrefix(prefixLength: 4, width: line.Width / 2, FlowDirection.RightToLeft);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+
+ // The ellipsis symbol's run should pick up the RTL bidi level
+ // from the FlowDirection passed to the constructor. Today the
+ // ctor in Collapse() hardcodes LeftToRight, so the symbol run
+ // has IsLeftToRight == true.
+ var ellipsisRun = collapsed.TextRuns
+ .OfType()
+ .FirstOrDefault(r => r.Text.ToString().Contains("…"));
+ Assert.NotNull(ellipsisRun);
+ Assert.False(ellipsisRun!.ShapedBuffer.IsLeftToRight);
+ }
+ }
+
+ [Fact]
+ public void Collapse_With_Multiple_Shaped_Runs_Preserves_Ellipsis()
+ {
+ // Three independent runs via FixedRunsTextSource. Trim point lands
+ // somewhere in the middle — collapse must not silently drop a run
+ // or duplicate one (covers the SplitTextRuns interaction).
+ using (TextFormatterTests.Start())
+ {
+ var props = new GenericTextRunProperties(Typeface.Default);
+ var sourceRuns = new TextRun[]
+ {
+ new TextCharacters("AAAA", props),
+ new TextCharacters("BBBB", props),
+ new TextCharacters("CCCC", props),
+ };
+ var src = new FixedRunsTextSource(sourceRuns);
+ var formatter = new TextFormatterImpl();
+ var line = formatter.FormatLine(src, 0, double.PositiveInfinity,
+ new GenericTextParagraphProperties(props));
+ Assert.NotNull(line);
+
+ var collapsing = TrailingChar(line!.Width / 2, FlowDirection.LeftToRight);
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var text = LogicalText(collapsed);
+ Assert.Contains("…", text);
+ // Every preserved character must come from the original source
+ // text in original order — no garbage.
+ var preserved = text.Replace("…", string.Empty);
+ Assert.StartsWith(preserved, "AAAABBBBCCCC");
+ }
+ }
+
+ [Fact]
+ public void LeadingPrefix_With_Fully_Fitting_Tail_Run_Does_Not_Throw()
+ {
+ // Regression: on a multi-run line a logical-tail run can fit entirely
+ // within the remaining suffix budget. TryMeasureCharactersBackwards then
+ // returns suffixCount == run.Length, and the old code called
+ // ShapedTextRun.Split(0), which throws ArgumentOutOfRangeException. The
+ // long leading run forces the collapse; the short trailing run wholly
+ // fits the suffix budget and exercises that boundary.
+ using (TextFormatterTests.Start())
+ {
+ var props = new GenericTextRunProperties(Typeface.Default);
+ var sourceRuns = new TextRun[]
+ {
+ new TextCharacters("AAAAAAAAAAAA", props), // long: forces the collapse
+ new TextCharacters("B", props), // short: wholly fits the suffix budget
+ };
+ var src = new FixedRunsTextSource(sourceRuns);
+ var formatter = new TextFormatterImpl();
+ var line = formatter.FormatLine(src, 0, double.PositiveInfinity,
+ new GenericTextParagraphProperties(props));
+ Assert.NotNull(line);
+
+ var collapsing = LeadingPrefix(prefixLength: 2, width: line!.Width * 0.7, FlowDirection.LeftToRight);
+
+ // Previously threw ArgumentOutOfRangeException from Split(0).
+ var collapsed = line.Collapse(collapsing);
+
+ AssertCollapsed(collapsed, line);
+ var text = LogicalText(collapsed);
+ Assert.Contains("…", text);
+ // The fully-fitting trailing run must survive in the logical-tail suffix.
+ Assert.Contains("B", text);
+ }
+ }
+
+ private static TextLine BuildLine(string text, FlowDirection flow)
+ {
+ var props = new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black);
+ var paragraphProps = new GenericTextParagraphProperties(
+ flow, TextAlignment.Left, true, true, props, TextWrapping.NoWrap, 0, 0, 0);
+ var source = new SingleBufferTextSource(text, props);
+ var formatter = new TextFormatterImpl();
+ var line = formatter.FormatLine(source, 0, double.PositiveInfinity, paragraphProps);
+ Assert.NotNull(line);
+ return line!;
+ }
+
+ private static TextTrailingCharacterEllipsis TrailingChar(double width, FlowDirection flow)
+ => new("…", width, new GenericTextRunProperties(Typeface.Default), flow);
+
+ private static TextTrailingWordEllipsis TrailingWord(double width, FlowDirection flow)
+ => new("…", width, new GenericTextRunProperties(Typeface.Default), flow);
+
+ private static TextLeadingPrefixCharacterEllipsis LeadingPrefix(
+ int prefixLength, double width, FlowDirection flow)
+ => new("…", prefixLength, width,
+ new GenericTextRunProperties(Typeface.Default), flow);
+
+ private static void AssertCollapsed(TextLine collapsed, TextLine original)
+ {
+ Assert.NotSame(original, collapsed);
+ Assert.True(collapsed.HasCollapsed,
+ "Collapsed line must report HasCollapsed = true.");
+ }
+
+ ///
+ /// Concatenates run text in logical order via
+ /// . For LTR-only lines this
+ /// equals walking TextRuns directly; for RTL/mixed lines it
+ /// returns the original-text order (what the collapse contract
+ /// requires) instead of the visual post-bidi order.
+ ///
+ private static string LogicalText(TextLine line)
+ {
+ var enumerator = new LogicalTextRunEnumerator(line);
+ var sb = new StringBuilder();
+ while (enumerator.MoveNext(out var run))
+ {
+ sb.Append(run!.Text.Span);
+ }
+ return sb.ToString();
+ }
+
+ ///
+ /// Local copy of the FixedRunsTextSource pattern used in
+ /// TextLineTests — that class is private, so duplicate here.
+ ///
+ private sealed class FixedRunsTextSource : ITextSource
+ {
+ private readonly IReadOnlyList _textRuns;
+
+ public FixedRunsTextSource(IReadOnlyList textRuns)
+ {
+ _textRuns = textRuns;
+ }
+
+ public TextRun? GetTextRun(int textSourceIndex)
+ {
+ var pos = 0;
+ foreach (var run in _textRuns)
+ {
+ if (pos == textSourceIndex)
+ {
+ return run;
+ }
+ pos += run.Length;
+ }
+ return null;
+ }
+ }
+ }
+}
diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs
index b638e01b47..8f039a97e2 100644
--- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs
+++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextShaperTests.cs
@@ -144,7 +144,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
// Measure: ask for the width of the first 3 glyphs.
var threeGlyphsWidth = buffer[0].GlyphAdvance + buffer[1].GlyphAdvance + buffer[2].GlyphAdvance;
- var fit = buffer.MeasureCharactersThatFit(threeGlyphsWidth, out var widthConsumed);
+ var fit = buffer.FindLeadingCharCountWithinWidth(threeGlyphsWidth);
+ var widthConsumed = buffer.GetCharRangeWidth(0, fit);
Assert.Equal(3, fit);
Assert.Equal(threeGlyphsWidth, widthConsumed, 5);
@@ -194,6 +195,79 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
}
}
+ [Fact]
+ public void ClusterCache_SimpleMode_TrimmingHelpers_Are_Correct()
+ {
+ using (Start())
+ {
+ var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface));
+
+ Assert.True(buffer.IsClusterCacheSimple);
+
+ var advances = new double[buffer.Length];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ advances[i] = buffer[i].GlyphAdvance;
+ }
+
+ double Sum(int start, int end)
+ {
+ var w = 0d;
+ for (var i = start; i < end; i++)
+ {
+ w += advances[i];
+ }
+ return w;
+ }
+
+ // GetCharRangeWidth: exact sub-range sums, including out-of-range clamping.
+ // These must not throw in simple mode (the regression: _clusterStartChars is null).
+ Assert.Equal(Sum(0, 3), buffer.GetCharRangeWidth(0, 3), 5);
+ Assert.Equal(Sum(2, 5), buffer.GetCharRangeWidth(2, 5), 5);
+ Assert.Equal(Sum(0, 8), buffer.GetCharRangeWidth(-2, 100), 5); // clamped to [0, 8]
+ Assert.Equal(0d, buffer.GetCharRangeWidth(4, 4), 5);
+
+ // FindLeadingCharCountWithinWidth: budget mid-way into the 4th glyph -> first 3 fit.
+ var leadingBudget = Sum(0, 3) + advances[3] * 0.5;
+ Assert.Equal(3, buffer.FindLeadingCharCountWithinWidth(leadingBudget));
+
+ // FindTrailingCharCountWithinWidth: budget mid-way into glyph index 4 -> last 3 fit.
+ var trailingBudget = Sum(5, 8) + advances[4] * 0.5;
+ var trailingCount = buffer.FindTrailingCharCountWithinWidth(trailingBudget, out var consumed);
+ Assert.Equal(3, trailingCount);
+ Assert.Equal(Sum(5, 8), consumed, 5);
+ }
+ }
+
+ [Fact]
+ public void ClusterCache_SimpleMode_TrimmingHelpers_Survive_Split()
+ {
+ using (Start())
+ {
+ var buffer = TextShaper.Current.ShapeText("ABCDEFGH", new TextShaperOptions(Typeface.Default.GlyphTypeface));
+ Assert.True(buffer.IsClusterCacheSimple);
+
+ var split = buffer.Split(3);
+ var second = split.Second;
+ Assert.NotNull(second);
+ Assert.True(second!.IsClusterCacheSimple);
+ Assert.Equal(5, second.Length); // "DEFGH"
+
+ var advances = new double[second.Length];
+ for (var i = 0; i < second.Length; i++)
+ {
+ advances[i] = second[i].GlyphAdvance;
+ }
+
+ // Exercises the _clusterStartIdx offset on a simple-mode sub-buffer.
+ var firstTwo = advances[0] + advances[1];
+ Assert.Equal(firstTwo, second.GetCharRangeWidth(0, 2), 5);
+
+ var leadingBudget = firstTwo + advances[2] * 0.5;
+ Assert.Equal(2, second.FindLeadingCharCountWithinWidth(leadingBudget));
+ }
+ }
+
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface