Browse Source

Merge f3bf87a004 into 658afb8717

pull/20449/merge
busitech 17 hours ago
committed by GitHub
parent
commit
b73e552991
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 22
      src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
  2. 49
      src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
  3. 156
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  4. 69
      src/Avalonia.Controls/TextBlock.cs
  5. 15
      src/Skia/Avalonia.Skia/GlyphRunImpl.cs
  6. 4
      tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs
  7. 3
      tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs
  8. BIN
      tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png
  9. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Account_For_Overhang_Leading_And_Trailing.expected.png
  10. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Draw_MultiLineText_WithOverHandLeadingTrailing.expected.png
  11. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Keep_TrailingWhiteSpace.expected.png
  12. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Measure_Arrange_TextBlock_44_NoWrap.expected.png
  13. BIN
      tests/TestFiles/Skia/Controls/TextBlock/Should_Measure_Arrange_TextBlock_44_Wrap.expected.png
  14. BIN
      tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Blue_Foreground.expected.png
  15. BIN
      tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Default_Foreground.expected.png
  16. BIN
      tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Red_Foreground.expected.png

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

@ -294,7 +294,27 @@ namespace Avalonia.Media.TextFormatting
var endX = textLine.GetDistanceFromCharacterHit(nextCharacterHit);
return new Rect(startX, currentY, endX - startX, textLine.Height);
var width = endX - startX;
var adjustedX = startX;
var lineContentLength = textLine.Length - textLine.NewLineLength;
if (lineContentLength > 0)
{
if (textPosition == textLine.FirstTextSourceIndex && textLine.OverhangLeading < 0 && startX > 0)
{
adjustedX += textLine.OverhangLeading;
width -= textLine.OverhangLeading;
}
var lastCharacterPosition = textLine.FirstTextSourceIndex + lineContentLength - 1;
if (textPosition >= lastCharacterPosition && textLine.OverhangTrailing < 0)
{
width -= textLine.OverhangTrailing;
}
}
return new Rect(adjustedX, currentY, width, textLine.Height);
}
return new Rect();

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

