From 84e62d67f98b76434e295091262fd236dca5f0b2 Mon Sep 17 00:00:00 2001 From: Compunet <117437050+dme-compunet@users.noreply.github.com> Date: Thu, 30 Jan 2025 06:38:56 +0200 Subject: [PATCH] [TextTrimming] Fixed some text trimming bugs (#17998) * Trim text-runs in their logical order * Revert breaking changes * Added TODO12 comment * Added test unit * Use LogicalTextRunEnumerator --------- Co-authored-by: Benedikt Stebner --- .../LogicalTextRunEnumerator.cs | 75 ++++++++ .../TextCollapsingProperties.cs | 57 +++--- .../TextFormatting/TextEllipsisHelper.cs | 169 +++++------------- .../TextLeadingPrefixCharacterEllipsis.cs | 7 +- .../TextFormatting/TextFormatterTests.cs | 39 ++++ 5 files changed, 192 insertions(+), 155 deletions(-) create mode 100644 src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs diff --git a/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs new file mode 100644 index 0000000000..7b391801a6 --- /dev/null +++ b/src/Avalonia.Base/Media/TextFormatting/LogicalTextRunEnumerator.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Avalonia.Media.TextFormatting; + +internal ref struct LogicalTextRunEnumerator +{ + private readonly IReadOnlyList? _textRuns; + private readonly IReadOnlyList? _indexedTextRuns; + + private readonly int _step; + private readonly int _end; + + private int _index; + + public int Count { get; } + + public LogicalTextRunEnumerator(TextLine line, bool backward = false) + { + var indexedTextRuns = (line as TextLineImpl)?._indexedTextRuns; + + if (indexedTextRuns?.Count > 0) + { + _indexedTextRuns = indexedTextRuns; + Count = indexedTextRuns.Count; + } + else if (line.TextRuns.Count > 0) + { + _textRuns = line.TextRuns; + Count = _textRuns.Count; + } + + if (backward) + { + _step = -1; + _end = -1; + _index = Count; + } + else + { + _step = 1; + _end = Count; + _index = -1; + } + } + + public bool MoveNext([MaybeNullWhen(false)] out TextRun run) + { + _index += _step; + + if (_index == _end) + { + run = null; + + return false; + } + + if (_indexedTextRuns != null) + { + run = _indexedTextRuns[_index].TextRun!; + } + else if (_textRuns != null) + { + run = _textRuns[0]; + } + else + { + run = null; + + return false; + } + + return true; + } +} diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs index e1d9253415..dcee8cea46 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextCollapsingProperties.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace Avalonia.Media.TextFormatting +namespace Avalonia.Media.TextFormatting { /// /// Properties of text collapsing. @@ -28,6 +26,7 @@ namespace Avalonia.Media.TextFormatting /// Text line to collapse. public abstract TextRun[]? Collapse(TextLine textLine); + // TODO12: Remove the flowDirection parameter /// /// Creates a list of runs for given collapsed length which includes specified symbol at the end. /// @@ -36,44 +35,50 @@ namespace Avalonia.Media.TextFormatting /// The flow direction. /// The symbol. /// List of remaining runs. - public static TextRun[] CreateCollapsedRuns(TextLine textLine, int collapsedLength, - FlowDirection flowDirection, TextRun shapedSymbol) + public static TextRun[] CreateCollapsedRuns(TextLine textLine, int collapsedLength, FlowDirection flowDirection, TextRun shapedSymbol) { - var textRuns = textLine.TextRuns; - if (collapsedLength <= 0) { - return new[] { shapedSymbol }; - } - - if (flowDirection == FlowDirection.RightToLeft) - { - collapsedLength = textLine.Length - collapsedLength; + return [shapedSymbol]; } var objectPool = FormattingObjectPool.Instance; - var (preSplitRuns, postSplitRuns) = TextFormatterImpl.SplitTextRuns(textRuns, collapsedLength, objectPool); + FormattingObjectPool.RentedList? preSplitRuns = null; + FormattingObjectPool.RentedList? postSplitRuns = null; + + var textRuns = objectPool.TextRunLists.Rent(); try { - if (flowDirection == FlowDirection.RightToLeft) - { - var collapsedRuns = new TextRun[postSplitRuns!.Count + 1]; - postSplitRuns.CopyTo(collapsedRuns, 1); - collapsedRuns[0] = shapedSymbol; - return collapsedRuns; - } - else + var textRunEnumerator = new LogicalTextRunEnumerator(textLine); + + var textRunsLength = 0; + + while (textRunEnumerator.MoveNext(out var textRun)) { - var collapsedRuns = new TextRun[preSplitRuns!.Count + 1]; - preSplitRuns.CopyTo(collapsedRuns); - collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol; - return collapsedRuns; + if (textRunsLength >= collapsedLength) + { + break; + } + + textRunsLength += textRun.Length; + + textRuns.Add(textRun); } + + (preSplitRuns, postSplitRuns) = TextFormatterImpl.SplitTextRuns(textRuns, collapsedLength, objectPool); + + var collapsedRuns = new TextRun[preSplitRuns!.Count + 1]; + + preSplitRuns.CopyTo(collapsedRuns); + collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol; + + return collapsedRuns; } finally { + objectPool.TextRunLists.Return(ref textRuns); objectPool.TextRunLists.Return(ref preSplitRuns); objectPool.TextRunLists.Return(ref postSplitRuns); } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs index fb01afa33d..426388f295 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs @@ -6,17 +6,14 @@ namespace Avalonia.Media.TextFormatting { public static TextRun[]? Collapse(TextLine textLine, TextCollapsingProperties properties, bool isWordEllipsis) { - var textRuns = textLine.TextRuns; + var textRunsEnumerator = new LogicalTextRunEnumerator(textLine); - if (textRuns.Count == 0) + if (textRunsEnumerator.Count == 0) { return null; } - var runIndex = 0; - var currentWidth = 0.0; - var collapsedLength = 0; - var shapedSymbol = TextFormatter.CreateSymbol(properties.Symbol, FlowDirection.LeftToRight); + var shapedSymbol = TextFormatter.CreateSymbol(properties.Symbol, properties.FlowDirection); if (properties.Width < shapedSymbol.GlyphRun.Bounds.Width) { @@ -26,153 +23,79 @@ namespace Avalonia.Media.TextFormatting var availableWidth = properties.Width - shapedSymbol.Size.Width; - if(properties.FlowDirection== FlowDirection.LeftToRight) + var collapsedLength = 0; + + while (textRunsEnumerator.MoveNext(out var currentRun)) { - while (runIndex < textRuns.Count) + switch (currentRun) { - var currentRun = textRuns[runIndex]; + case ShapedTextRun shapedRun: + { + var textRunWidth = shapedRun.Size.Width; - switch (currentRun) - { - case ShapedTextRun shapedRun: + if (textRunWidth > availableWidth) { - currentWidth += shapedRun.Size.Width; - - if (currentWidth > availableWidth) + if (shapedRun.IsReversed) { - if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) - { - if (isWordEllipsis && measuredLength < textLine.Length) - { - var currentBreakPosition = 0; - - var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); - - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) - { - var nextBreakPosition = lineBreak.PositionMeasure; - - if (nextBreakPosition == 0) - { - break; - } - - if (nextBreakPosition >= measuredLength) - { - break; - } - - currentBreakPosition = nextBreakPosition; - } - - measuredLength = currentBreakPosition; - } - } - - collapsedLength += measuredLength; - - return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.LeftToRight, shapedSymbol); + shapedRun.Reverse(); } - availableWidth -= shapedRun.Size.Width; - - break; - } - - case DrawableTextRun drawableRun: - { - //The whole run needs to fit into available space - if (currentWidth + drawableRun.Size.Width > availableWidth) + if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) { - return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.LeftToRight, shapedSymbol); - } - - availableWidth -= drawableRun.Size.Width; - - break; - } - } - - collapsedLength += currentRun.Length; - - runIndex++; - } - } - else - { - runIndex = textRuns.Count - 1; - - while (runIndex >= 0) - { - var currentRun = textRuns[runIndex]; + if (isWordEllipsis && measuredLength < textLine.Length) + { + var currentBreakPosition = 0; - switch (currentRun) - { - case ShapedTextRun shapedRun: - { - currentWidth += shapedRun.Size.Width; + var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); - if (currentWidth > availableWidth) - { - if (shapedRun.TryMeasureCharacters(availableWidth, out var measuredLength)) - { - if (isWordEllipsis && measuredLength < textLine.Length) + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) { - var currentBreakPosition = 0; - - var lineBreaker = new LineBreakEnumerator(currentRun.Text.Span); + var nextBreakPosition = lineBreak.PositionMeasure; - while (currentBreakPosition < measuredLength && lineBreaker.MoveNext(out var lineBreak)) + if (nextBreakPosition == 0) { - var nextBreakPosition = lineBreak.PositionMeasure; - - if (nextBreakPosition == 0) - { - break; - } - - if (nextBreakPosition >= measuredLength) - { - break; - } + break; + } - currentBreakPosition = nextBreakPosition; + if (nextBreakPosition >= measuredLength) + { + break; } - measuredLength = currentBreakPosition; + currentBreakPosition = nextBreakPosition; } - } - - collapsedLength += measuredLength; - return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.RightToLeft, shapedSymbol); + measuredLength = currentBreakPosition; + } } - availableWidth -= shapedRun.Size.Width; + collapsedLength += measuredLength; - break; + return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, properties.FlowDirection, shapedSymbol); } - case DrawableTextRun drawableRun: - { - //The whole run needs to fit into available space - if (currentWidth + drawableRun.Size.Width > availableWidth) - { - return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, FlowDirection.RightToLeft, shapedSymbol); - } + availableWidth -= textRunWidth; - availableWidth -= drawableRun.Size.Width; + break; + } - break; + case DrawableTextRun drawableRun: + { + //The whole run needs to fit into available space + if (drawableRun.Size.Width > availableWidth) + { + return TextCollapsingProperties.CreateCollapsedRuns(textLine, collapsedLength, properties.FlowDirection, shapedSymbol); } - } - collapsedLength += currentRun.Length; + availableWidth -= drawableRun.Size.Width; - runIndex--; + break; + } } + + collapsedLength += currentRun.Length; } - + return null; } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs index 41d451b9e3..1b11470f5e 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs @@ -51,14 +51,9 @@ namespace Avalonia.Media.TextFormatting { var textRuns = textLine.TextRuns; - if (textRuns.Count == 0) - { - return null; - } - var runIndex = 0; var currentWidth = 0.0; - var shapedSymbol = TextFormatterImpl.CreateSymbol(Symbol, FlowDirection.LeftToRight); + var shapedSymbol = TextFormatter.CreateSymbol(Symbol, FlowDirection.LeftToRight); if (Width < shapedSymbol.GlyphRun.Bounds.Width) { diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 59c9216aec..da00ce0672 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -409,6 +409,45 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Theory] + [InlineData("one שתיים three ארבע", "one שתיים thr…", FlowDirection.LeftToRight, false)] + [InlineData("one שתיים three ארבע", "…thrשתיים one", FlowDirection.RightToLeft, false)] + [InlineData("one שתיים three ארבע", "one שתיים…", FlowDirection.LeftToRight, true)] + [InlineData("one שתיים three ארבע", "…שתיים one", FlowDirection.RightToLeft, true)] + public void TextTrimming_Should_Trim_Correctly(string text, string trimmed, FlowDirection direction, bool wordEllipsis) + { + const double Width = 160.0; + const double EmSize = 20.0; + + using (Start()) + { + var defaultProperties = new GenericTextRunProperties(Typeface.Default, EmSize); + + var paragraphProperties = new GenericTextParagraphProperties(direction, TextAlignment.Start, true, + true, defaultProperties, TextWrapping.NoWrap, 0, 0, 0); + + var textSource = new SimpleTextSource(text, defaultProperties); + + var formatter = new TextFormatterImpl(); + + var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, paragraphProperties); + + Assert.NotNull(textLine); + + var textTrimming = wordEllipsis ? TextTrimming.WordEllipsis : TextTrimming.CharacterEllipsis; + + var collapsingProperties = textTrimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(Width, defaultProperties, direction)); + + var collapsedLine = textLine.Collapse(collapsingProperties); + + Assert.NotNull(collapsedLine); + + var trimmedResult = string.Concat(collapsedLine.TextRuns.Select(x => x.Text)); + + Assert.Equal(trimmed, trimmedResult); + } + } + [InlineData("Whether to turn off HTTPS. This option only applies if Individual, " + "IndividualB2C, SingleOrg, or MultiOrg aren't used for ‑‑auth." , "Noto Sans", 40)]