Browse Source

Merge pull request #8471 from Gillibald/fixes/textProcessingFixes

More text processing fixes
pull/8597/head
Max Katz 4 years ago
committed by GitHub
parent
commit
ad6eef6dd4
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      samples/ControlCatalog/Pages/TextBlockPage.xaml
  2. 109
      src/Avalonia.Base/Media/GlyphRun.cs
  3. 2
      src/Avalonia.Base/Media/TextDecoration.cs
  4. 51
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  5. 433
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  6. 9
      src/Avalonia.Controls/Documents/LineBreak.cs
  7. 119
      src/Avalonia.Controls/RichTextBlock.cs
  8. 7
      src/Avalonia.Controls/TextBlock.cs
  9. 285
      src/Avalonia.Controls/TextBox.cs
  10. 10
      src/Avalonia.Themes.Default/Controls/RichTextBlock.xaml
  11. 1
      src/Avalonia.Themes.Default/DefaultTheme.xaml
  12. 1
      src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
  13. 14
      src/Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml
  14. BIN
      tests/Avalonia.RenderTests/Assets/NotoKufiArabic-Regular.ttf
  15. 4
      tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs
  16. 8
      tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs
  17. 129
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs
  18. 17
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

2
samples/ControlCatalog/Pages/TextBlockPage.xaml

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

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

@ -445,7 +445,7 @@ namespace Avalonia.Media
/// </returns> /// </returns>
public int FindGlyphIndex(int characterIndex) public int FindGlyphIndex(int characterIndex)
{ {
if (GlyphClusters == null) if (GlyphClusters == null || GlyphClusters.Count == 0)
{ {
return characterIndex; return characterIndex;
} }
@ -614,17 +614,29 @@ namespace Avalonia.Media
private GlyphRunMetrics CreateGlyphRunMetrics() 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) 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); _offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster);
} }
var isReversed = firstCluster > lastCluster;
var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale; var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
var widthIncludingTrailingWhitespace = 0d; 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++) for (var index = 0; index < GlyphIndices.Count; index++)
{ {
@ -635,16 +647,16 @@ namespace Avalonia.Media
var width = widthIncludingTrailingWhitespace; 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 _); width -= GetGlyphAdvance(index, out _);
} }
} }
else else
{ {
for (var index = 0; index < glyphCount; index++) for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
{ {
width -= GetGlyphAdvance(index, out _); width -= GetGlyphAdvance(index, out _);
} }
@ -654,16 +666,15 @@ namespace Avalonia.Media
height); height);
} }
private int GetTrailingWhitespaceLength(out int newLineLength, out int glyphCount) private int GetTrailingWhitespaceLength(bool isReversed, out int newLineLength, out int glyphCount)
{ {
glyphCount = 0; if (isReversed)
newLineLength = 0;
if (Characters.IsEmpty)
{ {
return 0; return GetTralingWhitespaceLengthRightToLeft(out newLineLength, out glyphCount);
} }
glyphCount = 0;
newLineLength = 0;
var trailingWhitespaceLength = 0; var trailingWhitespaceLength = 0;
if (GlyphClusters == null) if (GlyphClusters == null)
@ -732,6 +743,78 @@ namespace Avalonia.Media
return trailingWhitespaceLength; 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) private void Set<T>(ref T field, T value)
{ {
_glyphRunImpl?.Dispose(); _glyphRunImpl?.Dispose();

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

@ -209,7 +209,7 @@ namespace Avalonia.Media
var pen = new Pen(Stroke ?? defaultBrush, thickness, var pen = new Pen(Stroke ?? defaultBrush, thickness,
new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap); new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap);
drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Size.Width, 0)); drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Metrics.Width, 0));
} }
} }
} }