@ -1328,7 +1328,7 @@ namespace Avalonia.Media.TextFormatting
}
}
var inkBounds = new Rect();
Rect? inkBounds = null;
for (var index = 0; index < _textRuns.Length; index++)
{
@ -1342,7 +1342,14 @@ namespace Avalonia.Media.TextFormatting
var runBounds = glyphRun.InkBounds.Translate(new Vector(widthIncludingWhitespace, offsetY));
inkBounds = inkBounds.Union(runBounds);
if (inkBounds == null)
{
inkBounds = runBounds;
}
else
{
inkBounds = inkBounds.Value.Union(runBounds);
}
widthIncludingWhitespace += textRun.Size.Width;
@ -1354,7 +1361,16 @@ namespace Avalonia.Media.TextFormatting
//Align the bounds at the common baseline
var offsetY = -ascent - drawableTextRun.Baseline;
inkBounds = inkBounds.Union(new Rect(new Point(widthIncludingWhitespace, offsetY), drawableTextRun.Size));
var drawableBounds = new Rect(new Point(widthIncludingWhitespace, offsetY), drawableTextRun.Size);
if (inkBounds == null)
{
inkBounds = drawableBounds;
}
else
{
inkBounds = inkBounds.Value.Union(drawableBounds);
}
widthIncludingWhitespace += drawableTextRun.Size.Width;
@ -1362,6 +1378,8 @@ namespace Avalonia.Media.TextFormatting
}
}
}
var finalInkBounds = inkBounds ?? new Rect();
var halfLineGap = lineGap * 0.5;
var naturalHeight = descent - ascent + lineGap;
@ -1416,18 +1434,18 @@ namespace Avalonia.Media.TextFormatting
}
}
var extent = inkBounds.Height;
var extent = finalInkBounds.Height;
//The height of overhanging pixels at the bottom
var overhangAfter = inkBounds.Bottom - height + halfLineGap;
var overhangAfter = finalInkBounds.Bottom - height + halfLineGap;
//The width of overhanging pixels at the natural alignment point. Positive value means we are inside.
var overhangLeading = inkBounds.Left;
var overhangLeading = finalInkBounds.Left;
//The width of overhanging pixels at the end of the natural bounds. Positive value means we are inside.
var overhangTrailing = widthIncludingWhitespace - inkBounds.Right;
var hasOverflowed = MathUtilities.GreaterThan(width, _paragraphWidth);
var overhangTrailing = widthIncludingWhitespace - finalInkBounds.Right;
var hasOverflowed = width > _paragraphWidth;
var start = GetParagraphOffsetX(width, widthIncludingWhitespace);
var start = GetParagraphOffsetX(width, widthIncludingWhitespace, overhangLeading, overhangTrailing);
_inkBounds = inkBounds.Translate(new Vector(start, 0));
_inkBounds = finalInkBounds.Translate(new Vector(start, 0));
_bounds = new Rect(start, 0, widthIncludingWhitespace, height);
@ -1453,9 +1471,11 @@ namespace Avalonia.Media.TextFormatting
/// </summary>
/// <param name="width">The line width.</param>
/// <param name="widthIncludingTrailingWhitespace">The paragraph width including whitespace.</param>
/// <param name="overhangLeading">The leading overhang.</param>
/// <param name="overhangTrailing">The trailing overhang.</param>
/// <returns>The paragraph offset.</returns>
private double GetParagraphOffsetX(double width, double widthIncludingTrailingWhitespace)
private double GetParagraphOffsetX(double width, double widthIncludingTrailingWhitespace, double overhangLeading, double overhangTrailing)
{
if (double.IsPositiveInfinity(_paragraphWidth))
{
@ -1492,6 +1512,7 @@ namespace Avalonia.Media.TextFormatting
switch (textAlignment)
{
case TextAlignment.Center:
{
var start = (_paragraphWidth - width) / 2;
if (paragraphFlowDirection == FlowDirection.RightToLeft)
@ -1500,8 +1521,12 @@ namespace Avalonia.Media.TextFormatting
}
return Math.Max(0, start);
}
case TextAlignment.Right:
return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace);
{
var overhangAdjustment = Math.Min(0, overhangTrailing);
return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace + overhangAdjustment);
}
default:
return 0;
}

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

