From 38bd8b8010269a1b43001a308e180cebb406d9bc Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Sun, 1 Mar 2020 16:07:20 +0100 Subject: [PATCH] Fix text line hit testing Fix some xml comments --- .../Media/TextFormatting/ShapedTextRun.cs | 10 +- .../TextFormatting/SimpleTextFormatter.cs | 2 +- .../Media/TextFormatting/SimpleTextLine.cs | 174 ++++++++---------- .../Media/TextFormatting/TextLayout.cs | 23 ++- .../Media/TextFormatting/TextLine.cs | 18 +- .../Media/TextFormatting/TextShaper.cs | 7 +- .../SimpleTextFormatterTests.cs | 70 +++++-- 7 files changed, 152 insertions(+), 152 deletions(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs index 00a393cf61..00f9b918cb 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs @@ -22,9 +22,7 @@ namespace Avalonia.Media.TextFormatting GlyphRun = glyphRun; } - /// - /// Gets the bounds. - /// + /// public override Rect Bounds => GlyphRun.Bounds; /// @@ -35,11 +33,7 @@ namespace Avalonia.Media.TextFormatting /// public GlyphRun GlyphRun { get; } - /// - /// Draws the at the given origin. - /// - /// The drawing context. - /// The origin. + /// public override void Draw(IDrawingContextImpl drawingContext, Point origin) { if (GlyphRun.GlyphIndices.Length == 0) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs index 66071878fd..e84242c628 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs @@ -166,7 +166,7 @@ namespace Avalonia.Media.TextFormatting var splitResult = SplitTextRuns(textRuns, measuredLength); - var trimmedRuns = new List(splitResult.First.Count + 1); + var trimmedRuns = new List(splitResult.First.Count + 1); trimmedRuns.AddRange(splitResult.First); diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs index ab93848c23..11d241bc34 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs @@ -6,42 +6,43 @@ namespace Avalonia.Media.TextFormatting { internal class SimpleTextLine : TextLine { - public SimpleTextLine(TextPointer textPointer, IReadOnlyList textRuns, TextLineMetrics lineMetrics) : - base(textPointer, textRuns, lineMetrics) - { + private readonly IReadOnlyList _textRuns; + public SimpleTextLine(TextPointer textPointer, IReadOnlyList textRuns, TextLineMetrics lineMetrics) + { + Text = textPointer; + _textRuns = textRuns; + LineMetrics = lineMetrics; } + /// + public override TextPointer Text { get; } + + /// + public override IReadOnlyList TextRuns => _textRuns; + + /// + public override TextLineMetrics LineMetrics { get; } + + /// public override void Draw(IDrawingContextImpl drawingContext, Point origin) { var currentX = origin.X; - foreach (var textRun in TextRuns) + foreach (var textRun in _textRuns) { - if (!(textRun is DrawableTextRun drawableRun)) - { - continue; - } - var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X, origin.Y + LineMetrics.BaselineOrigin.Y); - drawableRun.Draw(drawingContext, baselineOrigin); + textRun.Draw(drawingContext, baselineOrigin); - currentX += drawableRun.Bounds.Width; + currentX += textRun.Bounds.Width; } } - /// - /// Client to get the character hit corresponding to the specified - /// distance from the beginning of the line. - /// - /// distance in text flow direction from the beginning of the line - /// character hit + /// public override CharacterHit GetCharacterHitFromDistance(double distance) { - var first = Text.Start; - if (distance < 0) { // hit happens before the line, return the first position @@ -49,45 +50,30 @@ namespace Avalonia.Media.TextFormatting } // process hit that happens within the line - var runIndex = new CharacterHit(); + var characterHit = new CharacterHit(); - foreach (var run in TextRuns) + foreach (var run in _textRuns) { - var shapedTextRun = (ShapedTextRun)run; - - first += runIndex.TrailingLength; + characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _); - runIndex = shapedTextRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); - - first += runIndex.FirstCharacterIndex; - - if (distance <= shapedTextRun.Bounds.Width) + if (distance <= run.Bounds.Width) { break; } - distance -= shapedTextRun.Bounds.Width; + distance -= run.Bounds.Width; } - return new CharacterHit(first, runIndex.TrailingLength); + return characterHit; } - /// - /// Client to get the distance from the beginning of the line from the specified - /// character hit. - /// - /// character hit of the character to query the distance. - /// distance in text flow direction from the beginning of the line. + /// public override double GetDistanceFromCharacterHit(CharacterHit characterHit) { - return DistanceFromCp(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); + return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); } - /// - /// Client to get the next character hit for caret navigation - /// - /// the current character hit - /// the next character hit + /// public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) { int nextVisibleCp; @@ -95,7 +81,7 @@ namespace Avalonia.Media.TextFormatting if (characterHit.TrailingLength == 0) { - navigableCpFound = FindNextVisibleCp(characterHit.FirstCharacterIndex, out nextVisibleCp); + navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp); if (navigableCpFound) { @@ -104,7 +90,7 @@ namespace Avalonia.Media.TextFormatting } } - navigableCpFound = FindNextVisibleCp(characterHit.FirstCharacterIndex + 1, out nextVisibleCp); + navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp); if (navigableCpFound) { @@ -116,18 +102,14 @@ namespace Avalonia.Media.TextFormatting return characterHit; } - /// - /// Client to get the previous character hit for caret navigation - /// - /// the current character hit - /// the previous character hit + /// public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) { - int previousVisibleCp; - bool navigableCpFound; + int previousCodepointIndex; + bool codepointIndexFound; - int cpHit = characterHit.FirstCharacterIndex; - bool trailingHit = (characterHit.TrailingLength != 0); + var cpHit = characterHit.FirstCharacterIndex; + var trailingHit = characterHit.TrailingLength != 0; // Input can be right after the end of the current line. Snap it to be at the end of the line. if (cpHit >= Text.Start + Text.Length) @@ -139,32 +121,28 @@ namespace Avalonia.Media.TextFormatting if (trailingHit) { - navigableCpFound = FindPreviousVisibleCp(cpHit, out previousVisibleCp); + codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex); - if (navigableCpFound) + if (codepointIndexFound) { // Move from trailing to leading edge - return new CharacterHit(previousVisibleCp, 0); + return new CharacterHit(previousCodepointIndex, 0); } } - navigableCpFound = FindPreviousVisibleCp(cpHit - 1, out previousVisibleCp); + codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex); - if (navigableCpFound) + if (codepointIndexFound) { // Move from leading edge of current character to leading edge of previous - return new CharacterHit(previousVisibleCp, 0); + return new CharacterHit(previousCodepointIndex, 0); } // Can't move, we're before the first character return characterHit; } - /// - /// Client to get the previous character hit after backspacing - /// - /// the current character hit - /// the character hit after backspacing + /// public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) { // same operation as move-to-previous @@ -172,44 +150,41 @@ namespace Avalonia.Media.TextFormatting } /// - /// Get distance from line start to the specified cp + /// Get distance from line start to the specified codepoint index /// - private double DistanceFromCp(int currentIndex) + private double DistanceFromCodepointIndex(int codepointIndex) { - var distance = 0.0; - var dcp = currentIndex - Text.Start; + var currentDistance = 0.0; - foreach (var textRun in TextRuns) + foreach (var textRun in _textRuns) { - var run = (ShapedTextRun)textRun; - - distance += run.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(dcp)); - - if (dcp <= run.Text.Length) + if (codepointIndex > textRun.Text.End) { - break; + currentDistance += textRun.Bounds.Width; + + continue; } - dcp -= run.Text.Length; + return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex)); } - return distance; + return currentDistance; } /// - /// Search forward from the given cp index (inclusive) to find the next navigable cp index. - /// Return true if one such cp is found, false otherwise. + /// Search forward from the given codepoint index (inclusive) to find the next navigable codepoint index. + /// Return true if one such codepoint index is found, false otherwise. /// - private bool FindNextVisibleCp(int cp, out int cpVisible) + private bool FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex) { - cpVisible = cp; + nextCodepointIndex = codepointIndex; - if (cp >= Text.Start + Text.Length) + if (codepointIndex >= Text.Start + Text.Length) { return false; // Cannot go forward anymore } - GetRunIndexAtCp(cp, out var runIndex, out var cpRunStart); + GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart); while (runIndex < TextRuns.Count) { @@ -217,7 +192,7 @@ namespace Avalonia.Media.TextFormatting // navigable. if (runIndex < TextRuns.Count) { - cpVisible = Math.Max(cpRunStart, cp); + nextCodepointIndex = Math.Max(cpRunStart, codepointIndex); return true; } @@ -228,29 +203,29 @@ namespace Avalonia.Media.TextFormatting } /// - /// Search backward from the given cp index (inclusive) to find the previous navigable cp index. - /// Return true if one such cp is found, false otherwise. + /// Search backward from the given codepoint index (inclusive) to find the previous navigable codepoint index. + /// Return true if one such codepoint is found, false otherwise. /// - private bool FindPreviousVisibleCp(int cp, out int cpVisible) + private bool FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex) { - cpVisible = cp; + previousCodepointIndex = codepointIndex; - if (cp < Text.Start) + if (codepointIndex < Text.Start) { return false; // Cannot go backward anymore. } // Position the cpRunEnd at the end of the span that contains the given cp - GetRunIndexAtCp(cp, out var runIndex, out var cpRunEnd); + GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd); - cpRunEnd += TextRuns[runIndex].Text.End; + codepointIndexAtRunEnd += TextRuns[runIndex].Text.End; while (runIndex >= 0) { // Visible content has caret stops at its leading edge. if (runIndex + 1 < TextRuns.Count) { - cpVisible = Math.Min(cpRunEnd, cp); + previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex); return true; } @@ -258,25 +233,26 @@ namespace Avalonia.Media.TextFormatting if (runIndex == TextRuns.Count) { // Get the cp index at the beginning of the newline sequence. - cpVisible = cpRunEnd - TextRuns[runIndex].Text.Length + 1; + previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1; return true; } - cpRunEnd -= TextRuns[runIndex--].Text.Length; + codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length; } return false; } - private void GetRunIndexAtCp(int cp, out int runIndex, out int cpRunStart) + private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart) { - cpRunStart = Text.Start; + codepointIndexAtRunStart = Text.Start; runIndex = 0; // Find the span that contains the given cp - while (runIndex < TextRuns.Count && cpRunStart + TextRuns[runIndex].Text.Length <= cp) + while (runIndex < TextRuns.Count && + codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex) { - cpRunStart += TextRuns[runIndex++].Text.Length; + codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length; } } } diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index dc3942f224..0c9013e6f7 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -20,7 +20,7 @@ namespace Avalonia.Media.TextFormatting private readonly ReadOnlySlice _text; private readonly TextParagraphProperties _paragraphProperties; - private readonly TextStyleRun[] _textStyleOverrides; + private readonly IReadOnlyList _textStyleOverrides; /// /// Initializes a new instance of the class. @@ -47,7 +47,7 @@ namespace Avalonia.Media.TextFormatting TextDecorationCollection textDecorations = null, double maxWidth = double.PositiveInfinity, double maxHeight = double.PositiveInfinity, - TextStyleRun[] textStyleOverrides = null) + IReadOnlyList textStyleOverrides = null) { _text = string.IsNullOrEmpty(text) ? new ReadOnlySlice() : @@ -276,10 +276,10 @@ namespace Avalonia.Media.TextFormatting { private readonly ReadOnlySlice _text; private readonly TextStyle _defaultStyle; - private readonly TextStyleRun[] _textStyleOverrides; + private readonly IReadOnlyList _textStyleOverrides; public FormattedTextSource(ReadOnlySlice text, TextStyle defaultStyle, - TextStyleRun[] textStyleOverrides) + IReadOnlyList textStyleOverrides) { _text = text; _defaultStyle = defaultStyle; @@ -311,8 +311,13 @@ namespace Avalonia.Media.TextFormatting /// The created text style run. /// private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice text, - TextStyle defaultTextStyle, ReadOnlySpan textStyleOverrides) + TextStyle defaultTextStyle, IReadOnlyList textStyleOverrides) { + if(textStyleOverrides == null || textStyleOverrides.Count == 0) + { + return new TextStyleRun(new TextPointer(text.Start, text.Length), defaultTextStyle); + } + var currentTextStyle = defaultTextStyle; var hasOverride = false; @@ -321,7 +326,7 @@ namespace Avalonia.Media.TextFormatting var length = 0; - for (; i < textStyleOverrides.Length; i++) + for (; i < textStyleOverrides.Count; i++) { var styleOverride = textStyleOverrides[i]; @@ -341,7 +346,7 @@ namespace Avalonia.Media.TextFormatting if (textPointer.Start > text.Start) { if (styleOverride.Style.TextFormat != currentTextStyle.TextFormat || - currentTextStyle.Foreground != styleOverride.Style.Foreground) + !currentTextStyle.Foreground.Equals(styleOverride.Style.Foreground)) { length = Math.Min(Math.Abs(textPointer.Start - text.Start), text.Length); @@ -361,9 +366,9 @@ namespace Avalonia.Media.TextFormatting currentTextStyle = styleOverride.Style; } - if (length < text.Length && i == textStyleOverrides.Length) + if (length < text.Length && i == textStyleOverrides.Count) { - if (currentTextStyle.Foreground == defaultTextStyle.Foreground && + if (currentTextStyle.Foreground.Equals(defaultTextStyle.Foreground) && currentTextStyle.TextFormat == defaultTextStyle.TextFormat) { length = text.Length; diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs index 27f5355987..296aef0fed 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs @@ -11,25 +11,13 @@ namespace Avalonia.Media.TextFormatting /// public abstract class TextLine { - protected TextLine() - { - - } - - protected TextLine(TextPointer text, IReadOnlyList textRuns, TextLineMetrics lineMetrics) - { - Text = text; - TextRuns = textRuns; - LineMetrics = lineMetrics; - } - /// /// Gets the text. /// /// /// The text pointer. /// - public TextPointer Text { get; protected set; } + public abstract TextPointer Text { get; } /// /// Gets the text runs. @@ -37,7 +25,7 @@ namespace Avalonia.Media.TextFormatting /// /// The text runs. /// - public IReadOnlyList TextRuns { get; protected set; } + public abstract IReadOnlyList TextRuns { get; } /// /// Gets the line metrics. @@ -45,7 +33,7 @@ namespace Avalonia.Media.TextFormatting /// /// The line metrics. /// - public TextLineMetrics LineMetrics { get; protected set; } + public abstract TextLineMetrics LineMetrics { get; } /// /// Draws the at the given origin. diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs index 48e3312906..eb3a4129bc 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs @@ -43,12 +43,7 @@ namespace Avalonia.Media.TextFormatting } } - /// - /// Shapes the specified text and returns a resulting glyph run. - /// - /// The text. - /// The text format. - /// A shaped glyph run. + /// public GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat) { return _platformImpl.ShapeText(text, textFormat); diff --git a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs index 63cb7c2f36..f424027910 100644 --- a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs @@ -43,7 +43,7 @@ namespace Avalonia.Skia.UnitTests { var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); - var textSource = new MultipleBufferTextSource(defaultTextRunStyle); + var textSource = new MultiBufferTextSource(defaultTextRunStyle); var formatter = new SimpleTextFormatter(); @@ -56,18 +56,20 @@ namespace Avalonia.Skia.UnitTests } } - private class MultipleBufferTextSource : ITextSource + private class MultiBufferTextSource : ITextSource { private readonly string[] _runTexts; private readonly TextStyle _defaultStyle; - public MultipleBufferTextSource(TextStyle defaultStyle) + public MultiBufferTextSource(TextStyle defaultStyle) { _defaultStyle = defaultStyle; _runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" }; } + public TextPointer TextPointer => new TextPointer(0, 50); + public TextRun GetTextRun(int textSourceIndex) { if (textSourceIndex == 50) @@ -197,7 +199,7 @@ namespace Avalonia.Skia.UnitTests } [Fact] - public void Should_Split_Run_On_Direction() + public void Should_Split_Run_On_Script() { using (Start()) { @@ -219,18 +221,36 @@ namespace Avalonia.Skia.UnitTests { using (Start()) { - const string text = "0123456789"; - - var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); + var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); var formatter = new SimpleTextFormatter(); var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); - var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(text.Length)); + var currentDistance = 0.0; + + foreach (var run in textLine.TextRuns) + { + var textRun = (ShapedTextRun)run; + + var glyphRun = textRun.GlyphRun; - Assert.Equal(textLine.LineMetrics.Size.Width, distance); + for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) + { + var cluster = glyphRun.GlyphClusters[i]; + + var advance = glyphRun.GlyphAdvances[i]; + + var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); + + Assert.Equal(currentDistance, distance); + + currentDistance += advance; + } + } + + Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length))); } } @@ -239,18 +259,40 @@ namespace Avalonia.Skia.UnitTests { using (Start()) { - const string text = "0123456789"; - - var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); + var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); var formatter = new SimpleTextFormatter(); var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); - var characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width); + var currentDistance = 0.0; + + CharacterHit characterHit; + + foreach (var run in textLine.TextRuns) + { + var textRun = (ShapedTextRun)run; + + var glyphRun = textRun.GlyphRun; + + for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) + { + var cluster = glyphRun.GlyphClusters[i]; + + var advance = glyphRun.GlyphAdvances[i]; + + characterHit = textLine.GetCharacterHitFromDistance(currentDistance); + + Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + + currentDistance += advance; + } + } + + characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width); - Assert.Equal(textLine.Text.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex); } }