Browse Source

Fix text line hit testing

Fix some xml comments
pull/3438/head
Benedikt Schroeder 7 years ago
parent
commit
38bd8b8010
  1. 10
      src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs
  2. 2
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs
  3. 174
      src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs
  4. 23
      src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs
  5. 18
      src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs
  6. 7
      src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs
  7. 70
      tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs

10
src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs

@ -22,9 +22,7 @@ namespace Avalonia.Media.TextFormatting
GlyphRun = glyphRun;
}
/// <summary>
/// Gets the bounds.
/// </summary>
/// <inheritdoc/>
public override Rect Bounds => GlyphRun.Bounds;
/// <summary>
@ -35,11 +33,7 @@ namespace Avalonia.Media.TextFormatting
/// </value>
public GlyphRun GlyphRun { get; }
/// <summary>
/// Draws the <see cref="TextRun"/> at the given origin.
/// </summary>
/// <param name="drawingContext">The drawing context.</param>
/// <param name="origin">The origin.</param>
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
if (GlyphRun.GlyphIndices.Length == 0)

2
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs

@ -166,7 +166,7 @@ namespace Avalonia.Media.TextFormatting
var splitResult = SplitTextRuns(textRuns, measuredLength);
var trimmedRuns = new List<TextRun>(splitResult.First.Count + 1);
var trimmedRuns = new List<ShapedTextRun>(splitResult.First.Count + 1);
trimmedRuns.AddRange(splitResult.First);

174
src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs

@ -6,42 +6,43 @@ namespace Avalonia.Media.TextFormatting
{
internal class SimpleTextLine : TextLine
{
public SimpleTextLine(TextPointer textPointer, IReadOnlyList<TextRun> textRuns, TextLineMetrics lineMetrics) :
base(textPointer, textRuns, lineMetrics)
{
private readonly IReadOnlyList<ShapedTextRun> _textRuns;
public SimpleTextLine(TextPointer textPointer, IReadOnlyList<ShapedTextRun> textRuns, TextLineMetrics lineMetrics)
{
Text = textPointer;
_textRuns = textRuns;
LineMetrics = lineMetrics;
}
/// <inheritdoc/>
public override TextPointer Text { get; }
/// <inheritdoc/>
public override IReadOnlyList<TextRun> TextRuns => _textRuns;
/// <inheritdoc/>
public override TextLineMetrics LineMetrics { get; }
/// <inheritdoc/>
public override void Draw(IDrawingContextImpl drawingContext, Point origin)
{
var currentX = origin.X;
foreach (var textRun in TextRuns)
foreach (var textRun in _textRuns)
{
if (!(textRun is DrawableTextRun drawableRun))
{
continue;
}
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X,
origin.Y + LineMetrics.BaselineOrigin.Y);
drawableRun.Draw(drawingContext, baselineOrigin);
textRun.Draw(drawingContext, baselineOrigin);
currentX += drawableRun.Bounds.Width;
currentX += textRun.Bounds.Width;
}
}
/// <summary>
/// Client to get the character hit corresponding to the specified
/// distance from the beginning of the line.
/// </summary>
/// <param name="distance">distance in text flow direction from the beginning of the line</param>
/// <returns>character hit</returns>
/// <inheritdoc/>
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
var first = Text.Start;
if (distance < 0)
{
// hit happens before the line, return the first position
@ -49,45 +50,30 @@ namespace Avalonia.Media.TextFormatting
}
// process hit that happens within the line
var runIndex = new CharacterHit();
var characterHit = new CharacterHit();
foreach (var run in TextRuns)
foreach (var run in _textRuns)
{
var shapedTextRun = (ShapedTextRun)run;
first += runIndex.TrailingLength;
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _);
runIndex = shapedTextRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
first += runIndex.FirstCharacterIndex;
if (distance <= shapedTextRun.Bounds.Width)
if (distance <= run.Bounds.Width)
{
break;
}
distance -= shapedTextRun.Bounds.Width;
distance -= run.Bounds.Width;
}
return new CharacterHit(first, runIndex.TrailingLength);
return characterHit;
}
/// <summary>
/// Client to get the distance from the beginning of the line from the specified
/// character hit.
/// </summary>
/// <param name="characterHit">character hit of the character to query the distance.</param>
/// <returns>distance in text flow direction from the beginning of the line.</returns>
/// <inheritdoc/>
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
return DistanceFromCp(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0));
}
/// <summary>
/// Client to get the next character hit for caret navigation
/// </summary>
/// <param name="characterHit">the current character hit</param>
/// <returns>the next character hit</returns>
/// <inheritdoc/>
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit)
{
int nextVisibleCp;
@ -95,7 +81,7 @@ namespace Avalonia.Media.TextFormatting
if (characterHit.TrailingLength == 0)
{
navigableCpFound = FindNextVisibleCp(characterHit.FirstCharacterIndex, out nextVisibleCp);
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp);
if (navigableCpFound)
{
@ -104,7 +90,7 @@ namespace Avalonia.Media.TextFormatting
}
}
navigableCpFound = FindNextVisibleCp(characterHit.FirstCharacterIndex + 1, out nextVisibleCp);
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp);
if (navigableCpFound)
{
@ -116,18 +102,14 @@ namespace Avalonia.Media.TextFormatting
return characterHit;
}
/// <summary>
/// Client to get the previous character hit for caret navigation
/// </summary>
/// <param name="characterHit">the current character hit</param>
/// <returns>the previous character hit</returns>
/// <inheritdoc/>
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit)
{
int previousVisibleCp;
bool navigableCpFound;
int previousCodepointIndex;
bool codepointIndexFound;
int cpHit = characterHit.FirstCharacterIndex;
bool trailingHit = (characterHit.TrailingLength != 0);
var cpHit = characterHit.FirstCharacterIndex;
var trailingHit = characterHit.TrailingLength != 0;
// Input can be right after the end of the current line. Snap it to be at the end of the line.
if (cpHit >= Text.Start + Text.Length)
@ -139,32 +121,28 @@ namespace Avalonia.Media.TextFormatting
if (trailingHit)
{
navigableCpFound = FindPreviousVisibleCp(cpHit, out previousVisibleCp);
codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex);
if (navigableCpFound)
if (codepointIndexFound)
{
// Move from trailing to leading edge
return new CharacterHit(previousVisibleCp, 0);
return new CharacterHit(previousCodepointIndex, 0);
}
}
navigableCpFound = FindPreviousVisibleCp(cpHit - 1, out previousVisibleCp);
codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex);
if (navigableCpFound)
if (codepointIndexFound)
{
// Move from leading edge of current character to leading edge of previous
return new CharacterHit(previousVisibleCp, 0);
return new CharacterHit(previousCodepointIndex, 0);
}
// Can't move, we're before the first character
return characterHit;
}
/// <summary>
/// Client to get the previous character hit after backspacing
/// </summary>
/// <param name="characterHit">the current character hit</param>
/// <returns>the character hit after backspacing</returns>
/// <inheritdoc/>
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit)
{
// same operation as move-to-previous
@ -172,44 +150,41 @@ namespace Avalonia.Media.TextFormatting
}
/// <summary>
/// Get distance from line start to the specified cp
/// Get distance from line start to the specified codepoint index
/// </summary>
private double DistanceFromCp(int currentIndex)
private double DistanceFromCodepointIndex(int codepointIndex)
{
var distance = 0.0;
var dcp = currentIndex - Text.Start;
var currentDistance = 0.0;
foreach (var textRun in TextRuns)
foreach (var textRun in _textRuns)
{
var run = (ShapedTextRun)textRun;
distance += run.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(dcp));
if (dcp <= run.Text.Length)
if (codepointIndex > textRun.Text.End)
{
break;
currentDistance += textRun.Bounds.Width;
continue;
}
dcp -= run.Text.Length;
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex));
}
return distance;
return currentDistance;
}
/// <summary>
/// Search forward from the given cp index (inclusive) to find the next navigable cp index.
/// Return true if one such cp is found, false otherwise.
/// Search forward from the given codepoint index (inclusive) to find the next navigable codepoint index.
/// Return true if one such codepoint index is found, false otherwise.
/// </summary>
private bool FindNextVisibleCp(int cp, out int cpVisible)
private bool FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex)
{
cpVisible = cp;
nextCodepointIndex = codepointIndex;
if (cp >= Text.Start + Text.Length)
if (codepointIndex >= Text.Start + Text.Length)
{
return false; // Cannot go forward anymore
}
GetRunIndexAtCp(cp, out var runIndex, out var cpRunStart);
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart);
while (runIndex < TextRuns.Count)
{
@ -217,7 +192,7 @@ namespace Avalonia.Media.TextFormatting
// navigable.
if (runIndex < TextRuns.Count)
{
cpVisible = Math.Max(cpRunStart, cp);
nextCodepointIndex = Math.Max(cpRunStart, codepointIndex);
return true;
}
@ -228,29 +203,29 @@ namespace Avalonia.Media.TextFormatting
}
/// <summary>
/// Search backward from the given cp index (inclusive) to find the previous navigable cp index.
/// Return true if one such cp is found, false otherwise.
/// Search backward from the given codepoint index (inclusive) to find the previous navigable codepoint index.
/// Return true if one such codepoint is found, false otherwise.
/// </summary>
private bool FindPreviousVisibleCp(int cp, out int cpVisible)
private bool FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex)
{
cpVisible = cp;
previousCodepointIndex = codepointIndex;
if (cp < Text.Start)
if (codepointIndex < Text.Start)
{
return false; // Cannot go backward anymore.
}
// Position the cpRunEnd at the end of the span that contains the given cp
GetRunIndexAtCp(cp, out var runIndex, out var cpRunEnd);
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd);
cpRunEnd += TextRuns[runIndex].Text.End;
codepointIndexAtRunEnd += TextRuns[runIndex].Text.End;
while (runIndex >= 0)
{
// Visible content has caret stops at its leading edge.
if (runIndex + 1 < TextRuns.Count)
{
cpVisible = Math.Min(cpRunEnd, cp);
previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex);
return true;
}
@ -258,25 +233,26 @@ namespace Avalonia.Media.TextFormatting
if (runIndex == TextRuns.Count)
{
// Get the cp index at the beginning of the newline sequence.
cpVisible = cpRunEnd - TextRuns[runIndex].Text.Length + 1;
previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1;
return true;
}
cpRunEnd -= TextRuns[runIndex--].Text.Length;
codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length;
}
return false;
}
private void GetRunIndexAtCp(int cp, out int runIndex, out int cpRunStart)
private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart)
{
cpRunStart = Text.Start;
codepointIndexAtRunStart = Text.Start;
runIndex = 0;
// Find the span that contains the given cp
while (runIndex < TextRuns.Count && cpRunStart + TextRuns[runIndex].Text.Length <= cp)
while (runIndex < TextRuns.Count &&
codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex)
{
cpRunStart += TextRuns[runIndex++].Text.Length;
codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length;
}
}
}

23
src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs

@ -20,7 +20,7 @@ namespace Avalonia.Media.TextFormatting
private readonly ReadOnlySlice<char> _text;
private readonly TextParagraphProperties _paragraphProperties;
private readonly TextStyleRun[] _textStyleOverrides;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides;
/// <summary>
/// Initializes a new instance of the <see cref="TextLayout" /> class.
@ -47,7 +47,7 @@ namespace Avalonia.Media.TextFormatting
TextDecorationCollection textDecorations = null,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
TextStyleRun[] textStyleOverrides = null)
IReadOnlyList<TextStyleRun> textStyleOverrides = null)
{
_text = string.IsNullOrEmpty(text) ?
new ReadOnlySlice<char>() :
@ -276,10 +276,10 @@ namespace Avalonia.Media.TextFormatting
{
private readonly ReadOnlySlice<char> _text;
private readonly TextStyle _defaultStyle;
private readonly TextStyleRun[] _textStyleOverrides;
private readonly IReadOnlyList<TextStyleRun> _textStyleOverrides;
public FormattedTextSource(ReadOnlySlice<char> text, TextStyle defaultStyle,
TextStyleRun[] textStyleOverrides)
IReadOnlyList<TextStyleRun> textStyleOverrides)
{
_text = text;
_defaultStyle = defaultStyle;
@ -311,8 +311,13 @@ namespace Avalonia.Media.TextFormatting
/// The created text style run.
/// </returns>
private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice<char> text,
TextStyle defaultTextStyle, ReadOnlySpan<TextStyleRun> textStyleOverrides)
TextStyle defaultTextStyle, IReadOnlyList<TextStyleRun> textStyleOverrides)
{
if(textStyleOverrides == null || textStyleOverrides.Count == 0)
{
return new TextStyleRun(new TextPointer(text.Start, text.Length), defaultTextStyle);
}
var currentTextStyle = defaultTextStyle;
var hasOverride = false;
@ -321,7 +326,7 @@ namespace Avalonia.Media.TextFormatting
var length = 0;
for (; i < textStyleOverrides.Length; i++)
for (; i < textStyleOverrides.Count; i++)
{
var styleOverride = textStyleOverrides[i];
@ -341,7 +346,7 @@ namespace Avalonia.Media.TextFormatting
if (textPointer.Start > text.Start)
{
if (styleOverride.Style.TextFormat != currentTextStyle.TextFormat ||
currentTextStyle.Foreground != styleOverride.Style.Foreground)
!currentTextStyle.Foreground.Equals(styleOverride.Style.Foreground))
{
length = Math.Min(Math.Abs(textPointer.Start - text.Start), text.Length);
@ -361,9 +366,9 @@ namespace Avalonia.Media.TextFormatting
currentTextStyle = styleOverride.Style;
}
if (length < text.Length && i == textStyleOverrides.Length)
if (length < text.Length && i == textStyleOverrides.Count)
{
if (currentTextStyle.Foreground == defaultTextStyle.Foreground &&
if (currentTextStyle.Foreground.Equals(defaultTextStyle.Foreground) &&
currentTextStyle.TextFormat == defaultTextStyle.TextFormat)
{
length = text.Length;

18
src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs

@ -11,25 +11,13 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
public abstract class TextLine
{
protected TextLine()
{
}
protected TextLine(TextPointer text, IReadOnlyList<TextRun> textRuns, TextLineMetrics lineMetrics)
{
Text = text;
TextRuns = textRuns;
LineMetrics = lineMetrics;
}
/// <summary>
/// Gets the text.
/// </summary>
/// <value>
/// The text pointer.
/// </value>
public TextPointer Text { get; protected set; }
public abstract TextPointer Text { get; }
/// <summary>
/// Gets the text runs.
@ -37,7 +25,7 @@ namespace Avalonia.Media.TextFormatting
/// <value>
/// The text runs.
/// </value>
public IReadOnlyList<TextRun> TextRuns { get; protected set; }
public abstract IReadOnlyList<TextRun> TextRuns { get; }
/// <summary>
/// Gets the line metrics.
@ -45,7 +33,7 @@ namespace Avalonia.Media.TextFormatting
/// <value>
/// The line metrics.
/// </value>
public TextLineMetrics LineMetrics { get; protected set; }
public abstract TextLineMetrics LineMetrics { get; }
/// <summary>
/// Draws the <see cref="TextLine"/> at the given origin.

7
src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs

@ -43,12 +43,7 @@ namespace Avalonia.Media.TextFormatting
}
}
/// <summary>
/// Shapes the specified text and returns a resulting glyph run.
/// </summary>
/// <param name="text">The text.</param>
/// <param name="textFormat">The text format.</param>
/// <returns>A shaped glyph run.</returns>
/// <inheritdoc cref="ITextShaperImpl.ShapeText"/>
public GlyphRun ShapeText(ReadOnlySlice<char> text, TextFormat textFormat)
{
return _platformImpl.ShapeText(text, textFormat);

70
tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs

@ -43,7 +43,7 @@ namespace Avalonia.Skia.UnitTests
{
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black);
var textSource = new MultipleBufferTextSource(defaultTextRunStyle);
var textSource = new MultiBufferTextSource(defaultTextRunStyle);
var formatter = new SimpleTextFormatter();
@ -56,18 +56,20 @@ namespace Avalonia.Skia.UnitTests
}
}
private class MultipleBufferTextSource : ITextSource
private class MultiBufferTextSource : ITextSource
{
private readonly string[] _runTexts;
private readonly TextStyle _defaultStyle;
public MultipleBufferTextSource(TextStyle defaultStyle)
public MultiBufferTextSource(TextStyle defaultStyle)
{
_defaultStyle = defaultStyle;
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" };
}
public TextPointer TextPointer => new TextPointer(0, 50);
public TextRun GetTextRun(int textSourceIndex)
{
if (textSourceIndex == 50)
@ -197,7 +199,7 @@ namespace Avalonia.Skia.UnitTests
}
[Fact]
public void Should_Split_Run_On_Direction()
public void Should_Split_Run_On_Script()
{
using (Start())
{
@ -219,18 +221,36 @@ namespace Avalonia.Skia.UnitTests
{
using (Start())
{
const string text = "0123456789";
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(text.Length));
var currentDistance = 0.0;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
Assert.Equal(textLine.LineMetrics.Size.Width, distance);
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var advance = glyphRun.GlyphAdvances[i];
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster));
Assert.Equal(currentDistance, distance);
currentDistance += advance;
}
}
Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length)));
}
}
@ -239,18 +259,40 @@ namespace Avalonia.Skia.UnitTests
{
using (Start())
{
const string text = "0123456789";
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default));
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default));
var formatter = new SimpleTextFormatter();
var textLine =
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties());
var characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
var currentDistance = 0.0;
CharacterHit characterHit;
foreach (var run in textLine.TextRuns)
{
var textRun = (ShapedTextRun)run;
var glyphRun = textRun.GlyphRun;
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++)
{
var cluster = glyphRun.GlyphClusters[i];
var advance = glyphRun.GlyphAdvances[i];
characterHit = textLine.GetCharacterHitFromDistance(currentDistance);
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
currentDistance += advance;
}
}
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width);
Assert.Equal(textLine.Text.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex);
}
}

Loading…
Cancel
Save