From f8ec196e2fe1ddfcf9946238582bd14eda8516e9 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 4 Oct 2023 16:42:39 +0200 Subject: [PATCH] Fix text hit testing for invisible runs (#13135) * Repro unit test for GetCharacterHitFromDistance being broken with hidden runs * Repro for infinite loop in GetTextBounds * Fix failing tests * Fix GetRunBoundsRightToLeft --------- Co-authored-by: Nikita Tsukanov --- .../Media/TextFormatting/TextLineImpl.cs | 31 ++++++- .../TextFormatting/TextFormatterTests.cs | 93 +++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 085e60da5f..c25e51a1d7 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -290,6 +290,12 @@ namespace Avalonia.Media.TextFormatting continue; } } + else + { + currentPosition += currentRun.Length; + + continue; + } break; } @@ -990,6 +996,12 @@ namespace Avalonia.Media.TextFormatting var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength); + //Make sure we properly deal with zero width space runs + if (characterLength == 0 && currentRun.Length > 0 && currentRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace == 0) + { + characterLength = currentRun.Length; + } + if (endX < startX) { (endX, startX) = (startX, endX); @@ -1003,7 +1015,9 @@ namespace Avalonia.Media.TextFormatting var runWidth = endX - startX; - return new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + var textSourceIndex = offset + startHit.FirstCharacterIndex; + + return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); } private TextRunBounds GetRunBoundsRightToLeft(ShapedTextRun currentRun, double endX, @@ -1038,6 +1052,17 @@ namespace Avalonia.Media.TextFormatting var characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength); + //Make sure we properly deal with zero width space runs + if (characterLength == 0 && currentRun.Length > 0 && currentRun.GlyphRun.Metrics.WidthIncludingTrailingWhitespace == 0) + { + characterLength = currentRun.Length; + } + + if(startHit.FirstCharacterIndex > endHit.FirstCharacterIndex) + { + startHit = endHit; + } + if (endX < startX) { (endX, startX) = (startX, endX); @@ -1051,7 +1076,9 @@ namespace Avalonia.Media.TextFormatting var runWidth = endX - startX; - return new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + var textSourceIndex = offset + startHit.FirstCharacterIndex; + + return new TextRunBounds(new Rect(startX, 0, runWidth, Height), textSourceIndex, characterLength, currentRun); } public override void Dispose() diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index 8c16e1046b..a6cce760e4 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -706,6 +706,64 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.NotNull(textLine.TextLineBreak.TextEndOfLine); } } + + [Fact] + public void Should_HitTestStringWithInvisibleRuns() + { + var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties); + //var textSource = new ListTextSource( + + + + using (Start()) + { + var hello = new TextCharacters("Hello", + new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black)); + var world = new TextCharacters("world", + new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Red)); + + var source = new ListTextSource(new InvisibleRun(1), hello, new InvisibleRun(1), world); + + var textLine = + TextFormatter.Current.FormatLine(source, 0, double.PositiveInfinity, paragraphProperties); + + void VerifyHit(int offset) + { + var glyphCenter = textLine.GetTextBounds(offset, 1)[0].Rectangle.Center; + var hit = textLine.GetCharacterHitFromDistance(glyphCenter.X); + Assert.Equal(offset, hit.FirstCharacterIndex); + } + VerifyHit(3); + VerifyHit(8); + } + } + + [Fact] + public void GetTextBounds_For_TextLine_With_ZeroWidthSpaces_Does_Not_Freeze() + { + var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties); + + using (Start()) + { + var text = new TextCharacters("\u200B\u200B", + new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black)); + + var source = new ListTextSource(text, new InvisibleRun(1), new TextEndOfParagraph()); + + var textLine = + TextFormatter.Current.FormatLine(source, 0, double.PositiveInfinity, paragraphProperties); + + var bounds = textLine.GetTextBounds(0, 3); + + Assert.Equal(1, bounds.Count); + + var runBounds = bounds[0].TextRunBounds; + + Assert.Equal(2, runBounds.Count); + } + } protected readonly record struct SimpleTextSource : ITextSource { @@ -776,6 +834,32 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting return new TextCharacters(_text, new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black)); } } + + private class ListTextSource : ITextSource + { + private Dictionary _runs = new(); + + public ListTextSource(params TextRun[] runs) : this((IEnumerable)runs) + { + + } + + public ListTextSource(IEnumerable runs) + { + var off = 0; + foreach (var r in runs) + { + _runs[off] = r; + off += r.Length; + } + } + + public TextRun GetTextRun(int textSourceIndex) + { + _runs.TryGetValue(textSourceIndex, out var rv); + return rv; + } + } private class RectangleRun : DrawableTextRun { @@ -798,6 +882,15 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } } + + private class InvisibleRun : TextRun + { + public InvisibleRun(int length) + { + Length = length; + } + public override int Length { get; } + } public static IDisposable Start() {