Browse Source

Rework TextLineImpl hit testing

pull/8471/head
Benedikt Stebner 4 years ago
parent
commit
7cbeb7ad3f
  1. 2
      samples/ControlCatalog/Pages/TextBlockPage.xaml
  2. 1
      samples/Sandbox/MainWindow.axaml
  3. 1
      samples/Sandbox/Sandbox.csproj
  4. 109
      src/Avalonia.Base/Media/GlyphRun.cs
  5. 371
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  6. 84
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

2
samples/ControlCatalog/Pages/TextBlockPage.xaml

@ -118,7 +118,7 @@
</StackPanel>
</Border>
<Border>
<RichTextBlock Margin="10" TextWrapping="Wrap">
<RichTextBlock SelectionBrush="LightBlue" IsTextSelectionEnabled="True" Margin="10" TextWrapping="Wrap">
This <Span FontWeight="Bold">is</Span> a
<Span Background="Silver" Foreground="Maroon">TextBlock</Span>
with <Span TextDecorations="Underline">several</Span>

1
samples/Sandbox/MainWindow.axaml

@ -1,4 +1,5 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
x:Class="Sandbox.MainWindow">
<RichTextBlock Width="200" IsTextSelectionEnabled="True" Text="Multiline TextBlock with TextWrapping.&#xD;&#xD;Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est." TextWrapping="Wrap" />
</Window>

1
samples/Sandbox/Sandbox.csproj

@ -10,6 +10,7 @@
<ProjectReference Include="..\..\src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Controls.DataGrid\Avalonia.Controls.DataGrid.csproj" />
<ProjectReference Include="..\..\src\Avalonia.NativeGraphics.Backend\Avalonia.NativeGraphics.Backend.csproj" />
<ProjectReference Include="..\..\src\Avalonia.Themes.Fluent\Avalonia.Themes.Fluent.csproj" />
</ItemGroup>

109
src/Avalonia.Base/Media/GlyphRun.cs

@ -614,17 +614,29 @@ namespace Avalonia.Media
private GlyphRunMetrics CreateGlyphRunMetrics()
{
var firstCluster = 0;
var lastCluster = Characters.Length - 1;
if (!IsLeftToRight)
{
var cluster = firstCluster;
firstCluster = lastCluster;
lastCluster = cluster;
}
if (GlyphClusters != null && GlyphClusters.Count > 0)
{
var firstCluster = GlyphClusters[0];
firstCluster = GlyphClusters[0];
lastCluster = GlyphClusters[GlyphClusters.Count - 1];
_offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster);
}
var isReversed = firstCluster > lastCluster;
var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
var widthIncludingTrailingWhitespace = 0d;
var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount);
var trailingWhitespaceLength = GetTrailingWhitespaceLength(isReversed, out var newLineLength, out var glyphCount);
for (var index = 0; index < GlyphIndices.Count; index++)
{
@ -635,16 +647,16 @@ namespace Avalonia.Media
var width = widthIncludingTrailingWhitespace;
if (IsLeftToRight)
if (isReversed)
{
for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
for (var index = 0; index < glyphCount; index++)
{
width -= GetGlyphAdvance(index, out _);
}
}
}
else
{
for (var index = 0; index < glyphCount; index++)
for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
{
width -= GetGlyphAdvance(index, out _);
}
@ -654,16 +666,15 @@ namespace Avalonia.Media
height);
}
private int GetTrailingWhitespaceLength(out int newLineLength, out int glyphCount)
{
glyphCount = 0;
newLineLength = 0;
if (Characters.IsEmpty)
private int GetTrailingWhitespaceLength(bool isReversed, out int newLineLength, out int glyphCount)
{
if (isReversed)
{
return 0;
return GetTralingWhitespaceLengthRightToLeft(out newLineLength, out glyphCount);
}
glyphCount = 0;
newLineLength = 0;
var trailingWhitespaceLength = 0;
if (GlyphClusters == null)
@ -732,6 +743,78 @@ namespace Avalonia.Media
return trailingWhitespaceLength;
}
private int GetTralingWhitespaceLengthRightToLeft(out int newLineLength, out int glyphCount)
{
glyphCount = 0;
newLineLength = 0;
var trailingWhitespaceLength = 0;
if (GlyphClusters == null)
{
for (var i = 0; i < Characters.Length;)
{
var codepoint = Codepoint.ReadAt(_characters, i, out var count);
if (!codepoint.IsWhiteSpace)
{
break;
}
if (codepoint.IsBreakChar)
{
newLineLength++;
}
trailingWhitespaceLength++;
i += count;
glyphCount++;
}
}
else
{
for (var i = 0; i < GlyphClusters.Count; i++)
{
var currentCluster = GlyphClusters[i];
var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset);
var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _);
if (!codepoint.IsWhiteSpace)
{
break;
}
var clusterLength = 1;
while (i - 1 >= 0)
{
var nextCluster = GlyphClusters[i - 1];
if (currentCluster == nextCluster)
{
clusterLength++;
i--;
continue;
}
break;
}
if (codepoint.IsBreakChar)
{
newLineLength += clusterLength;
}
trailingWhitespaceLength += clusterLength;
glyphCount++;
}
}
return trailingWhitespaceLength;
}
private void Set<T>(ref T field, T value)
{
_glyphRunImpl?.Dispose();

371
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@ -166,58 +166,74 @@ namespace Avalonia.Media.TextFormatting
if (distance <= 0)
{
// hit happens before the line, return the first position
var firstRun = _textRuns[0];
if (firstRun is ShapedTextCharacters shapedTextCharacters)
{
return shapedTextCharacters.GlyphRun.GetCharacterHitFromDistance(distance, out _);
}
return GetRunCharacterHit(firstRun, FirstTextSourceIndex, 0);
}
return _resolvedFlowDirection == FlowDirection.LeftToRight ?
new CharacterHit(FirstTextSourceIndex) :
new CharacterHit(FirstTextSourceIndex + Length);
if (distance > WidthIncludingTrailingWhitespace)
{
var lastRun = _textRuns[_textRuns.Count - 1];
return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.TextSourceLength, lastRun.Size.Width);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
var currentPosition = FirstTextSourceIndex;
var currentDistance = 0.0;
foreach (var currentRun in _textRuns)
{
switch (currentRun)
if (currentDistance + currentRun.Size.Width < distance)
{
case ShapedTextCharacters shapedRun:
{
characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
currentDistance += currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
var offset = Math.Max(0, currentPosition - shapedRun.Text.Start);
continue;
}
characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength);
characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance);
break;
}
default:
break;
}
return characterHit;
}
private static CharacterHit GetRunCharacterHit(DrawableTextRun run, int currentPosition, double distance)
{
CharacterHit characterHit;
switch (run)
{
case ShapedTextCharacters shapedRun:
{
characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
var offset = Math.Max(0, currentPosition - shapedRun.Text.Start);
if (!shapedRun.GlyphRun.IsLeftToRight)
{
if (distance < currentRun.Size.Width / 2)
{
characterHit = new CharacterHit(currentPosition);
}
else
{
characterHit = new CharacterHit(currentPosition, currentRun.TextSourceLength);
}
break;
offset = Math.Max(0, offset - shapedRun.Text.End);
}
}
if (distance <= currentRun.Size.Width)
{
break;
}
characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength);
distance -= currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
break;
}
default:
{
if (distance < run.Size.Width / 2)
{
characterHit = new CharacterHit(currentPosition);
}
else
{
characterHit = new CharacterHit(currentPosition, run.TextSourceLength);
}
break;
}
}
return characterHit;
@ -226,136 +242,122 @@ namespace Avalonia.Media.TextFormatting
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
var isTrailingHit = characterHit.TrailingLength > 0;
var flowDirection = _paragraphProperties.FlowDirection;
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var currentDistance = Start;
var currentPosition = FirstTextSourceIndex;
var remainingLength = characterIndex - FirstTextSourceIndex;
GlyphRun? lastRun = null;
var currentDistance = Start;
for (var index = 0; index < _textRuns.Count; index++)
if (flowDirection == FlowDirection.LeftToRight)
{
for (var index = 0; index < _textRuns.Count; index++)
{
var currentRun = _textRuns[index];
if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength,
flowDirection, out var distance, out _))
{
return currentDistance + distance;
}
//No hit hit found so we add the full width
currentDistance += currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
remainingLength -= currentRun.TextSourceLength;
}
}
else
{
var textRun = _textRuns[index];
currentDistance += WidthIncludingTrailingWhitespace;
switch (textRun)
for (var index = _textRuns.Count - 1; index >= 0; index--)
{
case ShapedTextCharacters shapedTextCharacters:
var currentRun = _textRuns[index];
if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength,
flowDirection, out var distance, out var currentGlyphRun))
{
if (currentGlyphRun != null)
{
var currentRun = shapedTextCharacters.GlyphRun;
distance = currentGlyphRun.Size.Width - distance;
}
if (lastRun != null)
{
if (!lastRun.IsLeftToRight && currentRun.IsLeftToRight &&
currentRun.Characters.Start == characterHit.FirstCharacterIndex &&
characterHit.TrailingLength == 0)
{
return currentDistance;
}
}
return currentDistance - distance;
}
//Look for a hit in within the current run
if (currentPosition + remainingLength <= currentPosition + textRun.Text.Length)
{
characterHit = new CharacterHit(textRun.Text.Start + remainingLength);
//No hit hit found so we add the full width
currentDistance -= currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
remainingLength -= currentRun.TextSourceLength;
}
}
var distance = currentRun.GetDistanceFromCharacterHit(characterHit);
return currentDistance;
}
return currentDistance + distance;
}
private static bool TryGetDistanceFromCharacterHit(
DrawableTextRun currentRun,
CharacterHit characterHit,
int currentPosition,
int remainingLength,
FlowDirection flowDirection,
out double distance,
out GlyphRun? currentGlyphRun)
{
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var isTrailingHit = characterHit.TrailingLength > 0;
//Look at the left and right edge of the current run
if (currentRun.IsLeftToRight)
{
if (_resolvedFlowDirection == FlowDirection.LeftToRight && (lastRun == null || lastRun.IsLeftToRight))
{
if (characterIndex <= currentPosition)
{
return currentDistance;
}
}
else
{
if (characterIndex == currentPosition)
{
return currentDistance;
}
}
distance = 0;
currentGlyphRun = null;
if (characterIndex == currentPosition + textRun.Text.Length && isTrailingHit)
{
return currentDistance + currentRun.Size.Width;
}
}
else
{
if (characterIndex == currentPosition)
{
return currentDistance + currentRun.Size.Width;
}
var nextRun = index + 1 < _textRuns.Count ?
_textRuns[index + 1] as ShapedTextCharacters :
null;
switch (currentRun)
{
case ShapedTextCharacters shapedTextCharacters:
{
currentGlyphRun = shapedTextCharacters.GlyphRun;
if (nextRun != null)
{
if (nextRun.ShapedBuffer.IsLeftToRight)
{
if (characterIndex == currentPosition + textRun.Text.Length)
{
return currentDistance;
}
}
else
{
if (currentPosition + nextRun.Text.Length == characterIndex)
{
return currentDistance;
}
}
}
else
{
if (characterIndex > currentPosition + textRun.Text.Length)
{
return currentDistance;
}
}
}
if (currentPosition + remainingLength < currentPosition + currentRun.Text.Length)
{
characterHit = new CharacterHit(currentRun.Text.Start + remainingLength);
lastRun = currentRun;
distance = currentGlyphRun.GetDistanceFromCharacterHit(characterHit);
break;
return true;
}
default:
if (currentPosition + remainingLength == currentPosition + currentRun.Text.Length && isTrailingHit)
{
if (characterIndex == currentPosition)
if (currentGlyphRun.IsLeftToRight || flowDirection == FlowDirection.RightToLeft)
{
return currentDistance;
distance = currentGlyphRun.Size.Width;
}
if (characterIndex == currentPosition + textRun.TextSourceLength)
{
return currentDistance + textRun.Size.Width;
}
return true;
}
break;
break;
}
default:
{
if (characterIndex == currentPosition)
{
return true;
}
}
//No hit hit found so we add the full width
currentDistance += textRun.Size.Width;
currentPosition += textRun.TextSourceLength;
remainingLength -= textRun.TextSourceLength;
if (characterIndex == currentPosition + currentRun.TextSourceLength)
{
distance = currentRun.Size.Width;
if (remainingLength <= 0)
{
break;
}
return true;
}
break;
}
}
return currentDistance;
return false;
}
/// <inheritdoc/>
@ -460,20 +462,33 @@ namespace Avalonia.Media.TextFormatting
var startIndex = currentRun.Text.Start + offset;
var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
currentShapedRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(startIndex + remainingLength) :
new CharacterHit(startIndex));
double startOffset;
double endOffset;
endX += endOffset;
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
currentShapedRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(startIndex) :
new CharacterHit(startIndex + remainingLength));
if (currentPosition < startIndex)
{
startOffset = endOffset;
}
else
{
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
}
startX += startOffset;
endX += endOffset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
@ -504,7 +519,7 @@ namespace Avalonia.Media.TextFormatting
}
//Lines that only contain a linebreak need to be covered here
if(characterLength == 0)
if (characterLength == 0)
{
characterLength = NewLineLength;
}
@ -532,19 +547,9 @@ namespace Avalonia.Media.TextFormatting
currentWidth += runwidth;
currentPosition += characterLength;
if (currentDirection == FlowDirection.LeftToRight)
if (currentPosition > characterIndex)
{
if (currentPosition > characterIndex)
{
break;
}
}
else
{
if (currentPosition <= firstTextSourceIndex)
{
break;
}
break;
}
startX = endX;
@ -571,7 +576,7 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = FirstTextSourceIndex;
var remainingLength = textLength;
var startX = Start + WidthIncludingTrailingWhitespace;
var startX = WidthIncludingTrailingWhitespace;
double currentWidth = 0;
var currentRect = Rect.Empty;
@ -582,7 +587,7 @@ namespace Avalonia.Media.TextFormatting
continue;
}
if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
if (currentPosition + currentRun.TextSourceLength < firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
@ -601,20 +606,31 @@ namespace Avalonia.Media.TextFormatting
currentPosition += offset;
var startIndex = currentRun.Text.Start + offset;
double startOffset;
double endOffset;
var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
currentShapedRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(startIndex + remainingLength) :
new CharacterHit(startIndex));
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
if (currentPosition < startIndex)
{
startOffset = endOffset = 0;
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
endX += endOffset - currentShapedRun.Size.Width;
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
}
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
currentShapedRun.ShapedBuffer.IsLeftToRight ?
new CharacterHit(startIndex) :
new CharacterHit(startIndex + remainingLength));
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
startX += startOffset - currentShapedRun.Size.Width;
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
@ -652,9 +668,10 @@ namespace Avalonia.Media.TextFormatting
}
var runWidth = endX - startX;
var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX))
{
currentRect = currentRect.WithWidth(currentWidth + runWidth);
@ -674,19 +691,9 @@ namespace Avalonia.Media.TextFormatting
currentWidth += runWidth;
currentPosition += characterLength;
if (currentDirection == FlowDirection.LeftToRight)
if (currentPosition > characterIndex)
{
if (currentPosition > characterIndex)
{
break;
}
}
else
{
if (currentPosition <= firstTextSourceIndex)
{
break;
}
break;
}
lastDirection = currentDirection;

84
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

@ -154,7 +154,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{
j += inner.Current.Text.Length;
if(j + i > text.Length)
if (j + i > text.Length)
{
break;
}
@ -738,7 +738,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine = layout.TextLines[0];
var start = textLine.GetDistanceFromCharacterHit(new CharacterHit(5, 1));
var end = textLine.GetDistanceFromCharacterHit(new CharacterHit(6, 1));
var rects = layout.HitTestTextRange(0, 7).ToArray();
@ -746,7 +746,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(1, rects.Length);
var expected = rects[0];
Assert.Equal(expected.Left, start);
Assert.Equal(expected.Right, end);
}
@ -818,11 +818,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var expected = text.Substring(textLine.FirstTextSourceIndex, textLine.Length);
Assert.Equal(expected, actual);
}
}
}
}
}
[Fact]
public void Should_Layout_Empty_String()
{
@ -833,11 +833,83 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Typeface.Default,
12,
Brushes.Black);
Assert.True(layout.Bounds.Height > 0);
}
}
[Fact]
public void Should_HitTestPoint_RightToLeft()
{
using (Start())
{
var text = "אאא AAA";
var layout = new TextLayout(
text,
Typeface.Default,
12,
Brushes.Black,
flowDirection: FlowDirection.RightToLeft);
var firstRun = layout.TextLines[0].TextRuns[0] as ShapedTextCharacters;
var hit = layout.HitTestPoint(new Point());
Assert.Equal(4, hit.TextPosition);
var currentX = 0.0;
for (var i = 0; i < firstRun.GlyphRun.GlyphClusters.Count; i++)
{
var cluster = firstRun.GlyphRun.GlyphClusters[i];
var advance = firstRun.GlyphRun.GlyphAdvances[i];
hit = layout.HitTestPoint(new Point(currentX, 0));
Assert.Equal(cluster, hit.TextPosition);
var hitRange = layout.HitTestTextRange(hit.TextPosition, 1);
var distance = hitRange.First().Left;
Assert.Equal(currentX, distance);
currentX += advance;
}
var secondRun = layout.TextLines[0].TextRuns[1] as ShapedTextCharacters;
hit = layout.HitTestPoint(new Point(firstRun.Size.Width, 0));
Assert.Equal(7, hit.TextPosition);
hit = layout.HitTestPoint(new Point(layout.TextLines[0].WidthIncludingTrailingWhitespace, 0));
Assert.Equal(0, hit.TextPosition);
currentX = firstRun.Size.Width + 0.5;
for (var i = 0; i < secondRun.GlyphRun.GlyphClusters.Count; i++)
{
var cluster = secondRun.GlyphRun.GlyphClusters[i];
var advance = secondRun.GlyphRun.GlyphAdvances[i];
hit = layout.HitTestPoint(new Point(currentX, 0));
Assert.Equal(cluster, hit.CharacterHit.FirstCharacterIndex);
var hitRange = layout.HitTestTextRange(hit.CharacterHit.FirstCharacterIndex, hit.CharacterHit.TrailingLength);
var distance = hitRange.First().Left + 0.5;
Assert.Equal(currentX, distance);
currentX += advance;
}
}
}
private static IDisposable Start()
{
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

Loading…
Cancel
Save