@ -370,6 +370,41 @@ namespace Avalonia.Controls.Presenters
return textLayout;
}
private double GetLineOffsetX(TextLine line, double controlWidth, TextAlignment textAlignment)
{
var lineInkStartOffset = line.OverhangLeading;
var lineTrailingOverhang = -Math.Min(0, line.OverhangTrailing);
var lineLeadingOverhang = -Math.Min(0, line.OverhangLeading);
var lineInkWidth = line.WidthIncludingTrailingWhitespace + lineLeadingOverhang + lineTrailingOverhang;
switch (textAlignment)
{
case TextAlignment.Center:
return (controlWidth - lineInkWidth) / 2 - lineInkStartOffset - line.Start;
case TextAlignment.Right:
return controlWidth - line.WidthIncludingTrailingWhitespace - lineTrailingOverhang - line.Start;
default: // Left, Justify
return -lineInkStartOffset - line.Start;
}
}
private TextAlignment GetResolvedTextAlignment()
{
var textAlignment = TextAlignment;
var flowDirection = FlowDirection;
if (textAlignment == TextAlignment.Start)
return flowDirection == FlowDirection.LeftToRight ? TextAlignment.Left : TextAlignment.Right;
else if (textAlignment == TextAlignment.End)
return flowDirection == FlowDirection.RightToLeft ? TextAlignment.Left : TextAlignment.Right;
else if (textAlignment == TextAlignment.DetectFromContent)
return TextAlignment.Left;
return textAlignment;
}
/// <summary>
/// Renders the <see cref="TextPresenter"/> to a drawing context.
/// </summary>
@ -383,8 +418,12 @@ namespace Avalonia.Controls.Presenters
context.FillRectangle(background, new Rect(Bounds.Size));
}
if (TextLayout.TextLines.Count == 0)
{
return;
}
var top = 0d;
var left = 0.0;
var textHeight = TextLayout.Height;
@ -402,7 +441,17 @@ namespace Avalonia.Controls.Presenters
}
}
TextLayout.Draw(context, new Point(left, top));
var controlWidth = Bounds.Width;
var textAlignment = GetResolvedTextAlignment();
var currentY = top;
foreach (var line in TextLayout.TextLines)
{
var offsetX = GetLineOffsetX(line, controlWidth, textAlignment);
line.Draw(context, new Point(offsetX, currentY));
currentY += line.Height;
}
}
public sealed override void Render(DrawingContext context)
@ -417,10 +466,35 @@ namespace Avalonia.Controls.Presenters
var length = Math.Max(selectionStart, selectionEnd) - start;
var rects = TextLayout.HitTestTextRange(start, length);
var controlWidth = Bounds.Width;
var textAlignment = GetResolvedTextAlignment();
foreach (var rect in rects)
{
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1));
var currentY = 0d;
TextLine? targetLine = null;
foreach (var line in TextLayout.TextLines)
{
if (currentY + line.Height > rect.Y)
{
targetLine = line;
break;
}
currentY += line.Height;
}
if (targetLine != null)
{
var offsetX = GetLineOffsetX(targetLine, controlWidth, textAlignment);
var transformedRect = rect.WithX(rect.X + offsetX);
context.FillRectangle(selectionBrush, PixelRect.FromRect(transformedRect, 1).ToRect(1));
}
else
{
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1));
}
}
}
@ -472,11 +546,18 @@ namespace Avalonia.Controls.Presenters
var lineIndex = TextLayout.GetLineIndexFromCharacterIndex(caretIndex, _lastCharacterHit.TrailingLength > 0);
var textLine = TextLayout.TextLines[lineIndex];
var x = Math.Floor(_caretBounds.X) + 0.5;
var caretX = Math.Max(0, _caretBounds.X);
var x = Math.Floor(caretX) + 0.5;
var y = Math.Floor(_caretBounds.Y) + 0.5;
var b = Math.Ceiling(_caretBounds.Bottom) - 0.5;
if (_caretBounds.X > 0 && _caretBounds.X >= textLine.WidthIncludingTrailingWhitespace)
var controlWidth = Bounds.Width;
var textAlignment = GetResolvedTextAlignment();
var offsetX = GetLineOffsetX(textLine, controlWidth, textAlignment);
var lineEndX = textLine.WidthIncludingTrailingWhitespace + offsetX + textLine.Start;
if (caretX > 0 && caretX >= lineEndX)
{
x -= 1;
}
@ -646,8 +727,17 @@ namespace Avalonia.Controls.Presenters
InvalidateArrange();
// The textWidth used here is matching that TextBlock uses to measure the text.
var textWidth = TextLayout.OverhangLeading + TextLayout.WidthIncludingTrailingWhitespace + TextLayout.OverhangTrailing;
var maxLeadingOverhang = 0.0;
var maxTrailingOverhang = 0.0;
foreach (var line in TextLayout.TextLines)
{
maxLeadingOverhang = Math.Max(maxLeadingOverhang, -Math.Min(0, line.OverhangLeading));
maxTrailingOverhang = Math.Max(maxTrailingOverhang, -Math.Min(0, line.OverhangTrailing));
}
var textWidth = TextLayout.WidthIncludingTrailingWhitespace + maxLeadingOverhang + maxTrailingOverhang;
return new Size(textWidth, TextLayout.Height);
}
@ -655,8 +745,16 @@ namespace Avalonia.Controls.Presenters
{
var finalWidth = finalSize.Width;
var textWidth = TextLayout.OverhangLeading + TextLayout.WidthIncludingTrailingWhitespace + TextLayout.OverhangTrailing;
textWidth = Math.Ceiling(textWidth);
var maxLeadingOverhang = 0.0;
var maxTrailingOverhang = 0.0;
foreach (var line in TextLayout.TextLines)
{
maxLeadingOverhang = Math.Max(maxLeadingOverhang, -Math.Min(0, line.OverhangLeading));
maxTrailingOverhang = Math.Max(maxTrailingOverhang, -Math.Min(0, line.OverhangTrailing));
}
var textWidth = Math.Ceiling(TextLayout.WidthIncludingTrailingWhitespace + maxLeadingOverhang + maxTrailingOverhang);
if (finalSize.Width < textWidth)
{
@ -715,7 +813,9 @@ namespace Avalonia.Controls.Presenters
public void MoveCaretToPoint(Point point)
{
var hit = TextLayout.HitTestPoint(point);
var transformedPoint = TransformPointToTextLayout(point);
var hit = TextLayout.HitTestPoint(transformedPoint);
UpdateCaret(hit.CharacterHit);
@ -723,6 +823,36 @@ namespace Avalonia.Controls.Presenters
CaretChanged();
}
private Point TransformPointToTextLayout(Point point)
{
if (TextLayout.TextLines.Count == 0)
{
return point;
}
var controlWidth = Bounds.Width;
var textAlignment = GetResolvedTextAlignment();
var currentY = 0d;
TextLine? targetLine = null;
foreach (var line in TextLayout.TextLines)
{
if (currentY + line.Height > point.Y)
{
targetLine = line;
break;
}
currentY += line.Height;
}
targetLine ??= TextLayout.TextLines[TextLayout.TextLines.Count - 1];
var offsetX = GetLineOffsetX(targetLine, controlWidth, textAlignment);
return new Point(point.X - offsetX, point.Y);
}
public void MoveCaretVertical(LogicalDirection direction = LogicalDirection.Forward)
{
@ -917,7 +1047,11 @@ namespace Avalonia.Controls.Presenters
distanceY += currentLine.Height;
}
var caretBounds = new Rect(distanceX, distanceY, 0, textLine.Height);
var controlWidth = Bounds.Width;
var textAlignment = GetResolvedTextAlignment();
var offsetX = GetLineOffsetX(textLine, controlWidth, textAlignment);
var caretBounds = new Rect(distanceX + offsetX, distanceY, 0, textLine.Height);
if (caretBounds != _caretBounds)
{

69
src/Avalonia.Controls/TextBlock.cs

@ -631,9 +631,62 @@ namespace Avalonia.Controls
RenderTextLayout(context, new Point(padding.Left, top));
}
private double GetLineOffsetX(TextLine line, double controlWidth, TextAlignment textAlignment)
{
var lineInkStartOffset = line.OverhangLeading;
var lineTrailingOverhang = -Math.Min(0, line.OverhangTrailing);
var lineLeadingOverhang = -Math.Min(0, line.OverhangLeading);
var lineInkWidth = line.WidthIncludingTrailingWhitespace + lineLeadingOverhang + lineTrailingOverhang;
switch (textAlignment)
{
case TextAlignment.Center:
return (controlWidth - lineInkWidth) / 2 - lineInkStartOffset - line.Start;
case TextAlignment.Right:
return controlWidth - line.WidthIncludingTrailingWhitespace - lineTrailingOverhang - line.Start;
default: // Left, Justify
return -lineInkStartOffset - line.Start;
}
}
private TextAlignment GetResolvedTextAlignment()
{
var textAlignment = TextAlignment;
var flowDirection = FlowDirection;
if (textAlignment == TextAlignment.Start)
return flowDirection == FlowDirection.LeftToRight ? TextAlignment.Left : TextAlignment.Right;
else if (textAlignment == TextAlignment.End)
return flowDirection == FlowDirection.RightToLeft ? TextAlignment.Left : TextAlignment.Right;
else if (textAlignment == TextAlignment.DetectFromContent)
return TextAlignment.Left;
return textAlignment;
}
protected virtual void RenderTextLayout(DrawingContext context, Point origin)
{
TextLayout.Draw(context, origin);
var textLayout = TextLayout;
if (textLayout.TextLines.Count == 0)
{
return;
}
var padding = Padding;
var controlWidth = Bounds.Width - padding.Left - padding.Right;
var textAlignment = GetResolvedTextAlignment();
var currentY = origin.Y;
foreach (var line in textLayout.TextLines)
{
var offsetX = GetLineOffsetX(line, controlWidth, textAlignment);
line.Draw(context, new Point(origin.X + offsetX, currentY));
currentY += line.Height;
}
}
private bool _clearTextInternal;
@ -751,8 +804,18 @@ namespace Avalonia.Controls
//This implicitly recreated the TextLayout with a new constraint if we previously reset it.
var textLayout = TextLayout;
// The textWidth used here is matching that TextPresenter uses to measure the text.
return new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height).Inflate(padding);
var maxLeadingOverhang = 0.0;
var maxTrailingOverhang = 0.0;
foreach (var line in textLayout.TextLines)
{
maxLeadingOverhang = Math.Max(maxLeadingOverhang, -Math.Min(0, line.OverhangLeading));
maxTrailingOverhang = Math.Max(maxTrailingOverhang, -Math.Min(0, line.OverhangTrailing));
}
var totalWidth = textLayout.WidthIncludingTrailingWhitespace + maxLeadingOverhang + maxTrailingOverhang;
return new Size(totalWidth, textLayout.Height).Inflate(padding);
}
protected override Size ArrangeOverride(Size finalSize)

