63 changed files with 2614 additions and 1960 deletions
@ -0,0 +1,69 @@ |
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
public class GenericTextParagraphProperties : TextParagraphProperties |
|||
{ |
|||
private TextAlignment _textAlignment; |
|||
private TextWrapping _textWrapping; |
|||
private TextTrimming _textTrimming; |
|||
private double _lineHeight; |
|||
|
|||
public GenericTextParagraphProperties( |
|||
TextRunProperties defaultTextRunProperties, |
|||
TextAlignment textAlignment = TextAlignment.Left, |
|||
TextWrapping textWrapping = TextWrapping.WrapWithOverflow, |
|||
TextTrimming textTrimming = TextTrimming.None, |
|||
double lineHeight = 0) |
|||
{ |
|||
DefaultTextRunProperties = defaultTextRunProperties; |
|||
|
|||
_textAlignment = textAlignment; |
|||
|
|||
_textWrapping = textWrapping; |
|||
|
|||
_textTrimming = textTrimming; |
|||
|
|||
_lineHeight = lineHeight; |
|||
} |
|||
|
|||
public override TextRunProperties DefaultTextRunProperties { get; } |
|||
|
|||
public override TextAlignment TextAlignment => _textAlignment; |
|||
|
|||
public override TextWrapping TextWrapping => _textWrapping; |
|||
|
|||
public override TextTrimming TextTrimming => _textTrimming; |
|||
|
|||
public override double LineHeight => _lineHeight; |
|||
|
|||
/// <summary>
|
|||
/// Set text alignment
|
|||
/// </summary>
|
|||
internal void SetTextAlignment(TextAlignment textAlignment) |
|||
{ |
|||
_textAlignment = textAlignment; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Set text wrap
|
|||
/// </summary>
|
|||
internal void SetTextWrapping(TextWrapping textWrapping) |
|||
{ |
|||
_textWrapping = textWrapping; |
|||
} |
|||
/// <summary>
|
|||
/// Set text trimming
|
|||
/// </summary>
|
|||
internal void SetTextTrimming(TextTrimming textTrimming) |
|||
{ |
|||
_textTrimming = textTrimming; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Set line height
|
|||
/// </summary>
|
|||
internal void SetLineHeight(double lineHeight) |
|||
{ |
|||
_lineHeight = lineHeight; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
using System.Globalization; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Generic implementation of TextRunProperties
|
|||
/// </summary>
|
|||
public class GenericTextRunProperties : TextRunProperties |
|||
{ |
|||
public GenericTextRunProperties(Typeface typeface, double fontRenderingEmSize = 12, |
|||
TextDecorationCollection textDecorations = null, IBrush foregroundBrush = null, IBrush backgroundBrush = null, |
|||
CultureInfo cultureInfo = null) |
|||
{ |
|||
Typeface = typeface; |
|||
FontRenderingEmSize = fontRenderingEmSize; |
|||
TextDecorations = textDecorations; |
|||
ForegroundBrush = foregroundBrush; |
|||
BackgroundBrush = backgroundBrush; |
|||
CultureInfo = cultureInfo; |
|||
} |
|||
|
|||
/// <inheritdoc />
|
|||
public override Typeface Typeface { get; } |
|||
|
|||
/// <inheritdoc />
|
|||
public override double FontRenderingEmSize { get; } |
|||
|
|||
/// <inheritdoc />
|
|||
public override TextDecorationCollection TextDecorations { get; } |
|||
|
|||
/// <inheritdoc />
|
|||
public override IBrush ForegroundBrush { get; } |
|||
|
|||
/// <inheritdoc />
|
|||
public override IBrush BackgroundBrush { get; } |
|||
|
|||
/// <inheritdoc />
|
|||
public override CultureInfo CultureInfo { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// A group of characters that can be shaped.
|
|||
/// </summary>
|
|||
public sealed class ShapeableTextCharacters : TextRun |
|||
{ |
|||
public ShapeableTextCharacters(ReadOnlySlice<char> text, TextRunProperties properties) |
|||
{ |
|||
TextSourceLength = text.Length; |
|||
Text = text; |
|||
Properties = properties; |
|||
} |
|||
|
|||
public override int TextSourceLength { get; } |
|||
|
|||
public override ReadOnlySlice<char> Text { get; } |
|||
|
|||
public override TextRunProperties Properties { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// A text run that holds shaped characters.
|
|||
/// </summary>
|
|||
public sealed class ShapedTextCharacters : DrawableTextRun |
|||
{ |
|||
public ShapedTextCharacters(GlyphRun glyphRun, TextRunProperties properties) |
|||
{ |
|||
Text = glyphRun.Characters; |
|||
Properties = properties; |
|||
TextSourceLength = Text.Length; |
|||
FontMetrics = new FontMetrics(Properties.Typeface, Properties.FontRenderingEmSize); |
|||
GlyphRun = glyphRun; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override ReadOnlySlice<char> Text { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override TextRunProperties Properties { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override int TextSourceLength { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override Rect Bounds => GlyphRun.Bounds; |
|||
|
|||
/// <summary>
|
|||
/// Gets the font metrics.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The font metrics.
|
|||
/// </value>
|
|||
public FontMetrics FontMetrics { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the glyph run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The glyphs.
|
|||
/// </value>
|
|||
public GlyphRun GlyphRun { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override void Draw(DrawingContext drawingContext, Point origin) |
|||
{ |
|||
if (GlyphRun.GlyphIndices.Length == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (Properties.Typeface == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (Properties.ForegroundBrush == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (Properties.BackgroundBrush != null) |
|||
{ |
|||
drawingContext.DrawRectangle(Properties.BackgroundBrush, null, |
|||
new Rect(origin.X, origin.Y + FontMetrics.Ascent, Bounds.Width, Bounds.Height)); |
|||
} |
|||
|
|||
drawingContext.DrawGlyphRun(Properties.ForegroundBrush, GlyphRun, origin); |
|||
|
|||
if (Properties.TextDecorations == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
foreach (var textDecoration in Properties.TextDecorations) |
|||
{ |
|||
textDecoration.Draw(drawingContext, this, origin); |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Splits the <see cref="TextRun"/> at specified length.
|
|||
/// </summary>
|
|||
/// <param name="length">The length.</param>
|
|||
/// <returns>The split result.</returns>
|
|||
public SplitTextCharactersResult Split(int length) |
|||
{ |
|||
var glyphCount = 0; |
|||
|
|||
var firstCharacters = GlyphRun.Characters.Take(length); |
|||
|
|||
var codepointEnumerator = new CodepointEnumerator(firstCharacters); |
|||
|
|||
while (codepointEnumerator.MoveNext()) |
|||
{ |
|||
glyphCount++; |
|||
} |
|||
|
|||
if (GlyphRun.Characters.Length == length) |
|||
{ |
|||
return new SplitTextCharactersResult(this, null); |
|||
} |
|||
|
|||
if (GlyphRun.GlyphIndices.Length == glyphCount) |
|||
{ |
|||
return new SplitTextCharactersResult(this, null); |
|||
} |
|||
|
|||
var firstGlyphRun = new GlyphRun( |
|||
Properties.Typeface.GlyphTypeface, |
|||
Properties.FontRenderingEmSize, |
|||
GlyphRun.GlyphIndices.Take(glyphCount), |
|||
GlyphRun.GlyphAdvances.Take(glyphCount), |
|||
GlyphRun.GlyphOffsets.Take(glyphCount), |
|||
GlyphRun.Characters.Take(length), |
|||
GlyphRun.GlyphClusters.Take(glyphCount)); |
|||
|
|||
var firstTextRun = new ShapedTextCharacters(firstGlyphRun, Properties); |
|||
|
|||
var secondGlyphRun = new GlyphRun( |
|||
Properties.Typeface.GlyphTypeface, |
|||
Properties.FontRenderingEmSize, |
|||
GlyphRun.GlyphIndices.Skip(glyphCount), |
|||
GlyphRun.GlyphAdvances.Skip(glyphCount), |
|||
GlyphRun.GlyphOffsets.Skip(glyphCount), |
|||
GlyphRun.Characters.Skip(length), |
|||
GlyphRun.GlyphClusters.Skip(glyphCount)); |
|||
|
|||
var secondTextRun = new ShapedTextCharacters(secondGlyphRun, Properties); |
|||
|
|||
return new SplitTextCharactersResult(firstTextRun, secondTextRun); |
|||
} |
|||
|
|||
public readonly struct SplitTextCharactersResult |
|||
{ |
|||
public SplitTextCharactersResult(ShapedTextCharacters first, ShapedTextCharacters second) |
|||
{ |
|||
First = first; |
|||
|
|||
Second = second; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the first text run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The first text run.
|
|||
/// </value>
|
|||
public ShapedTextCharacters First { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the second text run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The second text run.
|
|||
/// </value>
|
|||
public ShapedTextCharacters Second { get; } |
|||
} |
|||
} |
|||
} |
|||
@ -1,212 +0,0 @@ |
|||
using Avalonia.Media.Immutable; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Utility; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// A text run that holds a shaped glyph run.
|
|||
/// </summary>
|
|||
public sealed class ShapedTextRun : DrawableTextRun |
|||
{ |
|||
public ShapedTextRun(ReadOnlySlice<char> text, TextStyle style) : this( |
|||
TextShaper.Current.ShapeText(text, style.TextFormat), style) |
|||
{ |
|||
} |
|||
|
|||
public ShapedTextRun(GlyphRun glyphRun, TextStyle style) |
|||
{ |
|||
Text = glyphRun.Characters; |
|||
Style = style; |
|||
GlyphRun = glyphRun; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override Rect Bounds => GlyphRun.Bounds; |
|||
|
|||
/// <summary>
|
|||
/// Gets the glyph run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The glyphs.
|
|||
/// </value>
|
|||
public GlyphRun GlyphRun { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override void Draw(IDrawingContextImpl drawingContext, Point origin) |
|||
{ |
|||
if (GlyphRun.GlyphIndices.Length == 0) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (Style.TextFormat.Typeface == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (Style.Foreground == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
drawingContext.DrawGlyphRun(Style.Foreground, GlyphRun, origin); |
|||
|
|||
if (Style.TextDecorations == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
foreach (var textDecoration in Style.TextDecorations) |
|||
{ |
|||
DrawTextDecoration(drawingContext, textDecoration, origin); |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Draws the <see cref="TextDecoration"/> at given origin.
|
|||
/// </summary>
|
|||
/// <param name="drawingContext">The drawing context.</param>
|
|||
/// <param name="textDecoration">The text decoration.</param>
|
|||
/// <param name="origin">The origin.</param>
|
|||
private void DrawTextDecoration(IDrawingContextImpl drawingContext, ImmutableTextDecoration textDecoration, Point origin) |
|||
{ |
|||
var textFormat = Style.TextFormat; |
|||
|
|||
var fontMetrics = Style.TextFormat.FontMetrics; |
|||
|
|||
var thickness = textDecoration.Pen?.Thickness ?? 1.0; |
|||
|
|||
switch (textDecoration.PenThicknessUnit) |
|||
{ |
|||
case TextDecorationUnit.FontRecommended: |
|||
switch (textDecoration.Location) |
|||
{ |
|||
case TextDecorationLocation.Underline: |
|||
thickness = fontMetrics.UnderlineThickness; |
|||
break; |
|||
case TextDecorationLocation.Strikethrough: |
|||
thickness = fontMetrics.StrikethroughThickness; |
|||
break; |
|||
} |
|||
break; |
|||
case TextDecorationUnit.FontRenderingEmSize: |
|||
thickness = textFormat.FontRenderingEmSize * thickness; |
|||
break; |
|||
} |
|||
|
|||
switch (textDecoration.Location) |
|||
{ |
|||
case TextDecorationLocation.Overline: |
|||
origin += new Point(0, textFormat.FontMetrics.Ascent); |
|||
break; |
|||
case TextDecorationLocation.Strikethrough: |
|||
origin += new Point(0, -textFormat.FontMetrics.StrikethroughPosition); |
|||
break; |
|||
case TextDecorationLocation.Underline: |
|||
origin += new Point(0, -textFormat.FontMetrics.UnderlinePosition); |
|||
break; |
|||
} |
|||
|
|||
switch (textDecoration.PenOffsetUnit) |
|||
{ |
|||
case TextDecorationUnit.FontRenderingEmSize: |
|||
origin += new Point(0, textDecoration.PenOffset * textFormat.FontRenderingEmSize); |
|||
break; |
|||
case TextDecorationUnit.Pixel: |
|||
origin += new Point(0, textDecoration.PenOffset); |
|||
break; |
|||
} |
|||
|
|||
var pen = new ImmutablePen( |
|||
textDecoration.Pen?.Brush ?? Style.Foreground.ToImmutable(), |
|||
thickness, |
|||
textDecoration.Pen?.DashStyle?.ToImmutable(), |
|||
textDecoration.Pen?.LineCap ?? default, |
|||
textDecoration.Pen?.LineJoin ?? PenLineJoin.Miter, |
|||
textDecoration.Pen?.MiterLimit ?? 10.0); |
|||
|
|||
drawingContext.DrawLine(pen, origin, origin + new Point(GlyphRun.Bounds.Width, 0)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Splits the <see cref="TextRun"/> at specified length.
|
|||
/// </summary>
|
|||
/// <param name="length">The length.</param>
|
|||
/// <returns>The split result.</returns>
|
|||
public SplitTextCharactersResult Split(int length) |
|||
{ |
|||
var glyphCount = 0; |
|||
|
|||
var firstCharacters = GlyphRun.Characters.Take(length); |
|||
|
|||
var codepointEnumerator = new CodepointEnumerator(firstCharacters); |
|||
|
|||
while (codepointEnumerator.MoveNext()) |
|||
{ |
|||
glyphCount++; |
|||
} |
|||
|
|||
if (GlyphRun.Characters.Length == length) |
|||
{ |
|||
return new SplitTextCharactersResult(this, null); |
|||
} |
|||
|
|||
if (GlyphRun.GlyphIndices.Length == glyphCount) |
|||
{ |
|||
return new SplitTextCharactersResult(this, null); |
|||
} |
|||
|
|||
var firstGlyphRun = new GlyphRun( |
|||
Style.TextFormat.Typeface.GlyphTypeface, |
|||
Style.TextFormat.FontRenderingEmSize, |
|||
GlyphRun.GlyphIndices.Take(glyphCount), |
|||
GlyphRun.GlyphAdvances.Take(glyphCount), |
|||
GlyphRun.GlyphOffsets.Take(glyphCount), |
|||
GlyphRun.Characters.Take(length), |
|||
GlyphRun.GlyphClusters.Take(length)); |
|||
|
|||
var firstTextRun = new ShapedTextRun(firstGlyphRun, Style); |
|||
|
|||
var secondGlyphRun = new GlyphRun( |
|||
Style.TextFormat.Typeface.GlyphTypeface, |
|||
Style.TextFormat.FontRenderingEmSize, |
|||
GlyphRun.GlyphIndices.Skip(glyphCount), |
|||
GlyphRun.GlyphAdvances.Skip(glyphCount), |
|||
GlyphRun.GlyphOffsets.Skip(glyphCount), |
|||
GlyphRun.Characters.Skip(length), |
|||
GlyphRun.GlyphClusters.Skip(length)); |
|||
|
|||
var secondTextRun = new ShapedTextRun(secondGlyphRun, Style); |
|||
|
|||
return new SplitTextCharactersResult(firstTextRun, secondTextRun); |
|||
} |
|||
|
|||
public readonly struct SplitTextCharactersResult |
|||
{ |
|||
public SplitTextCharactersResult(ShapedTextRun first, ShapedTextRun second) |
|||
{ |
|||
First = first; |
|||
|
|||
Second = second; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the first text run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The first text run.
|
|||
/// </value>
|
|||
public ShapedTextRun First { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the second text run.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The second text run.
|
|||
/// </value>
|
|||
public ShapedTextRun Second { get; } |
|||
} |
|||
} |
|||
} |
|||
@ -1,395 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Utility; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
internal class SimpleTextFormatter : TextFormatter |
|||
{ |
|||
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' }); |
|||
|
|||
/// <inheritdoc cref="TextFormatter.FormatLine"/>
|
|||
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, |
|||
TextParagraphProperties paragraphProperties) |
|||
{ |
|||
var textTrimming = paragraphProperties.TextTrimming; |
|||
var textWrapping = paragraphProperties.TextWrapping; |
|||
TextLine textLine; |
|||
|
|||
var textRuns = FormatTextRuns(textSource, firstTextSourceIndex, out var textPointer); |
|||
|
|||
if (textTrimming != TextTrimming.None) |
|||
{ |
|||
textLine = PerformTextTrimming(textPointer, textRuns, paragraphWidth, paragraphProperties); |
|||
} |
|||
else |
|||
{ |
|||
if (textWrapping == TextWrapping.Wrap) |
|||
{ |
|||
textLine = PerformTextWrapping(textPointer, textRuns, paragraphWidth, paragraphProperties); |
|||
} |
|||
else |
|||
{ |
|||
var textLineMetrics = |
|||
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment); |
|||
|
|||
textLine = new SimpleTextLine(textPointer, textRuns, textLineMetrics); |
|||
} |
|||
} |
|||
|
|||
return textLine; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Formats text runs with optional text style overrides.
|
|||
/// </summary>
|
|||
/// <param name="textSource">The text source.</param>
|
|||
/// <param name="firstTextSourceIndex">The first text source index.</param>
|
|||
/// <param name="textPointer">The text pointer that covers the formatted text runs.</param>
|
|||
/// <returns>
|
|||
/// The formatted text runs.
|
|||
/// </returns>
|
|||
private List<ShapedTextRun> FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer) |
|||
{ |
|||
var start = -1; |
|||
var length = 0; |
|||
|
|||
var textRuns = new List<ShapedTextRun>(); |
|||
|
|||
while (true) |
|||
{ |
|||
var textRun = textSource.GetTextRun(firstTextSourceIndex + length); |
|||
|
|||
if (start == -1) |
|||
{ |
|||
start = textRun.Text.Start; |
|||
} |
|||
|
|||
if (textRun is TextEndOfLine) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
switch (textRun) |
|||
{ |
|||
case TextCharacters textCharacters: |
|||
|
|||
var runText = textCharacters.Text; |
|||
|
|||
while (!runText.IsEmpty) |
|||
{ |
|||
var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style); |
|||
|
|||
var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length), |
|||
shapableTextStyleRun.Style); |
|||
|
|||
textRuns.Add(shapedRun); |
|||
|
|||
runText = runText.Skip(shapedRun.Text.Length); |
|||
} |
|||
|
|||
break; |
|||
default: |
|||
throw new NotSupportedException("Run type not supported by the formatter."); |
|||
} |
|||
|
|||
length += textRun.Text.Length; |
|||
} |
|||
|
|||
textPointer = new TextPointer(start, length); |
|||
|
|||
return textRuns; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Performs text trimming and returns a trimmed line.
|
|||
/// </summary>
|
|||
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
|
|||
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
|
|||
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
|
|||
/// <param name="textRuns">The text runs to perform the trimming on.</param>
|
|||
/// <param name="text">The text that was used to construct the text runs.</param>
|
|||
/// <returns></returns>
|
|||
private static TextLine PerformTextTrimming(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns, |
|||
double paragraphWidth, TextParagraphProperties paragraphProperties) |
|||
{ |
|||
var textTrimming = paragraphProperties.TextTrimming; |
|||
var availableWidth = paragraphWidth; |
|||
var currentWidth = 0.0; |
|||
var runIndex = 0; |
|||
|
|||
while (runIndex < textRuns.Count) |
|||
{ |
|||
var currentRun = textRuns[runIndex]; |
|||
|
|||
currentWidth += currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
if (currentWidth > availableWidth) |
|||
{ |
|||
var ellipsisRun = CreateEllipsisRun(currentRun.Style); |
|||
|
|||
var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width); |
|||
|
|||
if (textTrimming == TextTrimming.WordEllipsis) |
|||
{ |
|||
if (measuredLength < text.End) |
|||
{ |
|||
var currentBreakPosition = 0; |
|||
|
|||
var lineBreaker = new LineBreakEnumerator(currentRun.Text); |
|||
|
|||
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) |
|||
{ |
|||
var nextBreakPosition = lineBreaker.Current.PositionWrap; |
|||
|
|||
if (nextBreakPosition == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
if (nextBreakPosition > measuredLength) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
currentBreakPosition = nextBreakPosition; |
|||
} |
|||
|
|||
measuredLength = currentBreakPosition; |
|||
} |
|||
} |
|||
|
|||
var splitResult = SplitTextRuns(textRuns, measuredLength); |
|||
|
|||
var trimmedRuns = new List<ShapedTextRun>(splitResult.First.Count + 1); |
|||
|
|||
trimmedRuns.AddRange(splitResult.First); |
|||
|
|||
trimmedRuns.Add(ellipsisRun); |
|||
|
|||
var textLineMetrics = |
|||
TextLineMetrics.Create(trimmedRuns, paragraphWidth, paragraphProperties.TextAlignment); |
|||
|
|||
return new SimpleTextLine(text.Take(measuredLength), trimmedRuns, textLineMetrics); |
|||
} |
|||
|
|||
availableWidth -= currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return new SimpleTextLine(text, textRuns, |
|||
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Performs text wrapping returns a list of text lines.
|
|||
/// </summary>
|
|||
/// <param name="paragraphProperties">The text paragraph properties.</param>
|
|||
/// <param name="textRuns">The text run'S.</param>
|
|||
/// <param name="text">The text to analyze for break opportunities.</param>
|
|||
/// <param name="paragraphWidth"></param>
|
|||
/// <returns></returns>
|
|||
private static TextLine PerformTextWrapping(TextPointer text, IReadOnlyList<ShapedTextRun> textRuns, |
|||
double paragraphWidth, TextParagraphProperties paragraphProperties) |
|||
{ |
|||
var availableWidth = paragraphWidth; |
|||
var currentWidth = 0.0; |
|||
var runIndex = 0; |
|||
var length = 0; |
|||
|
|||
while (runIndex < textRuns.Count) |
|||
{ |
|||
var currentRun = textRuns[runIndex]; |
|||
|
|||
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth) |
|||
{ |
|||
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth); |
|||
|
|||
if (measuredLength < currentRun.Text.Length) |
|||
{ |
|||
var currentBreakPosition = -1; |
|||
|
|||
var lineBreaker = new LineBreakEnumerator(currentRun.Text); |
|||
|
|||
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) |
|||
{ |
|||
var nextBreakPosition = lineBreaker.Current.PositionWrap; |
|||
|
|||
if (nextBreakPosition == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
if (nextBreakPosition > measuredLength) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
currentBreakPosition = nextBreakPosition; |
|||
} |
|||
|
|||
if (currentBreakPosition != -1) |
|||
{ |
|||
measuredLength = currentBreakPosition; |
|||
} |
|||
} |
|||
|
|||
length += measuredLength; |
|||
|
|||
var splitResult = SplitTextRuns(textRuns, length); |
|||
|
|||
var textLineMetrics = |
|||
TextLineMetrics.Create(splitResult.First, paragraphWidth, paragraphProperties.TextAlignment); |
|||
|
|||
return new SimpleTextLine(text.Take(length), splitResult.First, textLineMetrics); |
|||
} |
|||
|
|||
currentWidth += currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
length += currentRun.GlyphRun.Characters.Length; |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return new SimpleTextLine(text, textRuns, |
|||
TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Measures the number of characters that fits into available width.
|
|||
/// </summary>
|
|||
/// <param name="textRun">The text run.</param>
|
|||
/// <param name="availableWidth">The available width.</param>
|
|||
/// <returns></returns>
|
|||
private static int MeasureText(ShapedTextRun textRun, double availableWidth) |
|||
{ |
|||
var glyphRun = textRun.GlyphRun; |
|||
|
|||
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _); |
|||
|
|||
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textRun.Text.Start; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates an ellipsis.
|
|||
/// </summary>
|
|||
/// <param name="textStyle">The text style.</param>
|
|||
/// <returns></returns>
|
|||
private static ShapedTextRun CreateEllipsisRun(TextStyle textStyle) |
|||
{ |
|||
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>(); |
|||
|
|||
var glyphRun = formatterImpl.ShapeText(s_ellipsis, textStyle.TextFormat); |
|||
|
|||
return new ShapedTextRun(glyphRun, textStyle); |
|||
} |
|||
|
|||
private readonly struct SplitTextRunsResult |
|||
{ |
|||
public SplitTextRunsResult(IReadOnlyList<ShapedTextRun> first, IReadOnlyList<ShapedTextRun> second) |
|||
{ |
|||
First = first; |
|||
|
|||
Second = second; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the first text runs.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The first text runs.
|
|||
/// </value>
|
|||
public IReadOnlyList<ShapedTextRun> First { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the second text runs.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The second text runs.
|
|||
/// </value>
|
|||
public IReadOnlyList<ShapedTextRun> Second { get; } |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Split a sequence of runs into two segments at specified length.
|
|||
/// </summary>
|
|||
/// <param name="textRuns">The text run's.</param>
|
|||
/// <param name="length">The length to split at.</param>
|
|||
/// <returns></returns>
|
|||
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextRun> textRuns, int length) |
|||
{ |
|||
var currentLength = 0; |
|||
|
|||
for (var i = 0; i < textRuns.Count; i++) |
|||
{ |
|||
var currentRun = textRuns[i]; |
|||
|
|||
if (currentLength + currentRun.GlyphRun.Characters.Length < length) |
|||
{ |
|||
currentLength += currentRun.GlyphRun.Characters.Length; |
|||
continue; |
|||
} |
|||
|
|||
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i; |
|||
|
|||
var first = new ShapedTextRun[firstCount]; |
|||
|
|||
if (firstCount > 1) |
|||
{ |
|||
for (var j = 0; j < i; j++) |
|||
{ |
|||
first[j] = textRuns[j]; |
|||
} |
|||
} |
|||
|
|||
var secondCount = textRuns.Count - firstCount; |
|||
|
|||
if (currentLength + currentRun.GlyphRun.Characters.Length == length) |
|||
{ |
|||
var second = new ShapedTextRun[secondCount]; |
|||
|
|||
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; |
|||
|
|||
if (secondCount > 0) |
|||
{ |
|||
for (var j = 0; j < secondCount; j++) |
|||
{ |
|||
second[j] = textRuns[i + j + offset]; |
|||
} |
|||
} |
|||
|
|||
first[i] = currentRun; |
|||
|
|||
return new SplitTextRunsResult(first, second); |
|||
} |
|||
else |
|||
{ |
|||
secondCount++; |
|||
|
|||
var second = new ShapedTextRun[secondCount]; |
|||
|
|||
if (secondCount > 0) |
|||
{ |
|||
for (var j = 1; j < secondCount; j++) |
|||
{ |
|||
second[j] = textRuns[i + j]; |
|||
} |
|||
} |
|||
|
|||
var split = currentRun.Split(length - currentLength); |
|||
|
|||
first[i] = split.First; |
|||
|
|||
second[0] = split.Second; |
|||
|
|||
return new SplitTextRunsResult(first, second); |
|||
} |
|||
} |
|||
|
|||
return new SplitTextRunsResult(textRuns, null); |
|||
} |
|||
} |
|||
} |
|||
@ -1,259 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Platform; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
internal class SimpleTextLine : TextLine |
|||
{ |
|||
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) |
|||
{ |
|||
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X, |
|||
origin.Y + LineMetrics.BaselineOrigin.Y); |
|||
|
|||
textRun.Draw(drawingContext, baselineOrigin); |
|||
|
|||
currentX += textRun.Bounds.Width; |
|||
} |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetCharacterHitFromDistance(double distance) |
|||
{ |
|||
if (distance < 0) |
|||
{ |
|||
// hit happens before the line, return the first position
|
|||
return new CharacterHit(Text.Start); |
|||
} |
|||
|
|||
// process hit that happens within the line
|
|||
var characterHit = new CharacterHit(); |
|||
|
|||
foreach (var run in _textRuns) |
|||
{ |
|||
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _); |
|||
|
|||
if (distance <= run.Bounds.Width) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
distance -= run.Bounds.Width; |
|||
} |
|||
|
|||
return characterHit; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override double GetDistanceFromCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
int nextVisibleCp; |
|||
bool navigableCpFound; |
|||
|
|||
if (characterHit.TrailingLength == 0) |
|||
{ |
|||
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp); |
|||
|
|||
if (navigableCpFound) |
|||
{ |
|||
// Move from leading to trailing edge
|
|||
return new CharacterHit(nextVisibleCp, 1); |
|||
} |
|||
} |
|||
|
|||
navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp); |
|||
|
|||
if (navigableCpFound) |
|||
{ |
|||
// Move from trailing edge of current character to trailing edge of next
|
|||
return new CharacterHit(nextVisibleCp, 1); |
|||
} |
|||
|
|||
// Can't move, we're after the last character
|
|||
return characterHit; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
int previousCodepointIndex; |
|||
bool codepointIndexFound; |
|||
|
|||
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) |
|||
{ |
|||
cpHit = Text.Start + Text.Length - 1; |
|||
|
|||
trailingHit = true; |
|||
} |
|||
|
|||
if (trailingHit) |
|||
{ |
|||
codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex); |
|||
|
|||
if (codepointIndexFound) |
|||
{ |
|||
// Move from trailing to leading edge
|
|||
return new CharacterHit(previousCodepointIndex, 0); |
|||
} |
|||
} |
|||
|
|||
codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex); |
|||
|
|||
if (codepointIndexFound) |
|||
{ |
|||
// Move from leading edge of current character to leading edge of previous
|
|||
return new CharacterHit(previousCodepointIndex, 0); |
|||
} |
|||
|
|||
// Can't move, we're before the first character
|
|||
return characterHit; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
// same operation as move-to-previous
|
|||
return GetPreviousCaretCharacterHit(characterHit); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get distance from line start to the specified codepoint index
|
|||
/// </summary>
|
|||
private double DistanceFromCodepointIndex(int codepointIndex) |
|||
{ |
|||
var currentDistance = 0.0; |
|||
|
|||
foreach (var textRun in _textRuns) |
|||
{ |
|||
if (codepointIndex > textRun.Text.End) |
|||
{ |
|||
currentDistance += textRun.Bounds.Width; |
|||
|
|||
continue; |
|||
} |
|||
|
|||
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex)); |
|||
} |
|||
|
|||
return currentDistance; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// 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 FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex) |
|||
{ |
|||
nextCodepointIndex = codepointIndex; |
|||
|
|||
if (codepointIndex >= Text.Start + Text.Length) |
|||
{ |
|||
return false; // Cannot go forward anymore
|
|||
} |
|||
|
|||
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart); |
|||
|
|||
while (runIndex < TextRuns.Count) |
|||
{ |
|||
// When navigating forward, only the trailing edge of visible content is
|
|||
// navigable.
|
|||
if (runIndex < TextRuns.Count) |
|||
{ |
|||
nextCodepointIndex = Math.Max(cpRunStart, codepointIndex); |
|||
return true; |
|||
} |
|||
|
|||
cpRunStart += TextRuns[runIndex++].Text.Length; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// 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 FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex) |
|||
{ |
|||
previousCodepointIndex = codepointIndex; |
|||
|
|||
if (codepointIndex < Text.Start) |
|||
{ |
|||
return false; // Cannot go backward anymore.
|
|||
} |
|||
|
|||
// Position the cpRunEnd at the end of the span that contains the given cp
|
|||
GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd); |
|||
|
|||
codepointIndexAtRunEnd += TextRuns[runIndex].Text.End; |
|||
|
|||
while (runIndex >= 0) |
|||
{ |
|||
// Visible content has caret stops at its leading edge.
|
|||
if (runIndex + 1 < TextRuns.Count) |
|||
{ |
|||
previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex); |
|||
return true; |
|||
} |
|||
|
|||
// Newline sequence has caret stops at its leading edge.
|
|||
if (runIndex == TextRuns.Count) |
|||
{ |
|||
// Get the cp index at the beginning of the newline sequence.
|
|||
previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1; |
|||
return true; |
|||
} |
|||
|
|||
codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart) |
|||
{ |
|||
codepointIndexAtRunStart = Text.Start; |
|||
runIndex = 0; |
|||
|
|||
// Find the span that contains the given cp
|
|||
while (runIndex < TextRuns.Count && |
|||
codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex) |
|||
{ |
|||
codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Specialized text run used to mark the end of a segment, i.e., to end
|
|||
/// the scope affected by a preceding TextModifier run.
|
|||
/// </summary>
|
|||
public class TextEndOfSegment : TextRun |
|||
{ |
|||
public TextEndOfSegment(int textSourceLength) |
|||
{ |
|||
TextSourceLength = textSourceLength; |
|||
} |
|||
|
|||
/// <inheritdoc />
|
|||
public override int TextSourceLength { get; } |
|||
} |
|||
} |
|||
@ -1,71 +0,0 @@ |
|||
using System; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Unique text formatting properties that are used by the <see cref="TextFormatter"/>.
|
|||
/// </summary>
|
|||
public readonly struct TextFormat : IEquatable<TextFormat> |
|||
{ |
|||
public TextFormat(Typeface typeface, double fontRenderingEmSize) |
|||
{ |
|||
Typeface = typeface; |
|||
FontRenderingEmSize = fontRenderingEmSize; |
|||
FontMetrics = new FontMetrics(typeface, fontRenderingEmSize); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the typeface.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The typeface.
|
|||
/// </value>
|
|||
public Typeface Typeface { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the font rendering em size.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The em rendering size of the font.
|
|||
/// </value>
|
|||
public double FontRenderingEmSize { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the font metrics.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The metrics of the font.
|
|||
/// </value>
|
|||
public FontMetrics FontMetrics { get; } |
|||
|
|||
public static bool operator ==(TextFormat self, TextFormat other) |
|||
{ |
|||
return self.Equals(other); |
|||
} |
|||
|
|||
public static bool operator !=(TextFormat self, TextFormat other) |
|||
{ |
|||
return !(self == other); |
|||
} |
|||
|
|||
public bool Equals(TextFormat other) |
|||
{ |
|||
return Typeface.Equals(other.Typeface) && FontRenderingEmSize.Equals(other.FontRenderingEmSize); |
|||
} |
|||
|
|||
public override bool Equals(object obj) |
|||
{ |
|||
return obj is TextFormat other && Equals(other); |
|||
} |
|||
|
|||
public override int GetHashCode() |
|||
{ |
|||
unchecked |
|||
{ |
|||
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0); |
|||
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode(); |
|||
return hashCode; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,544 @@ |
|||
using System.Collections.Generic; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.Platform; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
internal class TextFormatterImpl : TextFormatter |
|||
{ |
|||
private static readonly ReadOnlySlice<char> s_ellipsis = new ReadOnlySlice<char>(new[] { '\u2026' }); |
|||
|
|||
/// <inheritdoc cref="TextFormatter.FormatLine"/>
|
|||
public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, |
|||
TextParagraphProperties paragraphProperties, TextLineBreak previousLineBreak = null) |
|||
{ |
|||
var textTrimming = paragraphProperties.TextTrimming; |
|||
var textWrapping = paragraphProperties.TextWrapping; |
|||
TextLine textLine = null; |
|||
|
|||
var textRuns = FetchTextRuns(textSource, firstTextSourceIndex, previousLineBreak, out var nextLineBreak); |
|||
|
|||
var textRange = GetTextRange(textRuns); |
|||
|
|||
if (textTrimming != TextTrimming.None) |
|||
{ |
|||
textLine = PerformTextTrimming(textRuns, textRange, paragraphWidth, paragraphProperties); |
|||
} |
|||
else |
|||
{ |
|||
switch (textWrapping) |
|||
{ |
|||
case TextWrapping.NoWrap: |
|||
{ |
|||
var textLineMetrics = |
|||
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties); |
|||
|
|||
textLine = new TextLineImpl(textRuns, textLineMetrics, nextLineBreak); |
|||
break; |
|||
} |
|||
case TextWrapping.WrapWithOverflow: |
|||
case TextWrapping.Wrap: |
|||
{ |
|||
textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return textLine; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Fetches text runs.
|
|||
/// </summary>
|
|||
/// <param name="textSource">The text source.</param>
|
|||
/// <param name="firstTextSourceIndex">The first text source index.</param>
|
|||
/// <param name="previousLineBreak">Previous line break. Can be null.</param>
|
|||
/// <param name="nextLineBreak">Next line break. Can be null.</param>
|
|||
/// <returns>
|
|||
/// The formatted text runs.
|
|||
/// </returns>
|
|||
private static IReadOnlyList<ShapedTextCharacters> FetchTextRuns(ITextSource textSource, |
|||
int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak) |
|||
{ |
|||
nextLineBreak = default; |
|||
|
|||
var currentLength = 0; |
|||
|
|||
var textRuns = new List<ShapedTextCharacters>(); |
|||
|
|||
if (previousLineBreak != null) |
|||
{ |
|||
foreach (var shapedCharacters in previousLineBreak.RemainingCharacters) |
|||
{ |
|||
textRuns.Add(shapedCharacters); |
|||
|
|||
if (TryGetLineBreak(shapedCharacters, out var runLineBreak)) |
|||
{ |
|||
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); |
|||
|
|||
nextLineBreak = new TextLineBreak(splitResult.Second); |
|||
|
|||
return splitResult.First; |
|||
} |
|||
|
|||
currentLength += shapedCharacters.Text.Length; |
|||
} |
|||
} |
|||
|
|||
firstTextSourceIndex += currentLength; |
|||
|
|||
var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex); |
|||
|
|||
while (textRunEnumerator.MoveNext()) |
|||
{ |
|||
var textRun = textRunEnumerator.Current; |
|||
|
|||
switch (textRun) |
|||
{ |
|||
case TextCharacters textCharacters: |
|||
{ |
|||
var shapeableRuns = textCharacters.GetShapeableCharacters(); |
|||
|
|||
foreach (var run in shapeableRuns) |
|||
{ |
|||
var glyphRun = TextShaper.Current.ShapeText(run.Text, run.Properties.Typeface, |
|||
run.Properties.FontRenderingEmSize, run.Properties.CultureInfo); |
|||
|
|||
var shapedCharacters = new ShapedTextCharacters(glyphRun, textRun.Properties); |
|||
|
|||
textRuns.Add(shapedCharacters); |
|||
} |
|||
|
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (TryGetLineBreak(textRun, out var runLineBreak)) |
|||
{ |
|||
var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); |
|||
|
|||
nextLineBreak = new TextLineBreak(splitResult.Second); |
|||
|
|||
return splitResult.First; |
|||
} |
|||
|
|||
currentLength += textRun.Text.Length; |
|||
} |
|||
|
|||
return textRuns; |
|||
} |
|||
|
|||
private static bool TryGetLineBreak(TextRun textRun, out LineBreak lineBreak) |
|||
{ |
|||
lineBreak = default; |
|||
|
|||
if (textRun.Text.IsEmpty) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var lineBreakEnumerator = new LineBreakEnumerator(textRun.Text); |
|||
|
|||
while (lineBreakEnumerator.MoveNext()) |
|||
{ |
|||
if (!lineBreakEnumerator.Current.Required) |
|||
{ |
|||
continue; |
|||
} |
|||
|
|||
lineBreak = lineBreakEnumerator.Current; |
|||
|
|||
if (lineBreak.PositionWrap >= textRun.Text.Length) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
//The line breaker isn't treating \n\r as a pair so we have to fix that here.
|
|||
if (textRun.Text[lineBreak.PositionMeasure] == '\n' |
|||
&& textRun.Text[lineBreak.PositionWrap] == '\r') |
|||
{ |
|||
lineBreak = new LineBreak(lineBreak.PositionMeasure, lineBreak.PositionWrap + 1, |
|||
lineBreak.Required); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Performs text trimming and returns a trimmed line.
|
|||
/// </summary>
|
|||
/// <param name="textRuns">The text runs to perform the trimming on.</param>
|
|||
/// <param name="textRange">The text range that is covered by the text runs.</param>
|
|||
/// <param name="paragraphWidth">A <see cref="double"/> value that specifies the width of the paragraph that the line fills.</param>
|
|||
/// <param name="paragraphProperties">A <see cref="TextParagraphProperties"/> value that represents paragraph properties,
|
|||
/// such as TextWrapping, TextAlignment, or TextStyle.</param>
|
|||
/// <returns></returns>
|
|||
private static TextLine PerformTextTrimming(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange, |
|||
double paragraphWidth, TextParagraphProperties paragraphProperties) |
|||
{ |
|||
var textTrimming = paragraphProperties.TextTrimming; |
|||
var availableWidth = paragraphWidth; |
|||
var currentWidth = 0.0; |
|||
var runIndex = 0; |
|||
|
|||
while (runIndex < textRuns.Count) |
|||
{ |
|||
var currentRun = textRuns[runIndex]; |
|||
|
|||
currentWidth += currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
if (currentWidth > availableWidth) |
|||
{ |
|||
var ellipsisRun = CreateEllipsisRun(currentRun.Properties); |
|||
|
|||
var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width); |
|||
|
|||
if (textTrimming == TextTrimming.WordEllipsis) |
|||
{ |
|||
if (measuredLength < textRange.End) |
|||
{ |
|||
var currentBreakPosition = 0; |
|||
|
|||
var lineBreaker = new LineBreakEnumerator(currentRun.Text); |
|||
|
|||
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) |
|||
{ |
|||
var nextBreakPosition = lineBreaker.Current.PositionWrap; |
|||
|
|||
if (nextBreakPosition == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
if (nextBreakPosition > measuredLength) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
currentBreakPosition = nextBreakPosition; |
|||
} |
|||
|
|||
measuredLength = currentBreakPosition; |
|||
} |
|||
} |
|||
|
|||
var splitResult = SplitTextRuns(textRuns, measuredLength); |
|||
|
|||
var trimmedRuns = new List<ShapedTextCharacters>(splitResult.First.Count + 1); |
|||
|
|||
trimmedRuns.AddRange(splitResult.First); |
|||
|
|||
trimmedRuns.Add(ellipsisRun); |
|||
|
|||
var textLineMetrics = |
|||
TextLineMetrics.Create(trimmedRuns, textRange, paragraphWidth, paragraphProperties); |
|||
|
|||
return new TextLineImpl(trimmedRuns, textLineMetrics); |
|||
} |
|||
|
|||
availableWidth -= currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return new TextLineImpl(textRuns, |
|||
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Performs text wrapping returns a list of text lines.
|
|||
/// </summary>
|
|||
/// <param name="textRuns">The text run's.</param>
|
|||
/// <param name="textRange">The text range that is covered by the text runs.</param>
|
|||
/// <param name="paragraphWidth">The paragraph width.</param>
|
|||
/// <param name="paragraphProperties">The text paragraph properties.</param>
|
|||
/// <returns>The wrapped text line.</returns>
|
|||
private static TextLine PerformTextWrapping(IReadOnlyList<ShapedTextCharacters> textRuns, TextRange textRange, |
|||
double paragraphWidth, TextParagraphProperties paragraphProperties) |
|||
{ |
|||
var availableWidth = paragraphWidth; |
|||
var currentWidth = 0.0; |
|||
var runIndex = 0; |
|||
var length = 0; |
|||
|
|||
while (runIndex < textRuns.Count) |
|||
{ |
|||
var currentRun = textRuns[runIndex]; |
|||
|
|||
if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth) |
|||
{ |
|||
var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth); |
|||
|
|||
if (measuredLength < currentRun.Text.Length) |
|||
{ |
|||
if (paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) |
|||
{ |
|||
var lineBreaker = new LineBreakEnumerator(currentRun.Text.Skip(measuredLength)); |
|||
|
|||
if (lineBreaker.MoveNext()) |
|||
{ |
|||
measuredLength += lineBreaker.Current.PositionWrap; |
|||
} |
|||
else |
|||
{ |
|||
measuredLength = currentRun.Text.Length; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
var currentBreakPosition = -1; |
|||
|
|||
var lineBreaker = new LineBreakEnumerator(currentRun.Text); |
|||
|
|||
while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) |
|||
{ |
|||
var nextBreakPosition = lineBreaker.Current.PositionWrap; |
|||
|
|||
if (nextBreakPosition == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
if (nextBreakPosition > measuredLength) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
currentBreakPosition = nextBreakPosition; |
|||
} |
|||
|
|||
if (currentBreakPosition != -1) |
|||
{ |
|||
measuredLength = currentBreakPosition; |
|||
} |
|||
|
|||
} |
|||
} |
|||
|
|||
length += measuredLength; |
|||
|
|||
var splitResult = SplitTextRuns(textRuns, length); |
|||
|
|||
var textLineMetrics = TextLineMetrics.Create(splitResult.First, |
|||
new TextRange(textRange.Start, length), paragraphWidth, paragraphProperties); |
|||
|
|||
var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ? |
|||
new TextLineBreak(splitResult.Second) : |
|||
null; |
|||
|
|||
return new TextLineImpl(splitResult.First, textLineMetrics, lineBreak); |
|||
} |
|||
|
|||
currentWidth += currentRun.GlyphRun.Bounds.Width; |
|||
|
|||
length += currentRun.GlyphRun.Characters.Length; |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return new TextLineImpl(textRuns, |
|||
TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Measures the number of characters that fits into available width.
|
|||
/// </summary>
|
|||
/// <param name="textCharacters">The text run.</param>
|
|||
/// <param name="availableWidth">The available width.</param>
|
|||
/// <returns></returns>
|
|||
private static int MeasureText(ShapedTextCharacters textCharacters, double availableWidth) |
|||
{ |
|||
var glyphRun = textCharacters.GlyphRun; |
|||
|
|||
var characterHit = glyphRun.GetCharacterHitFromDistance(availableWidth, out _); |
|||
|
|||
return characterHit.FirstCharacterIndex + characterHit.TrailingLength - textCharacters.Text.Start; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates an ellipsis.
|
|||
/// </summary>
|
|||
/// <param name="properties">The text run properties.</param>
|
|||
/// <returns></returns>
|
|||
private static ShapedTextCharacters CreateEllipsisRun(TextRunProperties properties) |
|||
{ |
|||
var formatterImpl = AvaloniaLocator.Current.GetService<ITextShaperImpl>(); |
|||
|
|||
var glyphRun = formatterImpl.ShapeText(s_ellipsis, properties.Typeface, properties.FontRenderingEmSize, |
|||
properties.CultureInfo); |
|||
|
|||
return new ShapedTextCharacters(glyphRun, properties); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the text range that is covered by the text runs.
|
|||
/// </summary>
|
|||
/// <param name="textRuns">The text runs.</param>
|
|||
/// <returns>The text range that is covered by the text runs.</returns>
|
|||
private static TextRange GetTextRange(IReadOnlyList<TextRun> textRuns) |
|||
{ |
|||
if (textRuns is null || textRuns.Count == 0) |
|||
{ |
|||
return new TextRange(); |
|||
} |
|||
|
|||
var firstTextRun = textRuns[0]; |
|||
|
|||
if (textRuns.Count == 1) |
|||
{ |
|||
return new TextRange(firstTextRun.Text.Start, firstTextRun.Text.Length); |
|||
} |
|||
|
|||
var start = firstTextRun.Text.Start; |
|||
|
|||
var end = textRuns[textRuns.Count - 1].Text.End + 1; |
|||
|
|||
return new TextRange(start, end - start); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Split a sequence of runs into two segments at specified length.
|
|||
/// </summary>
|
|||
/// <param name="textRuns">The text run's.</param>
|
|||
/// <param name="length">The length to split at.</param>
|
|||
/// <returns>The split text runs.</returns>
|
|||
private static SplitTextRunsResult SplitTextRuns(IReadOnlyList<ShapedTextCharacters> textRuns, int length) |
|||
{ |
|||
var currentLength = 0; |
|||
|
|||
for (var i = 0; i < textRuns.Count; i++) |
|||
{ |
|||
var currentRun = textRuns[i]; |
|||
|
|||
if (currentLength + currentRun.GlyphRun.Characters.Length < length) |
|||
{ |
|||
currentLength += currentRun.GlyphRun.Characters.Length; |
|||
continue; |
|||
} |
|||
|
|||
var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i; |
|||
|
|||
var first = new ShapedTextCharacters[firstCount]; |
|||
|
|||
if (firstCount > 1) |
|||
{ |
|||
for (var j = 0; j < i; j++) |
|||
{ |
|||
first[j] = textRuns[j]; |
|||
} |
|||
} |
|||
|
|||
var secondCount = textRuns.Count - firstCount; |
|||
|
|||
if (currentLength + currentRun.GlyphRun.Characters.Length == length) |
|||
{ |
|||
var second = new ShapedTextCharacters[secondCount]; |
|||
|
|||
var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; |
|||
|
|||
if (secondCount > 0) |
|||
{ |
|||
for (var j = 0; j < secondCount; j++) |
|||
{ |
|||
second[j] = textRuns[i + j + offset]; |
|||
} |
|||
} |
|||
|
|||
first[i] = currentRun; |
|||
|
|||
return new SplitTextRunsResult(first, second); |
|||
} |
|||
else |
|||
{ |
|||
secondCount++; |
|||
|
|||
var second = new ShapedTextCharacters[secondCount]; |
|||
|
|||
if (secondCount > 0) |
|||
{ |
|||
for (var j = 1; j < secondCount; j++) |
|||
{ |
|||
second[j] = textRuns[i + j]; |
|||
} |
|||
} |
|||
|
|||
var split = currentRun.Split(length - currentLength); |
|||
|
|||
first[i] = split.First; |
|||
|
|||
second[0] = split.Second; |
|||
|
|||
return new SplitTextRunsResult(first, second); |
|||
} |
|||
} |
|||
|
|||
return new SplitTextRunsResult(textRuns, null); |
|||
} |
|||
|
|||
private readonly struct SplitTextRunsResult |
|||
{ |
|||
public SplitTextRunsResult(IReadOnlyList<ShapedTextCharacters> first, IReadOnlyList<ShapedTextCharacters> second) |
|||
{ |
|||
First = first; |
|||
|
|||
Second = second; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the first text runs.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The first text runs.
|
|||
/// </value>
|
|||
public IReadOnlyList<ShapedTextCharacters> First { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the second text runs.
|
|||
/// </summary>
|
|||
/// <value>
|
|||
/// The second text runs.
|
|||
/// </value>
|
|||
public IReadOnlyList<ShapedTextCharacters> Second { get; } |
|||
} |
|||
|
|||
private struct TextRunEnumerator |
|||
{ |
|||
private readonly ITextSource _textSource; |
|||
private int _pos; |
|||
|
|||
public TextRunEnumerator(ITextSource textSource, int firstTextSourceIndex) |
|||
{ |
|||
_textSource = textSource; |
|||
_pos = firstTextSourceIndex; |
|||
Current = null; |
|||
} |
|||
|
|||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
|||
public TextRun Current { get; private set; } |
|||
|
|||
public bool MoveNext() |
|||
{ |
|||
Current = _textSource.GetTextRun(_pos); |
|||
|
|||
if (Current is null) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
if (Current.TextSourceLength == 0) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
_pos += Current.TextSourceLength; |
|||
|
|||
return !(Current is TextEndOfLine); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Specialized text run used to mark a range of hidden characters
|
|||
/// </summary>
|
|||
public class TextHidden : TextRun |
|||
{ |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
public class TextLineBreak |
|||
{ |
|||
public TextLineBreak(IReadOnlyList<ShapedTextCharacters> remainingCharacters) |
|||
{ |
|||
RemainingCharacters = remainingCharacters; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get the remaining shaped characters that were split up by the <see cref="TextFormatter"/> during the formatting process.
|
|||
/// </summary>
|
|||
public IReadOnlyList<ShapedTextCharacters> RemainingCharacters { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,235 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
internal class TextLineImpl : TextLine |
|||
{ |
|||
private readonly IReadOnlyList<ShapedTextCharacters> _textRuns; |
|||
|
|||
public TextLineImpl(IReadOnlyList<ShapedTextCharacters> textRuns, TextLineMetrics lineMetrics, |
|||
TextLineBreak lineBreak = null) |
|||
{ |
|||
_textRuns = textRuns; |
|||
LineMetrics = lineMetrics; |
|||
LineBreak = lineBreak; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override TextRange TextRange => LineMetrics.TextRange; |
|||
|
|||
/// <inheritdoc/>
|
|||
public override IReadOnlyList<TextRun> TextRuns => _textRuns; |
|||
|
|||
/// <inheritdoc/>
|
|||
public override TextLineMetrics LineMetrics { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override TextLineBreak LineBreak { get; } |
|||
|
|||
/// <inheritdoc/>
|
|||
public override void Draw(DrawingContext drawingContext, Point origin) |
|||
{ |
|||
var currentX = origin.X; |
|||
|
|||
foreach (var textRun in _textRuns) |
|||
{ |
|||
var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X, |
|||
origin.Y + LineMetrics.BaselineOrigin.Y); |
|||
|
|||
textRun.Draw(drawingContext, baselineOrigin); |
|||
|
|||
currentX += textRun.Bounds.Width; |
|||
} |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetCharacterHitFromDistance(double distance) |
|||
{ |
|||
if (distance < 0) |
|||
{ |
|||
// hit happens before the line, return the first position
|
|||
return new CharacterHit(TextRange.Start); |
|||
} |
|||
|
|||
// process hit that happens within the line
|
|||
var characterHit = new CharacterHit(); |
|||
|
|||
foreach (var run in _textRuns) |
|||
{ |
|||
characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _); |
|||
|
|||
if (distance <= run.Bounds.Width) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
distance -= run.Bounds.Width; |
|||
} |
|||
|
|||
return characterHit; |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override double GetDistanceFromCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
if (TryFindNextCharacterHit(characterHit, out var nextCharacterHit)) |
|||
{ |
|||
return nextCharacterHit; |
|||
} |
|||
|
|||
return new CharacterHit(TextRange.End); // Can't move, we're after the last character
|
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
if (TryFindPreviousCharacterHit(characterHit, out var previousCharacterHit)) |
|||
{ |
|||
return previousCharacterHit; |
|||
} |
|||
|
|||
return new CharacterHit(TextRange.Start); // Can't move, we're before the first character
|
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) |
|||
{ |
|||
// same operation as move-to-previous
|
|||
return GetPreviousCaretCharacterHit(characterHit); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get distance from line start to the specified codepoint index.
|
|||
/// </summary>
|
|||
private double DistanceFromCodepointIndex(int codepointIndex) |
|||
{ |
|||
var currentDistance = 0.0; |
|||
|
|||
foreach (var textRun in _textRuns) |
|||
{ |
|||
if (codepointIndex > textRun.Text.End) |
|||
{ |
|||
currentDistance += textRun.Bounds.Width; |
|||
|
|||
continue; |
|||
} |
|||
|
|||
return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex)); |
|||
} |
|||
|
|||
return currentDistance; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Tries to find the next character hit.
|
|||
/// </summary>
|
|||
/// <param name="characterHit">The current character hit.</param>
|
|||
/// <param name="nextCharacterHit">The next character hit.</param>
|
|||
/// <returns></returns>
|
|||
private bool TryFindNextCharacterHit(CharacterHit characterHit, out CharacterHit nextCharacterHit) |
|||
{ |
|||
nextCharacterHit = characterHit; |
|||
|
|||
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; |
|||
|
|||
if (codepointIndex >= TextRange.Start + TextRange.Length) |
|||
{ |
|||
return false; // Cannot go forward anymore
|
|||
} |
|||
|
|||
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex); |
|||
|
|||
while (runIndex < TextRuns.Count) |
|||
{ |
|||
var run = _textRuns[runIndex]; |
|||
|
|||
nextCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex + characterHit.TrailingLength, out _); |
|||
|
|||
if (codepointIndex <= nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Tries to find the previous character hit.
|
|||
/// </summary>
|
|||
/// <param name="characterHit">The current character hit.</param>
|
|||
/// <param name="previousCharacterHit">The previous character hit.</param>
|
|||
/// <returns></returns>
|
|||
private bool TryFindPreviousCharacterHit(CharacterHit characterHit, out CharacterHit previousCharacterHit) |
|||
{ |
|||
previousCharacterHit = characterHit; |
|||
|
|||
var codepointIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; |
|||
|
|||
if (codepointIndex < TextRange.Start) |
|||
{ |
|||
return false; // Cannot go backward anymore.
|
|||
} |
|||
|
|||
var runIndex = GetRunIndexAtCodepointIndex(codepointIndex); |
|||
|
|||
while (runIndex >= 0) |
|||
{ |
|||
var run = _textRuns[runIndex]; |
|||
|
|||
previousCharacterHit = run.GlyphRun.FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _); |
|||
|
|||
if (previousCharacterHit.FirstCharacterIndex < codepointIndex) |
|||
{ |
|||
return true; |
|||
} |
|||
|
|||
runIndex--; |
|||
} |
|||
|
|||
return false; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the run index of the specified codepoint index.
|
|||
/// </summary>
|
|||
/// <param name="codepointIndex">The codepoint index.</param>
|
|||
/// <returns>The text run index.</returns>
|
|||
private int GetRunIndexAtCodepointIndex(int codepointIndex) |
|||
{ |
|||
if (codepointIndex >= TextRange.End) |
|||
{ |
|||
return _textRuns.Count - 1; |
|||
} |
|||
|
|||
if (codepointIndex <= 0) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
var runIndex = 0; |
|||
|
|||
while (runIndex < _textRuns.Count) |
|||
{ |
|||
var run = _textRuns[runIndex]; |
|||
|
|||
if (run.Text.End > codepointIndex) |
|||
{ |
|||
return runIndex; |
|||
} |
|||
|
|||
runIndex++; |
|||
} |
|||
|
|||
return runIndex; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Specialized text run used to modify properties of text runs in its scope.
|
|||
/// The scope extends to the next matching EndOfSegment text run (matching
|
|||
/// because text modifiers may be nested), or to the next EndOfParagraph.
|
|||
/// </summary>
|
|||
public abstract class TextModifier : TextRun |
|||
{ |
|||
/// <summary>
|
|||
/// Modifies the properties of a text run.
|
|||
/// </summary>
|
|||
/// <param name="properties">Properties of a text run or the return value of
|
|||
/// ModifyProperties for a nested text modifier.</param>
|
|||
/// <returns>Returns the actual text run properties to be used for formatting,
|
|||
/// subject to further modification by text modifiers at outer scopes.</returns>
|
|||
public abstract TextRunProperties ModifyProperties(TextRunProperties properties); |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
using System; |
|||
using System.Globalization; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Properties that can change from one run to the next, such as typeface or foreground brush.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// The client provides a concrete implementation of this abstract run properties class. This
|
|||
/// allows client to implement their run properties the way that fits with their run formatting
|
|||
/// store.
|
|||
/// </remarks>
|
|||
public abstract class TextRunProperties : IEquatable<TextRunProperties> |
|||
{ |
|||
/// <summary>
|
|||
/// Run typeface
|
|||
/// </summary>
|
|||
public abstract Typeface Typeface { get; } |
|||
|
|||
/// <summary>
|
|||
/// Em size of font used to format and display text
|
|||
/// </summary>
|
|||
public abstract double FontRenderingEmSize { get; } |
|||
|
|||
///<summary>
|
|||
/// Run TextDecorations.
|
|||
///</summary>
|
|||
public abstract TextDecorationCollection TextDecorations { get; } |
|||
|
|||
/// <summary>
|
|||
/// Brush used to fill text.
|
|||
/// </summary>
|
|||
public abstract IBrush ForegroundBrush { get; } |
|||
|
|||
/// <summary>
|
|||
/// Brush used to paint background of run.
|
|||
/// </summary>
|
|||
public abstract IBrush BackgroundBrush { get; } |
|||
|
|||
/// <summary>
|
|||
/// Run text culture.
|
|||
/// </summary>
|
|||
public abstract CultureInfo CultureInfo { get; } |
|||
|
|||
public bool Equals(TextRunProperties other) |
|||
{ |
|||
if (ReferenceEquals(null, other)) |
|||
return false; |
|||
if (ReferenceEquals(this, other)) |
|||
return true; |
|||
|
|||
return Typeface.Equals(other.Typeface) && |
|||
FontRenderingEmSize.Equals(other.FontRenderingEmSize) |
|||
&& Equals(TextDecorations, other.TextDecorations) && |
|||
Equals(ForegroundBrush, other.ForegroundBrush) && |
|||
Equals(BackgroundBrush, other.BackgroundBrush) && |
|||
Equals(CultureInfo, other.CultureInfo); |
|||
} |
|||
|
|||
public override bool Equals(object obj) |
|||
{ |
|||
return ReferenceEquals(this, obj) || obj is TextRunProperties other && Equals(other); |
|||
} |
|||
|
|||
public override int GetHashCode() |
|||
{ |
|||
unchecked |
|||
{ |
|||
var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0); |
|||
hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode(); |
|||
hashCode = (hashCode * 397) ^ (TextDecorations != null ? TextDecorations.GetHashCode() : 0); |
|||
hashCode = (hashCode * 397) ^ (ForegroundBrush != null ? ForegroundBrush.GetHashCode() : 0); |
|||
hashCode = (hashCode * 397) ^ (BackgroundBrush != null ? BackgroundBrush.GetHashCode() : 0); |
|||
hashCode = (hashCode * 397) ^ (CultureInfo != null ? CultureInfo.GetHashCode() : 0); |
|||
return hashCode; |
|||
} |
|||
} |
|||
|
|||
public static bool operator ==(TextRunProperties left, TextRunProperties right) |
|||
{ |
|||
return Equals(left, right); |
|||
} |
|||
|
|||
public static bool operator !=(TextRunProperties left, TextRunProperties right) |
|||
{ |
|||
return !Equals(left, right); |
|||
} |
|||
} |
|||
} |
|||
@ -1,39 +0,0 @@ |
|||
using Avalonia.Media.Immutable; |
|||
|
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Unique text formatting properties that effect the styling of a text.
|
|||
/// </summary>
|
|||
public readonly struct TextStyle |
|||
{ |
|||
public TextStyle(Typeface typeface, double fontRenderingEmSize = 12, IBrush foreground = null, |
|||
ImmutableTextDecoration[] textDecorations = null) |
|||
: this(new TextFormat(typeface, fontRenderingEmSize), foreground, textDecorations) |
|||
{ |
|||
} |
|||
|
|||
public TextStyle(TextFormat textFormat, IBrush foreground = null, |
|||
ImmutableTextDecoration[] textDecorations = null) |
|||
{ |
|||
TextFormat = textFormat; |
|||
Foreground = foreground; |
|||
TextDecorations = textDecorations; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the text format.
|
|||
/// </summary>
|
|||
public TextFormat TextFormat { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the foreground.
|
|||
/// </summary>
|
|||
public IBrush Foreground { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the text decorations.
|
|||
/// </summary>
|
|||
public ImmutableTextDecoration[] TextDecorations { get; } |
|||
} |
|||
} |
|||
@ -1,24 +0,0 @@ |
|||
namespace Avalonia.Media.TextFormatting |
|||
{ |
|||
/// <summary>
|
|||
/// Represents a text run's style and is used during the layout process of the <see cref="TextFormatter"/>.
|
|||
/// </summary>
|
|||
public readonly struct TextStyleRun |
|||
{ |
|||
public TextStyleRun(TextPointer textPointer, TextStyle style) |
|||
{ |
|||
TextPointer = textPointer; |
|||
Style = style; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the text pointer.
|
|||
/// </summary>
|
|||
public TextPointer TextPointer { get; } |
|||
|
|||
/// <summary>
|
|||
/// Gets the text style.
|
|||
/// </summary>
|
|||
public TextStyle Style { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
namespace Avalonia.Utilities |
|||
{ |
|||
/// <summary>
|
|||
/// Pairing of value and positions sharing that value.
|
|||
/// </summary>
|
|||
public readonly struct ValueSpan<T> |
|||
{ |
|||
public ValueSpan(int start, int length, T value) |
|||
{ |
|||
Start = start; |
|||
Length = length; |
|||
Value = value; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get's the start of the span.
|
|||
/// </summary>
|
|||
public int Start { get; } |
|||
|
|||
/// <summary>
|
|||
/// Get's the length of the span.
|
|||
/// </summary>
|
|||
public int Length { get; } |
|||
|
|||
/// <summary>
|
|||
/// Get's the value of the span.
|
|||
/// </summary>
|
|||
public T Value { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
using System; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
internal class FormattableTextSource : ITextSource |
|||
{ |
|||
private readonly ReadOnlySlice<char> _text; |
|||
private readonly TextRunProperties _defaultStyle; |
|||
private ReadOnlySlice<ValueSpan<TextRunProperties>> _styleSpans; |
|||
|
|||
public FormattableTextSource(string text, TextRunProperties defaultStyle, |
|||
ReadOnlySlice<ValueSpan<TextRunProperties>> styleSpans) |
|||
{ |
|||
_text = text.AsMemory(); |
|||
|
|||
_defaultStyle = defaultStyle; |
|||
|
|||
_styleSpans = styleSpans; |
|||
} |
|||
|
|||
public TextRun GetTextRun(int textSourceIndex) |
|||
{ |
|||
if (_styleSpans.IsEmpty) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
var currentSpan = _styleSpans[0]; |
|||
|
|||
_styleSpans = _styleSpans.Skip(1); |
|||
|
|||
return new TextCharacters(_text.AsSlice(currentSpan.Start, currentSpan.Length), |
|||
_defaultStyle); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
using System; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
internal class MultiBufferTextSource : ITextSource |
|||
{ |
|||
private readonly string[] _runTexts; |
|||
private readonly GenericTextRunProperties _defaultStyle; |
|||
|
|||
public MultiBufferTextSource(GenericTextRunProperties defaultStyle) |
|||
{ |
|||
_defaultStyle = defaultStyle; |
|||
|
|||
_runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" }; |
|||
} |
|||
|
|||
public static TextRange TextRange => new TextRange(0, 50); |
|||
|
|||
public TextRun GetTextRun(int textSourceIndex) |
|||
{ |
|||
if (textSourceIndex == 50) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
var index = textSourceIndex / 10; |
|||
|
|||
var runText = _runTexts[index]; |
|||
|
|||
return new TextCharacters( |
|||
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
using System; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
internal class SingleBufferTextSource : ITextSource |
|||
{ |
|||
private readonly ReadOnlySlice<char> _text; |
|||
private readonly GenericTextRunProperties _defaultGenericPropertiesRunProperties; |
|||
|
|||
public SingleBufferTextSource(string text, GenericTextRunProperties defaultProperties) |
|||
{ |
|||
_text = text.AsMemory(); |
|||
_defaultGenericPropertiesRunProperties = defaultProperties; |
|||
} |
|||
|
|||
public TextRun GetTextRun(int textSourceIndex) |
|||
{ |
|||
var runText = _text.Skip(textSourceIndex); |
|||
|
|||
if (runText.IsEmpty) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
return new TextCharacters(runText, _defaultGenericPropertiesRunProperties); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,275 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.UnitTests; |
|||
using Avalonia.Utilities; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
public class TextFormatterTests |
|||
{ |
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_Default_Style() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "0123456789"; |
|||
|
|||
var defaultProperties = |
|||
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
Assert.Single(textLine.TextRuns); |
|||
|
|||
var textRun = textLine.TextRuns[0]; |
|||
|
|||
Assert.Equal(defaultProperties.Typeface, textRun.Properties.Typeface); |
|||
|
|||
Assert.Equal(defaultProperties.ForegroundBrush, textRun.Properties.ForegroundBrush); |
|||
|
|||
Assert.Equal(text.Length, textRun.Text.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_Multiple_Buffers() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = |
|||
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black); |
|||
|
|||
var textSource = new MultiBufferTextSource(defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
Assert.Equal(5, textLine.TextRuns.Count); |
|||
|
|||
Assert.Equal(50, textLine.TextRange.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_TextRunStyles() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "0123456789"; |
|||
|
|||
var defaultProperties = |
|||
new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Black); |
|||
|
|||
var GenericTextRunPropertiesRuns = new[] |
|||
{ |
|||
new ValueSpan<TextRunProperties>(0, 3, defaultProperties), |
|||
new ValueSpan<TextRunProperties>(3, 3, |
|||
new GenericTextRunProperties(Typeface.Default, 13, foregroundBrush: Brushes.Black)), |
|||
new ValueSpan<TextRunProperties>(6, 3, |
|||
new GenericTextRunProperties(Typeface.Default, 14, foregroundBrush: Brushes.Black)), |
|||
new ValueSpan<TextRunProperties>(9, 1, defaultProperties) |
|||
}; |
|||
|
|||
var textSource = new FormattableTextSource(text, defaultProperties, GenericTextRunPropertiesRuns); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
Assert.Equal(text.Length, textLine.TextRange.Length); |
|||
|
|||
for (var i = 0; i < GenericTextRunPropertiesRuns.Length; i++) |
|||
{ |
|||
var GenericTextRunPropertiesRun = GenericTextRunPropertiesRuns[i]; |
|||
|
|||
var textRun = textLine.TextRuns[i]; |
|||
|
|||
Assert.Equal(GenericTextRunPropertiesRun.Length, textRun.Text.Length); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("0123", 1)] |
|||
[InlineData("\r\n", 1)] |
|||
[InlineData("👍b", 2)] |
|||
[InlineData("a👍b", 3)] |
|||
[InlineData("a👍子b", 4)] |
|||
public void Should_Produce_Unique_Runs(string text, int numberOfRuns) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
Assert.Equal(numberOfRuns, textLine.TextRuns.Count); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Split_Run_On_Script() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "1234الدولي"; |
|||
|
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
Assert.Equal(4, textLine.TextRuns[0].Text.Length); |
|||
} |
|||
} |
|||
|
|||
[InlineData("𐐷𐐷𐐷𐐷𐐷", 10, 1)] |
|||
[InlineData("01234 56789 01234 56789", 6, 4)] |
|||
[Theory] |
|||
public void Should_Wrap_With_Overflow(string text, int expectedCharactersPerLine, int expectedNumberOfLines) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var numberOfLines = 0; |
|||
|
|||
var currentPosition = 0; |
|||
|
|||
while (currentPosition < text.Length) |
|||
{ |
|||
var textLine = |
|||
formatter.FormatLine(textSource, currentPosition, 1, |
|||
new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.WrapWithOverflow)); |
|||
|
|||
if (text.Length - currentPosition > expectedCharactersPerLine) |
|||
{ |
|||
Assert.Equal(expectedCharactersPerLine, textLine.TextRange.Length); |
|||
} |
|||
|
|||
currentPosition += textLine.TextRange.Length; |
|||
|
|||
numberOfLines++; |
|||
} |
|||
|
|||
Assert.Equal(expectedNumberOfLines, numberOfLines); |
|||
} |
|||
} |
|||
|
|||
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " + |
|||
"IndividualB2C, SingleOrg, or MultiOrg aren't used for ‑‑auth." |
|||
, "Noto Sans", 40)] |
|||
[InlineData("01234 56789 01234 56789", "Noto Mono", 7)] |
|||
[Theory] |
|||
public void Should_Wrap(string text, string familyName, int numberOfCharactersPerLine) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var lineBreaker = new LineBreakEnumerator(text.AsMemory()); |
|||
|
|||
var expected = new List<int>(); |
|||
|
|||
while (lineBreaker.MoveNext()) |
|||
{ |
|||
expected.Add(lineBreaker.Current.PositionWrap - 1); |
|||
} |
|||
|
|||
var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" + |
|||
familyName); |
|||
|
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var glyph = typeface.GlyphTypeface.GetGlyph('a'); |
|||
|
|||
var advance = typeface.GlyphTypeface.GetGlyphAdvance(glyph) * |
|||
(12.0 / typeface.GlyphTypeface.DesignEmHeight); |
|||
|
|||
var paragraphWidth = advance * numberOfCharactersPerLine; |
|||
|
|||
var currentPosition = 0; |
|||
|
|||
while (currentPosition < text.Length) |
|||
{ |
|||
var textLine = |
|||
formatter.FormatLine(textSource, currentPosition, paragraphWidth, |
|||
new GenericTextParagraphProperties(defaultProperties, textWrapping: TextWrapping.Wrap)); |
|||
|
|||
Assert.True(expected.Contains(textLine.TextRange.End)); |
|||
|
|||
var index = expected.IndexOf(textLine.TextRange.End); |
|||
|
|||
for (var i = 0; i <= index; i++) |
|||
{ |
|||
expected.RemoveAt(0); |
|||
} |
|||
|
|||
currentPosition += textLine.TextRange.Length; |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Produce_Fixed_Height_Lines() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "012345"; |
|||
|
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties, lineHeight: 50)); |
|||
|
|||
Assert.Equal(50, textLine.LineMetrics.Size.Height); |
|||
} |
|||
} |
|||
|
|||
public static IDisposable Start() |
|||
{ |
|||
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface |
|||
.With(renderInterface: new PlatformRenderInterface(null), |
|||
textShaperImpl: new TextShaperImpl())); |
|||
|
|||
AvaloniaLocator.CurrentMutable |
|||
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl())); |
|||
|
|||
return disposable; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,175 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests.Media.TextFormatting |
|||
{ |
|||
public class TextLineTests |
|||
{ |
|||
[InlineData("𐐷𐐷𐐷𐐷𐐷")] |
|||
[InlineData("𐐷1234")] |
|||
[Theory] |
|||
public void Should_Get_Next_Caret_CharacterHit(string text) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters) |
|||
.ToArray(); |
|||
|
|||
var nextCharacterHit = new CharacterHit(0); |
|||
|
|||
for (var i = 1; i < clusters.Length; i++) |
|||
{ |
|||
nextCharacterHit = textLine.GetNextCaretCharacterHit(nextCharacterHit); |
|||
|
|||
Assert.Equal(clusters[i], nextCharacterHit.FirstCharacterIndex + nextCharacterHit.TrailingLength); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[InlineData("𐐷𐐷𐐷𐐷𐐷")] |
|||
[InlineData("𐐷1234")] |
|||
[Theory] |
|||
public void Should_Get_Previous_Caret_CharacterHit(string text) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new SingleBufferTextSource(text, defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
var clusters = textLine.TextRuns.Cast<ShapedTextCharacters>().SelectMany(x => x.GlyphRun.GlyphClusters) |
|||
.ToArray(); |
|||
|
|||
var previousCharacterHit = new CharacterHit(clusters[^1]); |
|||
|
|||
for (var i = clusters.Length - 2; i > 0; i--) |
|||
{ |
|||
previousCharacterHit = textLine.GetPreviousCaretCharacterHit(previousCharacterHit); |
|||
|
|||
Assert.Equal(clusters[i], previousCharacterHit.FirstCharacterIndex); |
|||
} |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Get_Distance_From_CharacterHit() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new MultiBufferTextSource(defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
var currentDistance = 0.0; |
|||
|
|||
foreach (var run in textLine.TextRuns) |
|||
{ |
|||
var textRun = (ShapedTextCharacters)run; |
|||
|
|||
var glyphRun = textRun.GlyphRun; |
|||
|
|||
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) |
|||
{ |
|||
var cluster = glyphRun.GlyphClusters[i]; |
|||
|
|||
var glyph = glyphRun.GlyphIndices[i]; |
|||
|
|||
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; |
|||
|
|||
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); |
|||
|
|||
Assert.Equal(currentDistance, distance); |
|||
|
|||
currentDistance += advance; |
|||
} |
|||
} |
|||
|
|||
Assert.Equal(currentDistance, |
|||
textLine.GetDistanceFromCharacterHit(new CharacterHit(MultiBufferTextSource.TextRange.Length))); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Get_CharacterHit_From_Distance() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultProperties = new GenericTextRunProperties(Typeface.Default); |
|||
|
|||
var textSource = new MultiBufferTextSource(defaultProperties); |
|||
|
|||
var formatter = new TextFormatterImpl(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new GenericTextParagraphProperties(defaultProperties)); |
|||
|
|||
var currentDistance = 0.0; |
|||
|
|||
CharacterHit characterHit; |
|||
|
|||
foreach (var run in textLine.TextRuns) |
|||
{ |
|||
var textRun = (ShapedTextCharacters)run; |
|||
|
|||
var glyphRun = textRun.GlyphRun; |
|||
|
|||
for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) |
|||
{ |
|||
var cluster = glyphRun.GlyphClusters[i]; |
|||
|
|||
var glyph = glyphRun.GlyphIndices[i]; |
|||
|
|||
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; |
|||
|
|||
characterHit = textLine.GetCharacterHitFromDistance(currentDistance); |
|||
|
|||
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); |
|||
|
|||
currentDistance += advance; |
|||
} |
|||
} |
|||
|
|||
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width); |
|||
|
|||
Assert.Equal(MultiBufferTextSource.TextRange.End, characterHit.FirstCharacterIndex); |
|||
} |
|||
} |
|||
|
|||
private static IDisposable Start() |
|||
{ |
|||
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface |
|||
.With(renderInterface: new PlatformRenderInterface(null), |
|||
textShaperImpl: new TextShaperImpl(), |
|||
fontManagerImpl: new CustomFontManagerImpl())); |
|||
|
|||
return disposable; |
|||
} |
|||
} |
|||
} |
|||
@ -1,373 +0,0 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Media; |
|||
using Avalonia.Media.TextFormatting; |
|||
using Avalonia.Media.TextFormatting.Unicode; |
|||
using Avalonia.UnitTests; |
|||
using Avalonia.Utility; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Skia.UnitTests |
|||
{ |
|||
public class SimpleTextFormatterTests |
|||
{ |
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_Default_Style() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "0123456789"; |
|||
|
|||
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); |
|||
|
|||
var textSource = new SimpleTextSource(text, defaultTextRunStyle); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
Assert.Single(textLine.TextRuns); |
|||
|
|||
var textRun = textLine.TextRuns[0]; |
|||
|
|||
Assert.Equal(defaultTextRunStyle.TextFormat, textRun.Style.TextFormat); |
|||
|
|||
Assert.Equal(defaultTextRunStyle.Foreground, textRun.Style.Foreground); |
|||
|
|||
Assert.Equal(text.Length, textRun.Text.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_Multiple_Buffers() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); |
|||
|
|||
var textSource = new MultiBufferTextSource(defaultTextRunStyle); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, |
|||
new TextParagraphProperties(defaultTextRunStyle)); |
|||
|
|||
Assert.Equal(5, textLine.TextRuns.Count); |
|||
|
|||
Assert.Equal(50, textLine.Text.Length); |
|||
} |
|||
} |
|||
|
|||
private class MultiBufferTextSource : ITextSource |
|||
{ |
|||
private readonly string[] _runTexts; |
|||
private readonly 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) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
var index = textSourceIndex / 10; |
|||
|
|||
var runText = _runTexts[index]; |
|||
|
|||
return new TextCharacters( |
|||
new ReadOnlySlice<char>(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Format_TextRuns_With_TextRunStyles() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "0123456789"; |
|||
|
|||
var defaultStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); |
|||
|
|||
var textStyleRuns = new[] |
|||
{ |
|||
new TextStyleRun(new TextPointer(0, 3), defaultStyle ), |
|||
new TextStyleRun(new TextPointer(3, 3), new TextStyle(Typeface.Default, 13, Brushes.Black) ), |
|||
new TextStyleRun(new TextPointer(6, 3), new TextStyle(Typeface.Default, 14, Brushes.Black) ), |
|||
new TextStyleRun(new TextPointer(9, 1), defaultStyle ) |
|||
}; |
|||
|
|||
var textSource = new FormattableTextSource(text, defaultStyle, textStyleRuns); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
Assert.Equal(text.Length, textLine.Text.Length); |
|||
|
|||
for (var i = 0; i < textStyleRuns.Length; i++) |
|||
{ |
|||
var textStyleRun = textStyleRuns[i]; |
|||
|
|||
var textRun = textLine.TextRuns[i]; |
|||
|
|||
Assert.Equal(textStyleRun.TextPointer.Length, textRun.Text.Length); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private class FormattableTextSource : ITextSource |
|||
{ |
|||
private readonly ReadOnlySlice<char> _text; |
|||
private readonly TextStyle _defaultStyle; |
|||
private ReadOnlySlice<TextStyleRun> _textStyleRuns; |
|||
|
|||
public FormattableTextSource(string text, TextStyle defaultStyle, ReadOnlySlice<TextStyleRun> textStyleRuns) |
|||
{ |
|||
_text = text.AsMemory(); |
|||
|
|||
_defaultStyle = defaultStyle; |
|||
|
|||
_textStyleRuns = textStyleRuns; |
|||
} |
|||
|
|||
public TextRun GetTextRun(int textSourceIndex) |
|||
{ |
|||
if (_textStyleRuns.IsEmpty) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
var styleRun = _textStyleRuns[0]; |
|||
|
|||
_textStyleRuns = _textStyleRuns.Skip(1); |
|||
|
|||
return new TextCharacters(_text.AsSlice(styleRun.TextPointer.Start, styleRun.TextPointer.Length), |
|||
_defaultStyle); |
|||
} |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("0123", 1)] |
|||
[InlineData("\r\n", 1)] |
|||
[InlineData("👍b", 2)] |
|||
[InlineData("a👍b", 3)] |
|||
[InlineData("a👍子b", 4)] |
|||
public void Should_Produce_Unique_Runs(string text, int numberOfRuns) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
Assert.Equal(numberOfRuns, textLine.TextRuns.Count); |
|||
} |
|||
} |
|||
|
|||
private class SimpleTextSource : ITextSource |
|||
{ |
|||
private readonly ReadOnlySlice<char> _text; |
|||
private readonly TextStyle _defaultTextStyle; |
|||
|
|||
public SimpleTextSource(string text, TextStyle defaultText) |
|||
{ |
|||
_text = text.AsMemory(); |
|||
_defaultTextStyle = defaultText; |
|||
} |
|||
|
|||
public TextRun GetTextRun(int textSourceIndex) |
|||
{ |
|||
var runText = _text.Skip(textSourceIndex); |
|||
|
|||
if (runText.IsEmpty) |
|||
{ |
|||
return new TextEndOfParagraph(); |
|||
} |
|||
|
|||
return new TextCharacters(runText, _defaultTextStyle); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Split_Run_On_Script() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
const string text = "1234الدولي"; |
|||
|
|||
var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
Assert.Equal(4, textLine.TextRuns[0].Text.Length); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Get_Distance_From_CharacterHit() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
var currentDistance = 0.0; |
|||
|
|||
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 glyph = glyphRun.GlyphIndices[i]; |
|||
|
|||
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; |
|||
|
|||
var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); |
|||
|
|||
Assert.Equal(currentDistance, distance); |
|||
|
|||
currentDistance += advance; |
|||
} |
|||
} |
|||
|
|||
Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length))); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Get_CharacterHit_From_Distance() |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var textLine = |
|||
formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); |
|||
|
|||
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 glyph = glyphRun.GlyphIndices[i]; |
|||
|
|||
var advance = glyphRun.GlyphTypeface.GetGlyphAdvance(glyph) * glyphRun.Scale; |
|||
|
|||
characterHit = textLine.GetCharacterHitFromDistance(currentDistance); |
|||
|
|||
Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); |
|||
|
|||
currentDistance += advance; |
|||
} |
|||
} |
|||
|
|||
characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width); |
|||
|
|||
Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex); |
|||
} |
|||
} |
|||
|
|||
[InlineData("Whether to turn off HTTPS. This option only applies if Individual, " + |
|||
"IndividualB2C, SingleOrg, or MultiOrg aren't used for ‑‑auth." |
|||
, "Noto Sans", 40)] |
|||
[InlineData("01234 56789 01234 56789", "Noto Mono", 7)] |
|||
[Theory] |
|||
public void Should_Wrap_Text(string text, string familyName, int numberOfCharactersPerLine) |
|||
{ |
|||
using (Start()) |
|||
{ |
|||
var lineBreaker = new LineBreakEnumerator(text.AsMemory()); |
|||
|
|||
var expected = new List<int>(); |
|||
|
|||
while (lineBreaker.MoveNext()) |
|||
{ |
|||
expected.Add(lineBreaker.Current.PositionWrap - 1); |
|||
} |
|||
|
|||
var typeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#" + |
|||
familyName); |
|||
|
|||
var defaultStyle = new TextStyle(typeface); |
|||
|
|||
var textSource = new SimpleTextSource(text, defaultStyle); |
|||
|
|||
var formatter = new SimpleTextFormatter(); |
|||
|
|||
var glyph = typeface.GlyphTypeface.GetGlyph('a'); |
|||
|
|||
var advance = typeface.GlyphTypeface.GetGlyphAdvance(glyph) * |
|||
(12.0 / typeface.GlyphTypeface.DesignEmHeight); |
|||
|
|||
var paragraphWidth = advance * numberOfCharactersPerLine; |
|||
|
|||
var currentPosition = 0; |
|||
|
|||
while (currentPosition < text.Length) |
|||
{ |
|||
var textLine = |
|||
formatter.FormatLine(textSource, currentPosition, paragraphWidth, |
|||
new TextParagraphProperties(defaultStyle, textWrapping: TextWrapping.Wrap)); |
|||
|
|||
Assert.True(expected.Contains(textLine.Text.End)); |
|||
|
|||
var index = expected.IndexOf(textLine.Text.End); |
|||
|
|||
for (var i = 0; i <= index; i++) |
|||
{ |
|||
expected.RemoveAt(0); |
|||
} |
|||
|
|||
currentPosition += textLine.Text.Length; |
|||
} |
|||
} |
|||
} |
|||
|
|||
public static IDisposable Start() |
|||
{ |
|||
var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface |
|||
.With(renderInterface: new PlatformRenderInterface(null), |
|||
textShaperImpl: new TextShaperImpl())); |
|||
|
|||
AvaloniaLocator.CurrentMutable |
|||
.Bind<FontManager>().ToConstant(new FontManager(new CustomFontManagerImpl())); |
|||
|
|||
return disposable; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue