Browse Source

Attempt to fix consistent hit testing results

GetCharacterHitFromDistance and GetDistanceFromCharacterHit should match all the time
pull/8626/head
Benedikt Stebner 4 years ago
parent
commit
23b08b1f34
  1. 10
      samples/Sandbox/MainWindow.axaml
  2. 75
      samples/Sandbox/MainWindow.axaml.cs
  3. 2
      src/Avalonia.Base/Media/GlyphRun.cs
  4. 24
      src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs
  5. 339
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  6. 5
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  7. 10
      src/Avalonia.Controls/TextBox.cs
  8. 68
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs

10
samples/Sandbox/MainWindow.axaml

@ -1,4 +1,14 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
x:Class="Sandbox.MainWindow">
<StackPanel>
<TextBox x:Name="txtBox" Text="أَبْجَدِيَّة عَرَبِيَّة" SelectionStart="19" SelectionEnd="23"/>
<NumericUpDown Value="{Binding #txtBox.SelectionStart, Mode=TwoWay}"/>
<NumericUpDown Value="{Binding #txtBox.SelectionEnd, Mode=TwoWay}"/>
<RichTextBlock x:Name="txtBlock" Text="أَبْجَدِيَّة عَرَبِيَّة" IsTextSelectionEnabled="True"/>
<NumericUpDown Value="{Binding Distance, Mode=TwoWay}"/>
<TextBlock Text="{Binding #txtBlock.SelectionStart}"/>
<TextBlock Text="{Binding #txtBlock.SelectionEnd}"/>
</StackPanel>
</Window>

75
samples/Sandbox/MainWindow.axaml.cs

@ -1,7 +1,11 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Markup.Xaml;
using Avalonia.Win32.WinRT.Composition;
using Avalonia.VisualTree;
namespace Sandbox
{
@ -11,6 +15,19 @@ namespace Sandbox
{
this.InitializeComponent();
this.AttachDevTools();
var textBox = this.FindControl<TextBox>("txtBox");
textBox.TemplateApplied += TextBox_TemplateApplied;
}
private void TextBox_TemplateApplied(object sender, Avalonia.Controls.Primitives.TemplateAppliedEventArgs e)
{
var textBox = sender as TextBox;
var textPresenter = e.NameScope.Find("PART_TextPresenter") as TextPresenter;
DataContext = new TestViewModel(textPresenter);
}
private void InitializeComponent()
@ -18,4 +35,60 @@ namespace Sandbox
AvaloniaXamlLoader.Load(this);
}
}
public class TestViewModel : ViewModelBase
{
private readonly TextPresenter _textPresenter;
private double _distance = 45;
public TestViewModel(TextPresenter textPresenter)
{
_textPresenter = textPresenter;
}
public double Distance
{
get => _distance;
set
{
OnDistanceChanged(value);
RaisePropertyChanged();
}
}
private void OnDistanceChanged(double distance)
{
if(distance < 0)
{
distance = 0;
}
if(distance > _textPresenter.TextLayout.Bounds.Width)
{
distance = _textPresenter.TextLayout.Bounds.Width;
}
var height = _textPresenter.TextLayout.Bounds.Height;
var distanceY = height / 2;
_textPresenter.MoveCaretToPoint(new Point(distance, distanceY));
var caretIndex = _textPresenter.CaretIndex;
Debug.WriteLine(caretIndex);
_distance = distance;
}
}
public class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void RaisePropertyChanged([CallerMemberName]string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

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

@ -265,7 +265,7 @@ namespace Avalonia.Media
//RightToLeft
var glyphIndex = FindGlyphIndex(characterIndex);
if (GlyphClusters != null)
if (GlyphClusters != null && GlyphClusters.Count > 0)
{
if (characterIndex > GlyphClusters[0])
{

24
src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
@ -116,7 +117,30 @@ namespace Avalonia.Media.TextFormatting
length = text.Length;
}
length = CoerceLength(text, length);
return new ValueSpan<TextRunProperties>(firstTextSourceIndex, length, currentProperties);
}
private static int CoerceLength(ReadOnlySlice<char> text, int length)
{
var finalLength = 0;
var graphemeEnumerator = new GraphemeEnumerator(text);
while (graphemeEnumerator.MoveNext())
{
var grapheme = graphemeEnumerator.Current;
finalLength += grapheme.Text.Length;
if (finalLength >= length)
{
return finalLength;
}
}
return length;
}
}
}

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