15
src/Skia/Avalonia.Skia/GlyphRunImpl.cs

@ -67,7 +67,7 @@ namespace Avalonia.Skia
using var font = CreateFont(defaultTextOptions);
var runBounds = new Rect();
Rect? runBounds = null;
var glyphBounds = ArrayPool<SKRect>.Shared.Rent(count);
font.GetGlyphWidths(_glyphIndices, null, glyphBounds.AsSpan(0, count));
@ -79,14 +79,23 @@ namespace Avalonia.Skia
var gBounds = glyphBounds[i];
var advance = glyphInfos[i].GlyphAdvance;
runBounds = runBounds.Union(new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height));
var glyphRect = new Rect(currentX + gBounds.Left, gBounds.Top, gBounds.Width, gBounds.Height);
if (runBounds == null)
{
runBounds = glyphRect;
}
else
{
runBounds = runBounds.Value.Union(glyphRect);
}
currentX += advance;
}
ArrayPool<SKRect>.Shared.Return(glyphBounds);
BaselineOrigin = baselineOrigin;
Bounds = runBounds.Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
Bounds = (runBounds ?? new Rect()).Translate(new Vector(baselineOrigin.X, baselineOrigin.Y));
}
public double FontRenderingEmSize { get; }

4
tests/Avalonia.RenderTests/Media/TextFormatting/TextLayoutTests.cs