51
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@ -63,7 +63,7 @@ namespace Avalonia.Media.TextFormatting
MaxHeight = maxHeight; MaxHeight = maxHeight;
MaxLines = maxLines; MaxLines = maxLines;
TextLines = CreateTextLines(); TextLines = CreateTextLines();
} }
@ -80,7 +80,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="maxLines">The maximum number of text lines.</param> /// <param name="maxLines">The maximum number of text lines.</param>
public TextLayout( public TextLayout(
ITextSource textSource, ITextSource textSource,
TextParagraphProperties paragraphProperties, TextParagraphProperties paragraphProperties,
TextTrimming? textTrimming = null, TextTrimming? textTrimming = null,
double maxWidth = double.PositiveInfinity, double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity, double maxHeight = double.PositiveInfinity,
@ -178,24 +178,18 @@ namespace Avalonia.Media.TextFormatting
return new Rect(); return new Rect();
} }
if (textPosition < 0 || textPosition >= _textSourceLength) if (textPosition < 0)
{ {
var lastLine = TextLines[TextLines.Count - 1]; textPosition = _textSourceLength;
var lineX = lastLine.Width;
var lineY = Bounds.Bottom - lastLine.Height;
return new Rect(lineX, lineY, 0, lastLine.Height);
} }
var currentY = 0.0; var currentY = 0.0;
foreach (var textLine in TextLines) foreach (var textLine in TextLines)
{ {
var end = textLine.FirstTextSourceIndex + textLine.Length - 1; var end = textLine.FirstTextSourceIndex + textLine.Length;
if (end < textPosition) if (end <= textPosition && end < _textSourceLength)
{ {
currentY += textLine.Height; currentY += textLine.Height;
@ -224,7 +218,7 @@ namespace Avalonia.Media.TextFormatting
} }
var result = new List<Rect>(TextLines.Count); var result = new List<Rect>(TextLines.Count);
var currentY = 0d; var currentY = 0d;
foreach (var textLine in TextLines) foreach (var textLine in TextLines)
@ -239,7 +233,7 @@ namespace Avalonia.Media.TextFormatting
var textBounds = textLine.GetTextBounds(start, length); var textBounds = textLine.GetTextBounds(start, length);
if(textBounds.Count > 0) if (textBounds.Count > 0)
{ {
foreach (var bounds in textBounds) foreach (var bounds in textBounds)
{ {
@ -262,7 +256,7 @@ namespace Avalonia.Media.TextFormatting
} }
} }
if(textLine.FirstTextSourceIndex + textLine.Length >= start + length) if (textLine.FirstTextSourceIndex + textLine.Length >= start + length)
{ {
break; break;
} }
@ -305,7 +299,7 @@ namespace Avalonia.Media.TextFormatting
return GetHitTestResult(currentLine, characterHit, point); return GetHitTestResult(currentLine, characterHit, point);
} }
public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge) public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge)
{ {
if (charIndex < 0) if (charIndex < 0)
@ -327,7 +321,7 @@ namespace Avalonia.Media.TextFormatting
continue; continue;
} }
if (charIndex >= textLine.FirstTextSourceIndex && if (charIndex >= textLine.FirstTextSourceIndex &&
charIndex <= textLine.FirstTextSourceIndex + textLine.Length - (trailingEdge ? 0 : 1)) charIndex <= textLine.FirstTextSourceIndex + textLine.Length - (trailingEdge ? 0 : 1))
{ {
return index; return index;
@ -398,7 +392,7 @@ namespace Avalonia.Media.TextFormatting
/// <param name="left">The current left.</param> /// <param name="left">The current left.</param>
/// <param name="width">The current width.</param> /// <param name="width">The current width.</param>
/// <param name="height">The current height.</param> /// <param name="height">The current height.</param>
private static void UpdateBounds(TextLine textLine,ref double left, ref double width, ref double height) private static void UpdateBounds(TextLine textLine, ref double left, ref double width, ref double height)
{ {
var lineWidth = textLine.WidthIncludingTrailingWhitespace; var lineWidth = textLine.WidthIncludingTrailingWhitespace;
@ -421,7 +415,7 @@ namespace Avalonia.Media.TextFormatting
{ {
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties); var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties);
Bounds = new Rect(0,0,0, textLine.Height); Bounds = new Rect(0, 0, 0, textLine.Height);
return new List<TextLine> { textLine }; return new List<TextLine> { textLine };
} }
@ -439,9 +433,9 @@ namespace Avalonia.Media.TextFormatting
var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth, var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak); _paragraphProperties, previousLine?.TextLineBreak);
if(textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph) if (textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph)
{ {
if(previousLine != null && previousLine.NewLineLength > 0) if (previousLine != null && previousLine.NewLineLength > 0)
{ {
var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties); var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties);
@ -454,7 +448,7 @@ namespace Avalonia.Media.TextFormatting
} }
_textSourceLength += textLine.Length; _textSourceLength += textLine.Length;
//Fulfill max height constraint //Fulfill max height constraint
if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight) if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight)
{ {
@ -485,12 +479,17 @@ namespace Avalonia.Media.TextFormatting
//Fulfill max lines constraint //Fulfill max lines constraint
if (MaxLines > 0 && textLines.Count >= MaxLines) if (MaxLines > 0 && textLines.Count >= MaxLines)
{ {
if(textLine.TextLineBreak is TextLineBreak lineBreak && lineBreak.RemainingRuns != null)
{
textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width));
}
break; break;
} }
} }
//Make sure the TextLayout always contains at least on empty line //Make sure the TextLayout always contains at least on empty line
if(textLines.Count == 0) if (textLines.Count == 0)
{ {
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties); var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties);
@ -501,7 +500,7 @@ namespace Avalonia.Media.TextFormatting
Bounds = new Rect(left, 0, width, height); Bounds = new Rect(left, 0, width, height);
if(_paragraphProperties.TextAlignment == TextAlignment.Justify) if (_paragraphProperties.TextAlignment == TextAlignment.Justify)
{ {
var whitespaceWidth = 0d; var whitespaceWidth = 0d;
@ -509,7 +508,7 @@ namespace Avalonia.Media.TextFormatting
{ {
var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace; var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace;
if(lineWhitespaceWidth > whitespaceWidth) if (lineWhitespaceWidth > whitespaceWidth)
{ {
whitespaceWidth = lineWhitespaceWidth; whitespaceWidth = lineWhitespaceWidth;
} }
@ -517,7 +516,7 @@ namespace Avalonia.Media.TextFormatting
var justificationWidth = width - whitespaceWidth; var justificationWidth = width - whitespaceWidth;
if(justificationWidth > 0) if (justificationWidth > 0)
{ {
var justificationProperties = new InterWordJustification(justificationWidth); var justificationProperties = new InterWordJustification(justificationWidth);

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

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

9
src/Avalonia.Controls/Documents/LineBreak.cs

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Avalonia.LogicalTree;
using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting;
using Avalonia.Metadata; using Avalonia.Metadata;
@ -22,7 +21,13 @@ namespace Avalonia.Controls.Documents
internal override void BuildTextRun(IList<TextRun> textRuns) internal override void BuildTextRun(IList<TextRun> textRuns)
{ {
textRuns.Add(new TextEndOfLine()); var text = Environment.NewLine.AsMemory();
var textRunProperties = CreateTextRunProperties();
var textCharacters = new TextCharacters(text, textRunProperties);
textRuns.Add(textCharacters);
} }
internal override void AppendText(StringBuilder stringBuilder) internal override void AppendText(StringBuilder stringBuilder)

119
src/Avalonia.Controls/RichTextBlock.cs

@ -41,9 +41,6 @@ namespace Avalonia.Controls
public static readonly StyledProperty<IBrush?> SelectionBrushProperty = public static readonly StyledProperty<IBrush?> SelectionBrushProperty =
AvaloniaProperty.Register<RichTextBlock, IBrush?>(nameof(SelectionBrush), Brushes.Blue); AvaloniaProperty.Register<RichTextBlock, IBrush?>(nameof(SelectionBrush), Brushes.Blue);
public static readonly StyledProperty<IBrush?> SelectionForegroundBrushProperty =
AvaloniaProperty.Register<RichTextBlock, IBrush?>(nameof(SelectionForegroundBrush));
/// <summary> /// <summary>
/// Defines the <see cref="Inlines"/> property. /// Defines the <see cref="Inlines"/> property.
/// </summary> /// </summary>
@ -63,12 +60,13 @@ namespace Avalonia.Controls
private bool _canCopy; private bool _canCopy;
private int _selectionStart; private int _selectionStart;
private int _selectionEnd; private int _selectionEnd;
private int _wordSelectionStart = -1;
static RichTextBlock() static RichTextBlock()
{ {
FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true); FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true);
AffectsRender<RichTextBlock>(SelectionStartProperty, SelectionEndProperty, SelectionForegroundBrushProperty, SelectionBrushProperty); AffectsRender<RichTextBlock>(SelectionStartProperty, SelectionEndProperty, SelectionBrushProperty, IsTextSelectionEnabledProperty);
} }
public RichTextBlock() public RichTextBlock()
@ -89,15 +87,6 @@ namespace Avalonia.Controls
set => SetValue(SelectionBrushProperty, value); set => SetValue(SelectionBrushProperty, value);
} }
/// <summary>
/// Gets or sets a value that defines the brush used for selected text.
/// </summary>
public IBrush? SelectionForegroundBrush
{
get => GetValue(SelectionForegroundBrushProperty);
set => SetValue(SelectionForegroundBrushProperty, value);
}
/// <summary> /// <summary>
/// Gets or sets a character index for the beginning of the current selection. /// Gets or sets a character index for the beginning of the current selection.
/// </summary> /// </summary>
@ -200,7 +189,7 @@ namespace Avalonia.Controls
} }
} }
public override void Render(DrawingContext context) protected override void RenderTextLayout(DrawingContext context, Point origin)
{ {
var selectionStart = SelectionStart; var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd; var selectionEnd = SelectionEnd;
@ -215,13 +204,16 @@ namespace Avalonia.Controls
var rects = TextLayout.HitTestTextRange(start, length); var rects = TextLayout.HitTestTextRange(start, length);
foreach (var rect in rects) using (context.PushPostTransform(Matrix.CreateTranslation(origin)))
{ {
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1)); foreach (var rect in rects)
{
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1));
}
} }
} }
base.Render(context); base.RenderTextLayout(context, origin);
} }
/// <summary> /// <summary>
@ -297,8 +289,9 @@ namespace Avalonia.Controls
/// <returns>A <see cref="TextLayout"/> object.</returns> /// <returns>A <see cref="TextLayout"/> object.</returns>
protected override TextLayout CreateTextLayout(string? text) protected override TextLayout CreateTextLayout(string? text)
{ {
var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch);
var defaultProperties = new GenericTextRunProperties( var defaultProperties = new GenericTextRunProperties(
new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), typeface,
FontSize, FontSize,
TextDecorations, TextDecorations,
Foreground); Foreground);
@ -345,6 +338,8 @@ namespace Avalonia.Controls
protected override void OnKeyDown(KeyEventArgs e) protected override void OnKeyDown(KeyEventArgs e)
{ {
base.OnKeyDown(e);
var handled = false; var handled = false;
var modifiers = e.KeyModifiers; var modifiers = e.KeyModifiers;
var keymap = AvaloniaLocator.Current.GetRequiredService<PlatformHotkeyConfiguration>(); var keymap = AvaloniaLocator.Current.GetRequiredService<PlatformHotkeyConfiguration>();
@ -363,6 +358,8 @@ namespace Avalonia.Controls
protected override void OnPointerPressed(PointerPressedEventArgs e) protected override void OnPointerPressed(PointerPressedEventArgs e)
{ {
base.OnPointerPressed(e);
if (!IsTextSelectionEnabled) if (!IsTextSelectionEnabled)
{ {
return; return;
@ -373,7 +370,9 @@ namespace Avalonia.Controls
if (text != null && clickInfo.Properties.IsLeftButtonPressed) if (text != null && clickInfo.Properties.IsLeftButtonPressed)
{ {
var point = e.GetPosition(this); var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
@ -382,8 +381,6 @@ namespace Avalonia.Controls
var hit = TextLayout.HitTestPoint(point); var hit = TextLayout.HitTestPoint(point);
var index = hit.TextPosition; var index = hit.TextPosition;
SelectionStart = SelectionEnd = index;
#pragma warning disable CS0618 // Type or member is obsolete #pragma warning disable CS0618 // Type or member is obsolete
switch (e.ClickCount) switch (e.ClickCount)
#pragma warning restore CS0618 // Type or member is obsolete #pragma warning restore CS0618 // Type or member is obsolete
@ -391,12 +388,34 @@ namespace Avalonia.Controls
case 1: case 1:
if (clickToSelect) if (clickToSelect)
{ {
SelectionStart = Math.Min(oldIndex, index); if (_wordSelectionStart >= 0)
SelectionEnd = Math.Max(oldIndex, index); {
var previousWord = StringUtils.PreviousWord(text, index);
if (index > _wordSelectionStart)
{
SelectionEnd = StringUtils.NextWord(text, index);
}
if (index < _wordSelectionStart || previousWord == _wordSelectionStart)
{
SelectionStart = previousWord;
}
}
else
{
SelectionStart = Math.Min(oldIndex, index);
SelectionEnd = Math.Max(oldIndex, index);
}
} }
else else
{ {
SelectionStart = SelectionEnd = index; if (_wordSelectionStart == -1 || index < SelectionStart || index > SelectionEnd)
{
SelectionStart = SelectionEnd = index;
_wordSelectionStart = -1;
}
} }
break; break;
@ -406,9 +425,13 @@ namespace Avalonia.Controls
SelectionStart = StringUtils.PreviousWord(text, index); SelectionStart = StringUtils.PreviousWord(text, index);
} }
_wordSelectionStart = SelectionStart;
SelectionEnd = StringUtils.NextWord(text, index); SelectionEnd = StringUtils.NextWord(text, index);
break; break;
case 3: case 3:
_wordSelectionStart = -1;
SelectAll(); SelectAll();
break; break;
} }
@ -420,6 +443,8 @@ namespace Avalonia.Controls
protected override void OnPointerMoved(PointerEventArgs e) protected override void OnPointerMoved(PointerEventArgs e)
{ {
base.OnPointerMoved(e);
if (!IsTextSelectionEnabled) if (!IsTextSelectionEnabled)
{ {
return; return;
@ -428,20 +453,49 @@ namespace Avalonia.Controls
// selection should not change during pointer move if the user right clicks // selection should not change during pointer move if the user right clicks
if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{ {
var point = e.GetPosition(this); var text = Text;
var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
point = new Point( point = new Point(
MathUtilities.Clamp(point.X, 0, Math.Max(Bounds.Width - 1, 0)), MathUtilities.Clamp(point.X, 0, Math.Max(TextLayout.Bounds.Width, 0)),
MathUtilities.Clamp(point.Y, 0, Math.Max(Bounds.Height - 1, 0))); MathUtilities.Clamp(point.Y, 0, Math.Max(TextLayout.Bounds.Width, 0)));
var hit = TextLayout.HitTestPoint(point); var hit = TextLayout.HitTestPoint(point);
var textPosition = hit.TextPosition;
if (text != null && _wordSelectionStart >= 0)
{
var distance = textPosition - _wordSelectionStart;
if (distance <= 0)
{
SelectionStart = StringUtils.PreviousWord(text, textPosition);
}
if (distance >= 0)
{
if (SelectionStart != _wordSelectionStart)
{
SelectionStart = _wordSelectionStart;
}
SelectionEnd = StringUtils.NextWord(text, textPosition);
}
}
else
{
SelectionEnd = textPosition;
}
SelectionEnd = hit.TextPosition;
} }
} }
protected override void OnPointerReleased(PointerReleasedEventArgs e) protected override void OnPointerReleased(PointerReleasedEventArgs e)
{ {
base.OnPointerReleased(e);
if (!IsTextSelectionEnabled) if (!IsTextSelectionEnabled)
{ {
return; return;
@ -454,7 +508,9 @@ namespace Avalonia.Controls
if (e.InitialPressMouseButton == MouseButton.Right) if (e.InitialPressMouseButton == MouseButton.Right)
{ {
var point = e.GetPosition(this); var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
var hit = TextLayout.HitTestPoint(point); var hit = TextLayout.HitTestPoint(point);
@ -487,11 +543,6 @@ namespace Avalonia.Controls
InvalidateTextLayout(); InvalidateTextLayout();
break; break;
} }
case nameof(TextProperty):
{
InvalidateTextLayout();
break;
}
} }
} }

7
src/Avalonia.Controls/TextBlock.cs

@ -505,7 +505,12 @@ namespace Avalonia.Controls
} }
} }
TextLayout.Draw(context, new Point(padding.Left, top)); RenderTextLayout(context, new Point(padding.Left, top));
}
protected virtual void RenderTextLayout(DrawingContext context, Point origin)
{
TextLayout.Draw(context, origin);
} }
void IAddChild<string>.AddChild(string text) void IAddChild<string>.AddChild(string text)