@ -183,8 +183,47 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = FirstTextSourceIndex;
var currentDistance = 0.0;
foreach (var currentRun in _textRuns)
for (var i = 0; i < _textRuns.Count; i++)
{
var currentRun = _textRuns[i];
if(currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight)
{
var rightToLeftIndex = i;
var rightToLeftDistance = shapedRun.Size.Width;
while (rightToLeftIndex + 1 <= _textRuns.Count - 1)
{
var nextShaped = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters;
if (nextShaped == null || nextShaped.ShapedBuffer.IsLeftToRight)
{
break;
}
rightToLeftIndex++;
rightToLeftDistance += nextShaped.Size.Width;
}
for (var j = rightToLeftIndex; rightToLeftIndex >= 0; j--)
{
currentRun = _textRuns[j];
if(distance <= currentDistance + rightToLeftDistance - currentRun.Size.Width)
{
currentPosition += currentRun.TextSourceLength;
rightToLeftDistance -= currentRun.Size.Width;
continue;
}
characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance);
break;
}
}
if (currentDistance + currentRun.Size.Width < distance)
{
currentDistance += currentRun.Size.Width;
@ -255,6 +294,37 @@ namespace Avalonia.Media.TextFormatting
{
var currentRun = _textRuns[index];
if (currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight)
{
var i = index;
while (i + 1 <= _textRuns.Count - 1)
{
var nextRun = _textRuns[i + 1];
if (nextRun is ShapedTextCharacters nextShapedRun)
{
if (nextShapedRun.ShapedBuffer.IsLeftToRight)
{
break;
}
}
i++;
}
while (i > index)
{
var rightToLeftRun = _textRuns[i];
currentPosition += rightToLeftRun.TextSourceLength;
remainingLength -= rightToLeftRun.TextSourceLength;
i--;
}
}
if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength,
flowDirection, out var distance, out _))
{
@ -442,92 +512,130 @@ namespace Avalonia.Media.TextFormatting
continue;
}
if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
{
startX += currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
continue;
}
var characterLength = 0;
var endX = startX;
var runWidth = 0.0;
TextRunBounds? currentRunBounds = null;
if (currentRun is ShapedTextCharacters currentShapedRun)
var currentShapedRun = currentRun as ShapedTextCharacters;
if (currentShapedRun != null && !currentShapedRun.ShapedBuffer.IsLeftToRight)
{
var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
var rightToLeftIndex = index;
startX += currentShapedRun.Size.Width;
currentPosition += offset;
while (rightToLeftIndex + 1 <= _textRuns.Count - 1)
{
var nextShapedRun = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters;
var startIndex = currentRun.Text.Start + offset;
if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight)
{
break;
}
double startOffset;
double endOffset;
startX += nextShapedRun.Size.Width;
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
rightToLeftIndex++;
}
if (TryGetTextRunBoundsRightToLeft(startX, firstTextSourceIndex, characterIndex, rightToLeftIndex, ref currentPosition, ref remainingLength, out currentRunBounds))
{
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
startX = currentRunBounds!.Rectangle.Left;
endX = currentRunBounds.Rectangle.Right;
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
runWidth = currentRunBounds.Rectangle.Width;
}
else
currentDirection = FlowDirection.RightToLeft;
}
else
{
if (currentShapedRun != null)
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
{
startX += currentRun.Size.Width;
if (currentPosition < startIndex)
currentPosition += currentRun.TextSourceLength;
continue;
}
var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
currentPosition += offset;
var startIndex = currentRun.Text.Start + offset;
double startOffset;
double endOffset;
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
startOffset = endOffset;
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
else
{
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
if (currentPosition < startIndex)
{
startOffset = endOffset;
}
else
{
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
}
}
startX += startOffset;
startX += startOffset;
endX += endOffset;
endX += endOffset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength);
characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength);
currentDirection = currentShapedRun.ShapedBuffer.IsLeftToRight ?
FlowDirection.LeftToRight :
FlowDirection.RightToLeft;
}
else
{
if (currentPosition < firstTextSourceIndex)
currentDirection = FlowDirection.LeftToRight;
}
else
{
startX += currentRun.Size.Width;
if (currentPosition < firstTextSourceIndex)
{
startX += currentRun.Size.Width;
}
if (currentPosition + currentRun.TextSourceLength <= characterIndex)
{
endX += currentRun.Size.Width;
characterLength = currentRun.TextSourceLength;
}
}
if (currentPosition + currentRun.TextSourceLength <= characterIndex)
if (endX < startX)
{
endX += currentRun.Size.Width;
(endX, startX) = (startX, endX);
}
characterLength = currentRun.TextSourceLength;
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
}
if (endX < startX)
{
(endX, startX) = (startX, endX);
}
runWidth = endX - startX;
currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
currentPosition += characterLength;
var runWidth = endX - startX;
var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
remainingLength -= characterLength;
}
if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0)
if (currentRunBounds != null && !MathUtilities.IsZero(runWidth) || NewLineLength > 0)
{
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
{
@ -537,32 +645,26 @@ namespace Avalonia.Media.TextFormatting
textBounds.Rectangle = currentRect;
textBounds.TextRunBounds.Add(currentRunBounds);
textBounds.TextRunBounds.Add(currentRunBounds!);
}
else
{
currentRect = currentRunBounds.Rectangle;
currentRect = currentRunBounds!.Rectangle;
result.Add(new TextBounds(currentRect, currentDirection, new List<TextRunBounds> { currentRunBounds }));
}
}
currentWidth += runWidth;
currentPosition += characterLength;
if (currentPosition > characterIndex)
if (remainingLength <= 0 || currentPosition >= characterIndex)
{
break;
}
startX = endX;
lastDirection = currentDirection;
remainingLength -= characterLength;
if (remainingLength <= 0)
{
break;
}
}
return result;
@ -674,7 +776,7 @@ namespace Avalonia.Media.TextFormatting
var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
if(!MathUtilities.IsZero(runWidth) || NewLineLength > 0)
if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0)
{
if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX))
{
@ -692,7 +794,7 @@ namespace Avalonia.Media.TextFormatting
result.Add(new TextBounds(currentRect, currentDirection, new List<TextRunBounds> { currentRunBounds }));
}
}
}
currentWidth += runWidth;
currentPosition += characterLength;
@ -716,6 +818,107 @@ namespace Avalonia.Media.TextFormatting
return result;
}
private bool TryGetTextRunBoundsRightToLeft(double startX, int firstTextSourceIndex, int characterIndex, int runIndex, ref int currentPosition, ref int remainingLength, out TextRunBounds? textRunBounds)
{
textRunBounds = null;
for (var index = runIndex; index >= 0; index--)
{
if (TextRuns[index] is not DrawableTextRun currentRun)
{
continue;
}
if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
currentPosition += currentRun.TextSourceLength;
continue;
}
var characterLength = 0;
var endX = startX;
if (currentRun is ShapedTextCharacters currentShapedRun)
{
var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
currentPosition += offset;
var startIndex = currentRun.Text.Start + offset;
double startOffset;
double endOffset;
if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
if (currentPosition < startIndex)
{
startOffset = endOffset = 0;
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
}
}
else
{
endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
}
startX -= currentRun.Size.Width - startOffset;
endX -= currentRun.Size.Width - endOffset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
}
else
{
if (currentPosition + currentRun.TextSourceLength <= characterIndex)
{
endX -= currentRun.Size.Width;
}
if (currentPosition < firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
characterLength = currentRun.TextSourceLength;
}
}
if (endX < startX)
{
(endX, startX) = (startX, endX);
}
//Lines that only contain a linebreak need to be covered here
if (characterLength == 0)
{
characterLength = NewLineLength;
}
var runWidth = endX - startX;
remainingLength -= characterLength;
currentPosition += characterLength;
textRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
return true;
}
return false;
}
public override IReadOnlyList<TextBounds> GetTextBounds(int firstTextSourceIndex, int textLength)
{
if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight)
@ -1319,12 +1522,12 @@ namespace Avalonia.Media.TextFormatting
case TextAlignment.Center:
var start = (_paragraphWidth - width) / 2;
if(paragraphFlowDirection == FlowDirection.RightToLeft)
if (paragraphFlowDirection == FlowDirection.RightToLeft)
{
start -= (widthIncludingTrailingWhitespace - width);
}
return Math.Max(0, start);
return Math.Max(0, start);
case TextAlignment.Right:
return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace);