@ -178,7 +178,7 @@ namespace Avalonia.Skia.RenderTests
[Theory]
[InlineData("x", 0, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 171.20, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 170.20, 0, 8.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 200 - 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_RightAlign_Correctly(
string input, int index, double widthConstraint,
@ -197,7 +197,7 @@ namespace Avalonia.Skia.RenderTests
[Theory]
[InlineData("x", 0, 200, 100 - 7.20 / 2, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 85.6, 0, 7.20, FontSizeHeight)]
[InlineData(stringword, 0, 200, 84.6, 0, 8.20, FontSizeHeight)]
[InlineData(stringword, 3, 200, 100 + 7.20, 0, 7.20, FontSizeHeight)]
public void Should_HitTestPosition_CenterAlign_Correctly(
string input, int index, double widthConstraint,

3
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

@ -696,7 +696,8 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
expectedOffset = 50 - textLine.Width / 2;
break;
case TextAlignment.Right:
expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace;
var overhangAdjustment = Math.Min(0, textLine.OverhangTrailing);
expectedOffset = 100 - textLine.WidthIncludingTrailingWhitespace + overhangAdjustment;
break;
}

BIN
tests/TestFiles/Skia/Controls/TextBlock/RestrictedHeight_VerticalAlign.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 517 B

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Account_For_Overhang_Leading_And_Trailing.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Draw_MultiLineText_WithOverHandLeadingTrailing.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Keep_TrailingWhiteSpace.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Measure_Arrange_TextBlock_44_NoWrap.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
tests/TestFiles/Skia/Controls/TextBlock/Should_Measure_Arrange_TextBlock_44_Wrap.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Blue_Foreground.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 966 B

BIN
tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Default_Foreground.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
tests/TestFiles/Skia/Controls/TextBox/Placeholder_With_Red_Foreground.expected.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 946 B

Loading…
Cancel
Save