285
src/Avalonia.Controls/TextBox.cs

@ -53,7 +53,7 @@ namespace Avalonia.Controls
public static readonly StyledProperty<char> PasswordCharProperty = public static readonly StyledProperty<char> PasswordCharProperty =
AvaloniaProperty.Register<TextBox, char>(nameof(PasswordChar)); AvaloniaProperty.Register<TextBox, char>(nameof(PasswordChar));
public static readonly StyledProperty<IBrush?> SelectionBrushProperty = public static readonly StyledProperty<IBrush?> SelectionBrushProperty =
AvaloniaProperty.Register<TextBox, IBrush?>(nameof(SelectionBrush)); AvaloniaProperty.Register<TextBox, IBrush?>(nameof(SelectionBrush));
@ -80,7 +80,7 @@ namespace Avalonia.Controls
public static readonly StyledProperty<int> MaxLinesProperty = public static readonly StyledProperty<int> MaxLinesProperty =
AvaloniaProperty.Register<TextBox, int>(nameof(MaxLines), defaultValue: 0); AvaloniaProperty.Register<TextBox, int>(nameof(MaxLines), defaultValue: 0);
public static readonly DirectProperty<TextBox, string?> TextProperty = public static readonly DirectProperty<TextBox, string?> TextProperty =
TextBlock.TextProperty.AddOwnerWithDataValidation<TextBox>( TextBlock.TextProperty.AddOwnerWithDataValidation<TextBox>(
o => o.Text, o => o.Text,
@ -105,7 +105,7 @@ namespace Avalonia.Controls
public static readonly StyledProperty<TextWrapping> TextWrappingProperty = public static readonly StyledProperty<TextWrapping> TextWrappingProperty =
TextBlock.TextWrappingProperty.AddOwner<TextBox>(); TextBlock.TextWrappingProperty.AddOwner<TextBox>();
/// <summary> /// <summary>
/// Defines see <see cref="TextPresenter.LineHeight"/> property. /// Defines see <see cref="TextPresenter.LineHeight"/> property.
/// </summary> /// </summary>
@ -202,6 +202,7 @@ namespace Avalonia.Controls
private string _newLine = Environment.NewLine; private string _newLine = Environment.NewLine;
private static readonly string[] invalidCharacters = new String[1] { "\u007f" }; private static readonly string[] invalidCharacters = new String[1] { "\u007f" };
private int _wordSelectionStart = -1;
private int _selectedTextChangesMadeSinceLastUndoSnapshot; private int _selectedTextChangesMadeSinceLastUndoSnapshot;
private bool _hasDoneSnapshotOnce; private bool _hasDoneSnapshotOnce;
private const int _maxCharsBeforeUndoSnapshot = 7; private const int _maxCharsBeforeUndoSnapshot = 7;
@ -275,7 +276,7 @@ namespace Avalonia.Controls
get => GetValue(IsReadOnlyProperty); get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value); set => SetValue(IsReadOnlyProperty, value);
} }
public char PasswordChar public char PasswordChar
{ {
get => GetValue(PasswordCharProperty); get => GetValue(PasswordCharProperty);
@ -307,7 +308,7 @@ namespace Avalonia.Controls
{ {
value = CoerceCaretIndex(value); value = CoerceCaretIndex(value);
var changed = SetAndRaise(SelectionStartProperty, ref _selectionStart, value); var changed = SetAndRaise(SelectionStartProperty, ref _selectionStart, value);
if (changed) if (changed)
{ {
UpdateCommandStates(); UpdateCommandStates();
@ -327,12 +328,12 @@ namespace Avalonia.Controls
{ {
value = CoerceCaretIndex(value); value = CoerceCaretIndex(value);
var changed = SetAndRaise(SelectionEndProperty, ref _selectionEnd, value); var changed = SetAndRaise(SelectionEndProperty, ref _selectionEnd, value);
if (changed) if (changed)
{ {
UpdateCommandStates(); UpdateCommandStates();
} }
if (SelectionStart == value && CaretIndex != value) if (SelectionStart == value && CaretIndex != value)
{ {
CaretIndex = value; CaretIndex = value;
@ -351,7 +352,7 @@ namespace Avalonia.Controls
get => GetValue(MaxLinesProperty); get => GetValue(MaxLinesProperty);
set => SetValue(MaxLinesProperty, value); set => SetValue(MaxLinesProperty, value);
} }
/// <summary> /// <summary>
/// Gets or sets the line height. /// Gets or sets the line height.
/// </summary> /// </summary>
@ -370,7 +371,7 @@ namespace Avalonia.Controls
var caretIndex = CaretIndex; var caretIndex = CaretIndex;
var selectionStart = SelectionStart; var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd; var selectionEnd = SelectionEnd;
CaretIndex = CoerceCaretIndex(caretIndex, value); CaretIndex = CoerceCaretIndex(caretIndex, value);
SelectionStart = CoerceCaretIndex(selectionStart, value); SelectionStart = CoerceCaretIndex(selectionStart, value);
SelectionEnd = CoerceCaretIndex(selectionEnd, value); SelectionEnd = CoerceCaretIndex(selectionEnd, value);
@ -567,7 +568,7 @@ namespace Avalonia.Controls
_presenter = e.NameScope.Get<TextPresenter>("PART_TextPresenter"); _presenter = e.NameScope.Get<TextPresenter>("PART_TextPresenter");
_imClient.SetPresenter(_presenter, this); _imClient.SetPresenter(_presenter, this);
if (IsFocused) if (IsFocused)
{ {
_presenter?.ShowCaret(); _presenter?.ShowCaret();
@ -577,7 +578,7 @@ namespace Avalonia.Controls
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnAttachedToVisualTree(e); base.OnAttachedToVisualTree(e);
if (IsFocused) if (IsFocused)
{ {
_presenter?.ShowCaret(); _presenter?.ShowCaret();
@ -587,7 +588,7 @@ namespace Avalonia.Controls
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnDetachedFromVisualTree(e); base.OnDetachedFromVisualTree(e);
_imClient.SetPresenter(null, null); _imClient.SetPresenter(null, null);
} }
@ -637,7 +638,7 @@ namespace Avalonia.Controls
} }
UpdateCommandStates(); UpdateCommandStates();
_imClient.SetPresenter(_presenter, this); _imClient.SetPresenter(_presenter, this);
_presenter?.ShowCaret(); _presenter?.ShowCaret();
@ -657,7 +658,7 @@ namespace Avalonia.Controls
UpdateCommandStates(); UpdateCommandStates();
_presenter?.HideCaret(); _presenter?.HideCaret();
_imClient.SetPresenter(null, null); _imClient.SetPresenter(null, null);
} }
@ -700,14 +701,14 @@ namespace Avalonia.Controls
if (grapheme.FirstCodepoint.IsBreakChar) if (grapheme.FirstCodepoint.IsBreakChar)
{ {
if(lineCount + 1 > MaxLines) if (lineCount + 1 > MaxLines)
{ {
break; break;
} }
else else
{ {
lineCount++; lineCount++;
} }
} }
length += grapheme.Text.Length; length += grapheme.Text.Length;
@ -736,7 +737,7 @@ namespace Avalonia.Controls
text = Text ?? string.Empty; text = Text ?? string.Empty;
SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex)); SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex));
ClearSelection(); ClearSelection();
if (IsUndoEnabled) if (IsUndoEnabled)
{ {
_undoRedoHelper.DiscardRedo(); _undoRedoHelper.DiscardRedo();
@ -746,7 +747,7 @@ namespace Avalonia.Controls
{ {
RaisePropertyChanged(TextProperty, oldText, _text); RaisePropertyChanged(TextProperty, oldText, _text);
} }
CaretIndex = caretIndex + input.Length; CaretIndex = caretIndex + input.Length;
} }
} }
@ -828,7 +829,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
var text = Text ?? string.Empty; var text = Text ?? string.Empty;
var caretIndex = CaretIndex; var caretIndex = CaretIndex;
var movement = false; var movement = false;
@ -985,87 +986,87 @@ namespace Avalonia.Controls
break; break;
case Key.Up: case Key.Up:
{
selection = DetectSelection();
_presenter.MoveCaretVertical(LogicalDirection.Backward);
if (caretIndex != _presenter.CaretIndex)
{ {
movement = true; selection = DetectSelection();
}
if (selection) _presenter.MoveCaretVertical(LogicalDirection.Backward);
{
SelectionEnd = _presenter.CaretIndex; if (caretIndex != _presenter.CaretIndex)
} {
else movement = true;
{ }
CaretIndex = _presenter.CaretIndex;
if (selection)
{
SelectionEnd = _presenter.CaretIndex;
}
else
{
CaretIndex = _presenter.CaretIndex;
}
break;
} }
break;
}
case Key.Down: case Key.Down:
{
selection = DetectSelection();
_presenter.MoveCaretVertical();
if (caretIndex != _presenter.CaretIndex)
{
movement = true;
}
if (selection)
{
SelectionEnd = _presenter.CaretIndex;
}
else
{ {
CaretIndex = _presenter.CaretIndex; selection = DetectSelection();
_presenter.MoveCaretVertical();
if (caretIndex != _presenter.CaretIndex)
{
movement = true;
}
if (selection)
{
SelectionEnd = _presenter.CaretIndex;
}
else
{
CaretIndex = _presenter.CaretIndex;
}
break;
} }
break;
}
case Key.Back: case Key.Back:
{
SnapshotUndoRedo();
if (hasWholeWordModifiers && SelectionStart == SelectionEnd)
{ {
SetSelectionForControlBackspace(); SnapshotUndoRedo();
}
if (!DeleteSelection())
{
var characterHit = _presenter.GetNextCharacterHit(LogicalDirection.Backward);
var backspacePosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; if (hasWholeWordModifiers && SelectionStart == SelectionEnd)
{
SetSelectionForControlBackspace();
}
if (caretIndex != backspacePosition) if (!DeleteSelection())
{ {
var start = Math.Min(backspacePosition, caretIndex); var characterHit = _presenter.GetNextCharacterHit(LogicalDirection.Backward);
var end = Math.Max(backspacePosition, caretIndex);
var length = end - start; var backspacePosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
var editedText = text.Substring(0, start) + text.Substring(Math.Min(end, text.Length)); if (caretIndex != backspacePosition)
{
var start = Math.Min(backspacePosition, caretIndex);
var end = Math.Max(backspacePosition, caretIndex);
SetTextInternal(editedText); var length = end - start;
CaretIndex = start; var editedText = text.Substring(0, start) + text.Substring(Math.Min(end, text.Length));
}
}
SnapshotUndoRedo();
handled = true; SetTextInternal(editedText);
break;
} CaretIndex = start;
}
}
SnapshotUndoRedo();
handled = true;
break;
}
case Key.Delete: case Key.Delete:
SnapshotUndoRedo(); SnapshotUndoRedo();
if (hasWholeWordModifiers && SelectionStart == SelectionEnd) if (hasWholeWordModifiers && SelectionStart == SelectionEnd)
{ {
SetSelectionForControlDelete(); SetSelectionForControlDelete();
@ -1077,7 +1078,7 @@ namespace Avalonia.Controls
var nextPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; var nextPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
if(nextPosition != caretIndex) if (nextPosition != caretIndex)
{ {
var start = Math.Min(nextPosition, caretIndex); var start = Math.Min(nextPosition, caretIndex);
var end = Math.Max(nextPosition, caretIndex); var end = Math.Max(nextPosition, caretIndex);
@ -1144,7 +1145,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
var text = Text; var text = Text;
var clickInfo = e.GetCurrentPoint(this); var clickInfo = e.GetCurrentPoint(this);
@ -1170,24 +1171,50 @@ namespace Avalonia.Controls
case 1: case 1:
if (clickToSelect) if (clickToSelect)
{ {
SelectionStart = Math.Min(oldIndex, index); if (_wordSelectionStart >= 0)
SelectionEnd = Math.Max(oldIndex, index); {
var previousWord = StringUtils.PreviousWord(text, index);
if (index > _wordSelectionStart)
{
SelectionEnd = StringUtils.NextWord(text, index);
}
if (index < _wordSelectionStart || previousWord == _wordSelectionStart)
{
SelectionStart = previousWord;
}
}
else
{
SelectionStart = Math.Min(oldIndex, index);
SelectionEnd = Math.Max(oldIndex, index);
}
} }
else else
{ {
SelectionStart = SelectionEnd = index; if(_wordSelectionStart == -1 || index < SelectionStart || index > SelectionEnd)
{
SelectionStart = SelectionEnd = index;
_wordSelectionStart = -1;
}
} }
break; break;
case 2: case 2:
if (!StringUtils.IsStartOfWord(text, index)) if (!StringUtils.IsStartOfWord(text, index))
{ {
SelectionStart = StringUtils.PreviousWord(text, index); SelectionStart = StringUtils.PreviousWord(text, index);
} }
_wordSelectionStart = SelectionStart;
SelectionEnd = StringUtils.NextWord(text, index); SelectionEnd = StringUtils.NextWord(text, index);
break; break;
case 3: case 3:
_wordSelectionStart = -1;
SelectAll(); SelectAll();
break; break;
} }
@ -1203,7 +1230,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
// selection should not change during pointer move if the user right clicks // selection should not change during pointer move if the user right clicks
if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{ {
@ -1215,7 +1242,32 @@ namespace Avalonia.Controls
_presenter.MoveCaretToPoint(point); _presenter.MoveCaretToPoint(point);
SelectionEnd = _presenter.CaretIndex; var caretIndex = _presenter.CaretIndex;
var text = Text;
if (text != null && _wordSelectionStart >= 0)
{
var distance = caretIndex - _wordSelectionStart;
if (distance <= 0)
{
SelectionStart = StringUtils.PreviousWord(text, caretIndex);
}
if (distance >= 0)
{
if(SelectionStart != _wordSelectionStart)
{
SelectionStart = _wordSelectionStart;
}
SelectionEnd = StringUtils.NextWord(text, caretIndex);
}
}
else
{
SelectionEnd = _presenter.CaretIndex;
}
} }
} }
@ -1234,9 +1286,9 @@ namespace Avalonia.Controls
if (e.InitialPressMouseButton == MouseButton.Right) if (e.InitialPressMouseButton == MouseButton.Right)
{ {
var point = e.GetPosition(_presenter); var point = e.GetPosition(_presenter);
_presenter.MoveCaretToPoint(point); _presenter.MoveCaretToPoint(point);
var caretIndex = _presenter.CaretIndex; var caretIndex = _presenter.CaretIndex;
// see if mouse clicked inside current selection // see if mouse clicked inside current selection
@ -1250,7 +1302,7 @@ namespace Avalonia.Controls
CaretIndex = SelectionEnd = SelectionStart = caretIndex; CaretIndex = SelectionEnd = SelectionStart = caretIndex;
} }
} }
e.Pointer.Capture(null); e.Pointer.Capture(null);
} }
@ -1309,7 +1361,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
var text = Text ?? string.Empty; var text = Text ?? string.Empty;
var selectionStart = SelectionStart; var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd; var selectionEnd = SelectionEnd;
@ -1319,11 +1371,11 @@ namespace Avalonia.Controls
if (isSelecting) if (isSelecting)
{ {
_presenter.MoveCaretToTextPosition(selectionEnd); _presenter.MoveCaretToTextPosition(selectionEnd);
_presenter.MoveCaretHorizontal(direction > 0 ? _presenter.MoveCaretHorizontal(direction > 0 ?
LogicalDirection.Forward : LogicalDirection.Forward :
LogicalDirection.Backward); LogicalDirection.Backward);
SelectionEnd = _presenter.CaretIndex; SelectionEnd = _presenter.CaretIndex;
} }
else else
@ -1347,7 +1399,7 @@ namespace Avalonia.Controls
else else
{ {
int offset; int offset;
if (direction > 0) if (direction > 0)
{ {
offset = StringUtils.NextWord(text, selectionEnd) - selectionEnd; offset = StringUtils.NextWord(text, selectionEnd) - selectionEnd;
@ -1356,7 +1408,7 @@ namespace Avalonia.Controls
{ {
offset = StringUtils.PreviousWord(text, selectionEnd) - selectionEnd; offset = StringUtils.PreviousWord(text, selectionEnd) - selectionEnd;
} }
SelectionEnd += offset; SelectionEnd += offset;
_presenter.MoveCaretToTextPosition(SelectionEnd); _presenter.MoveCaretToTextPosition(SelectionEnd);
@ -1378,7 +1430,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
var caretIndex = CaretIndex; var caretIndex = CaretIndex;
if (document) if (document)
@ -1401,7 +1453,7 @@ namespace Avalonia.Controls
{ {
return; return;
} }
var text = Text ?? string.Empty; var text = Text ?? string.Empty;
var caretIndex = CaretIndex; var caretIndex = CaretIndex;
@ -1432,8 +1484,9 @@ namespace Avalonia.Controls
private bool DeleteSelection(bool raiseTextChanged = true) private bool DeleteSelection(bool raiseTextChanged = true)
{ {
if (IsReadOnly) return true; if (IsReadOnly)
return true;
var selectionStart = SelectionStart; var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd; var selectionEnd = SelectionEnd;
@ -1444,40 +1497,40 @@ namespace Avalonia.Controls
var text = Text!; var text = Text!;
SetTextInternal(text.Substring(0, start) + text.Substring(end), raiseTextChanged); SetTextInternal(text.Substring(0, start) + text.Substring(end), raiseTextChanged);
_presenter?.MoveCaretToTextPosition(start); _presenter?.MoveCaretToTextPosition(start);
CaretIndex= start; CaretIndex = start;
ClearSelection(); ClearSelection();
return true; return true;
} }
CaretIndex = SelectionStart; CaretIndex = SelectionStart;
return false; return false;
} }
private string GetSelection() private string GetSelection()
{ {
var text = Text; var text = Text;
if (string.IsNullOrEmpty(text)) if (string.IsNullOrEmpty(text))
{ {
return ""; return "";
} }
var selectionStart = SelectionStart; var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd; var selectionEnd = SelectionEnd;
var start = Math.Min(selectionStart, selectionEnd); var start = Math.Min(selectionStart, selectionEnd);
var end = Math.Max(selectionStart, selectionEnd); var end = Math.Max(selectionStart, selectionEnd);
if (start == end || (Text?.Length ?? 0) < end) if (start == end || (Text?.Length ?? 0) < end)
{ {
return ""; return "";
} }
return text.Substring(start, end - start); return text.Substring(start, end - start);
} }
@ -1496,7 +1549,7 @@ namespace Avalonia.Controls
private void SetSelectionForControlBackspace() private void SetSelectionForControlBackspace()
{ {
var selectionStart = CaretIndex; var selectionStart = CaretIndex;
MoveHorizontal(-1, true, false); MoveHorizontal(-1, true, false);
SelectionStart = selectionStart; SelectionStart = selectionStart;
@ -1508,9 +1561,9 @@ namespace Avalonia.Controls
{ {
return; return;
} }
SelectionStart = CaretIndex; SelectionStart = CaretIndex;
MoveHorizontal(1, true, true); MoveHorizontal(1, true, true);
if (SelectionEnd < _text.Length && _text[SelectionEnd] == ' ') if (SelectionEnd < _text.Length && _text[SelectionEnd] == ' ')

10
src/Avalonia.Themes.Default/Controls/RichTextBlock.xaml

@ -0,0 +1,10 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<RichTextBlock IsTextSelectionEnabled="True" Text="Preview"/>
</Design.PreviewWith>
<Style Selector="RichTextBlock[IsTextSelectionEnabled=true]">
<Setter Property="Cursor" Value="IBeam" />
</Style>
</Styles>

1
src/Avalonia.Themes.Default/DefaultTheme.xaml

@ -66,4 +66,5 @@
<StyleInclude Source="avares://Avalonia.Themes.Default/Controls/FlyoutPresenter.xaml"/> <StyleInclude Source="avares://Avalonia.Themes.Default/Controls/FlyoutPresenter.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Controls/MenuFlyoutPresenter.xaml"/> <StyleInclude Source="avares://Avalonia.Themes.Default/Controls/MenuFlyoutPresenter.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Controls/ManagedFileChooser.xaml"/> <StyleInclude Source="avares://Avalonia.Themes.Default/Controls/ManagedFileChooser.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Controls/RichTextBlock.xaml"/>
</Styles> </Styles>

1
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@ -68,6 +68,7 @@
<ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/Slider.xaml" /> <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/Slider.xaml" />
<!-- ManagedFileChooser comes last because it uses (and overrides) styles for a multitude of other controls...the dialogs were originally UserControls, after all --> <!-- ManagedFileChooser comes last because it uses (and overrides) styles for a multitude of other controls...the dialogs were originally UserControls, after all -->
<ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" /> <ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ManagedFileChooser.xaml" />
<ResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml"/>
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
</ResourceDictionary> </ResourceDictionary>
</Styles.Resources> </Styles.Resources>

14
src/Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml

@ -0,0 +1,14 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<RichTextBlock IsTextSelectionEnabled="True" Text="Preview"/>
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type RichTextBlock}" TargetType="RichTextBlock">
<Style Selector="^[IsTextSelectionEnabled=True]">
<Setter Property="Cursor" Value="IBeam" />
</Style>
</ControlTheme>
</ResourceDictionary>

BIN
tests/Avalonia.RenderTests/Assets/NotoKufiArabic-Regular.ttf

Binary file not shown.

4
tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs

@ -15,6 +15,8 @@ namespace Avalonia.Skia.UnitTests.Media
private readonly Typeface _defaultTypeface = private readonly Typeface _defaultTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono");
private readonly Typeface _arabicTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Kufi Arabic");
private readonly Typeface _italicTypeface = private readonly Typeface _italicTypeface =
new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans", FontStyle.Italic); new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans", FontStyle.Italic);
private readonly Typeface _emojiTypeface = private readonly Typeface _emojiTypeface =
@ -22,7 +24,7 @@ namespace Avalonia.Skia.UnitTests.Media
public CustomFontManagerImpl() public CustomFontManagerImpl()
{ {
_customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _arabicTypeface, _defaultTypeface };
_defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName; _defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName;
} }

8
tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs

@ -33,15 +33,11 @@ namespace Avalonia.Skia.UnitTests.Media
{ {
var fontManager = new FontManagerImpl(); var fontManager = new FontManagerImpl();
//we need to have a valid font name different from the default one
string fontName = fontManager.GetInstalledFontFamilyNames().First();
var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface(
new Typeface(new FontFamily($"A, B, {fontName}"), weight: FontWeight.Bold)); new Typeface(new FontFamily($"A, B, Arial"), weight: FontWeight.Bold));
var skTypeface = glyphTypeface.Typeface; var skTypeface = glyphTypeface.Typeface;
Assert.Equal(fontName, skTypeface.FamilyName);
Assert.True(skTypeface.FontWeight >= 600); Assert.True(skTypeface.FontWeight >= 600);
} }

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

