Browse Source

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 <[email protected]>
Benedikt Stebner 2 years ago
parent
commit
f8ec196e2f

+ 29 - 2
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()

+ 93 - 0
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<int, TextRun> _runs = new();
+
+            public ListTextSource(params TextRun[] runs) : this((IEnumerable<TextRun>)runs)
+            {
+                
+            }
+            
+            public ListTextSource(IEnumerable<TextRun> 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()
         {