5
src/Avalonia.Controls/Presenters/TextPresenter.cs

@ -9,6 +9,7 @@ using Avalonia.VisualTree;
using Avalonia.Layout;
using Avalonia.Media.Immutable;
using Avalonia.Controls.Documents;
using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Controls.Presenters
{
@ -496,14 +497,14 @@ namespace Avalonia.Controls.Presenters
var length = Math.Max(selectionStart, selectionEnd) - start;
IReadOnlyList<ValueSpan<TextRunProperties>>? textStyleOverrides = null;
if (length > 0)
{
textStyleOverrides = new[]
{
new ValueSpan<TextRunProperties>(start, length,
new GenericTextRunProperties(typeface, FontSize,
foregroundBrush: SelectionForegroundBrush ?? Brushes.White))
foregroundBrush: SelectionForegroundBrush ?? Brushes.Red))
};
}

10
src/Avalonia.Controls/TextBox.cs

@ -17,6 +17,7 @@ using Avalonia.Controls.Metadata;
using Avalonia.Media.TextFormatting;
using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Automation.Peers;
using System.Diagnostics;
namespace Avalonia.Controls
{
@ -1240,9 +1241,12 @@ namespace Avalonia.Controls
MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)),
MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0)));
_presenter.MoveCaretToPoint(point);
var hit = _presenter.TextLayout.HitTestPoint(point);
var caretIndex = hit.TextPosition;
Debug.WriteLine($"TextPos: {caretIndex}, X: {point.X}");
var caretIndex = _presenter.CaretIndex;
var text = Text;
if (text != null && _wordSelectionStart >= 0)
@ -1266,7 +1270,7 @@ namespace Avalonia.Controls
}
else
{
SelectionEnd = _presenter.CaretIndex;
SelectionEnd = caretIndex;
}
}
}

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

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia.Media;
@ -914,14 +915,14 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
public void Should_Get_CharacterHit_From_Distance_RTL()
{
using (Start())
{
{
var text = "أَبْجَدِيَّة عَرَبِيَّة";
var layout = new TextLayout(
text,
Typeface.Default,
12,
Brushes.Black);
text,
Typeface.Default,
12,
Brushes.Black);
var textLine = layout.TextLines[0];
@ -952,6 +953,63 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
rect = layout.HitTestTextPosition(23);
Assert.Equal(0, rect.Left, 5);
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance_RTL_With_TextStyles()
{
using (Start())
{
var text = "أَبْجَدِيَّة عَرَبِيَّة";
var i = 0;
var graphemeEnumerator = new GraphemeEnumerator(text.AsMemory());
while (graphemeEnumerator.MoveNext())
{
var grapheme = graphemeEnumerator.Current;
var textStyleOverrides = new[] { new ValueSpan<TextRunProperties>(i, grapheme.Text.Length, new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Red)) };
i += grapheme.Text.Length;
var layout = new TextLayout(
text,
Typeface.Default,
12,
Brushes.Black,
textStyleOverrides: textStyleOverrides);
var textLine = layout.TextLines[0];
var shapedRuns = textLine.TextRuns.Cast<ShapedTextCharacters>().ToList();
var clusters = shapedRuns.SelectMany(x => x.ShapedBuffer.GlyphClusters).ToList();
var glyphAdvances = shapedRuns.SelectMany(x => x.ShapedBuffer.GlyphAdvances).ToList();
var currentX = 0.0;
for (int j = 0; j < clusters.Count; j++)
{
var cluster = clusters[j];
var characterHit = textLine.GetCharacterHitFromDistance(currentX);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentX, distance);
var glyphAdvance = glyphAdvances[j];
currentX += glyphAdvance;
}
}
}
}

Loading…
Cancel
Save