@ -154,7 +154,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
{ {
j += inner.Current.Text.Length; j += inner.Current.Text.Length;
if(j + i > text.Length) if (j + i > text.Length)
{ {
break; break;
} }
@ -738,7 +738,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textLine = layout.TextLines[0]; var textLine = layout.TextLines[0];
var start = textLine.GetDistanceFromCharacterHit(new CharacterHit(5, 1)); var start = textLine.GetDistanceFromCharacterHit(new CharacterHit(5, 1));
var end = textLine.GetDistanceFromCharacterHit(new CharacterHit(6, 1)); var end = textLine.GetDistanceFromCharacterHit(new CharacterHit(6, 1));
var rects = layout.HitTestTextRange(0, 7).ToArray(); var rects = layout.HitTestTextRange(0, 7).ToArray();
@ -746,7 +746,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(1, rects.Length); Assert.Equal(1, rects.Length);
var expected = rects[0]; var expected = rects[0];
Assert.Equal(expected.Left, start); Assert.Equal(expected.Left, start);
Assert.Equal(expected.Right, end); Assert.Equal(expected.Right, end);
} }
@ -818,11 +818,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var expected = text.Substring(textLine.FirstTextSourceIndex, textLine.Length); var expected = text.Substring(textLine.FirstTextSourceIndex, textLine.Length);
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
} }
} }
} }
[Fact] [Fact]
public void Should_Layout_Empty_String() public void Should_Layout_Empty_String()
{ {
@ -833,11 +833,128 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Typeface.Default, Typeface.Default,
12, 12,
Brushes.Black); Brushes.Black);
Assert.True(layout.Bounds.Height > 0); 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;
}
}
}
[Fact]
public void Should_Get_CharacterHit_From_Distance_RTL()
{
using (Start())
{
var text = "أَبْجَدِيَّة عَرَبِيَّة";
var layout = new TextLayout(
text,
Typeface.Default,
12,
Brushes.Black);
var textLine = layout.TextLines[0];
var firstRun = (ShapedTextCharacters)textLine.TextRuns[0];
var firstCluster = firstRun.ShapedBuffer.GlyphClusters[0];
var characterHit = textLine.GetCharacterHitFromDistance(0);
Assert.Equal(firstCluster, characterHit.FirstCharacterIndex);
Assert.Equal(text.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength);
var distance = textLine.GetDistanceFromCharacterHit(characterHit);
Assert.Equal(0, distance);
distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex));
var firstAdvance = firstRun.ShapedBuffer.GlyphAdvances[0];
Assert.Equal(firstAdvance, distance, 5);
var rect = layout.HitTestTextPosition(22);
Assert.Equal(firstAdvance, rect.Left, 5);
rect = layout.HitTestTextPosition(23);
Assert.Equal(0, rect.Left, 5);
}
}
private static IDisposable Start() private static IDisposable Start()
{ {
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface

17
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs

@ -867,28 +867,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
var textBounds = textLine.GetTextBounds(0, 4); var textBounds = textLine.GetTextBounds(0, 4);
var firstRun = textLine.TextRuns[1] as ShapedTextCharacters; var secondRun = textLine.TextRuns[1] as ShapedTextCharacters;
Assert.Equal(1, textBounds.Count); Assert.Equal(1, textBounds.Count);
Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); Assert.Equal(secondRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(4, 3); textBounds = textLine.GetTextBounds(4, 3);
var secondRun = textLine.TextRuns[0] as ShapedTextCharacters; var firstRun = textLine.TextRuns[0] as ShapedTextCharacters;
Assert.Equal(1, textBounds.Count); Assert.Equal(1, textBounds.Count);
Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x=> x.Length)); Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x=> x.Length));
Assert.Equal(secondRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width));
textBounds = textLine.GetTextBounds(0, 5); textBounds = textLine.GetTextBounds(0, 5);
Assert.Equal(2, textBounds.Count); Assert.Equal(2, textBounds.Count);
Assert.Equal(5, textBounds.Sum(x=> x.TextRunBounds.Sum(x => x.Length))); Assert.Equal(5, textBounds.Sum(x=> x.TextRunBounds.Sum(x => x.Length)));
Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); Assert.Equal(secondRun.Size.Width, textBounds[1].Rectangle.Width);
Assert.Equal(7.201171875, textBounds[1].Rectangle.Width); Assert.Equal(7.201171875, textBounds[0].Rectangle.Width);
Assert.Equal(textLine.Start + 7.201171875, textBounds[1].Rectangle.Right); Assert.Equal(textLine.Start + 7.201171875, textBounds[0].Rectangle.Right);
Assert.Equal(textLine.Start + firstRun.Size.Width, textBounds[1].Rectangle.Left);
textBounds = textLine.GetTextBounds(0, text.Length); textBounds = textLine.GetTextBounds(0, text.Length);
@ -896,7 +897,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
Assert.Equal(7, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length))); Assert.Equal(7, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length)));
Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width)); Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width));
} }
} }
private class FixedRunsTextSource : ITextSource private class FixedRunsTextSource : ITextSource
{ {

Loading…
Cancel
Save