diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index fcb2cec733..a751ccc9bc 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -424,14 +424,20 @@ namespace Avalonia.Media /// public CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) { - var previousCharacterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _); + //Always produce a hit that is on the left edge - if (characterHit.TrailingLength != 0) + if (characterHit.TrailingLength > 0) { - return previousCharacterHit; + var previousCharacterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex, out _); + + return new CharacterHit(previousCharacterHit.FirstCharacterIndex); } + else + { + var previousCharacterHit = FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _); - return new CharacterHit(previousCharacterHit.FirstCharacterIndex); + return new CharacterHit(previousCharacterHit.FirstCharacterIndex); + } } /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index d2198a2cbf..177ada4934 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -713,9 +713,31 @@ namespace Avalonia.Media.TextFormatting var measuredLength = MeasureLength(textRuns, paragraphWidth); - if(measuredLength == 0) + if(measuredLength == 0 && paragraphProperties.TextWrapping == TextWrapping.WrapWithOverflow) { + for (int i = 0; i < textRuns.Count; i++) + { + measuredLength += textRuns[i].Length; + } + + TextLineBreak? textLineBreak; + + if (currentLineBreak?.TextEndOfLine is { } textEndOfLine) + { + textLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection); + } + else + { + textLineBreak = null; + } + var textLine = new TextLineImpl(textRuns.ToArray(), firstTextSourceIndex, measuredLength, + paragraphWidth, paragraphProperties, resolvedFlowDirection, + textLineBreak); + + textLine.FinalizeLine(); + + return textLine; } var currentLength = 0; diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index c25e51a1d7..a9f821a367 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting @@ -567,82 +568,13 @@ namespace Avalonia.Media.TextFormatting /// public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) { - if (_textRuns.Length == 0 || _indexedTextRuns is null) - { - return new CharacterHit(); - } - - if (characterHit.TrailingLength > 0 && characterHit.FirstCharacterIndex <= FirstTextSourceIndex) - { - return new CharacterHit(FirstTextSourceIndex); - } - - var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - - if (characterIndex <= FirstTextSourceIndex) - { - return new CharacterHit(FirstTextSourceIndex); - } - - var currentCharacterrHit = characterHit; - - var currentRun = GetRunAtCharacterIndex(characterIndex, LogicalDirection.Backward, out var currentPosition); - - if (currentPosition == characterHit.FirstCharacterIndex) - { - currentRun = GetRunAtCharacterIndex(characterHit.FirstCharacterIndex, LogicalDirection.Backward, out currentPosition); - } - - var previousCharacterHit = characterHit; - - switch (currentRun) - { - case ShapedTextRun shapedRun: - { - var offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); - - if (offset > 0) - { - currentCharacterrHit = new CharacterHit(Math.Max(0, characterHit.FirstCharacterIndex - offset), characterHit.TrailingLength); - } - - previousCharacterHit = shapedRun.GlyphRun.GetPreviousCaretCharacterHit(currentCharacterrHit); - - if (offset > 0) - { - previousCharacterHit = new CharacterHit(previousCharacterHit.FirstCharacterIndex + offset, previousCharacterHit.TrailingLength); - } - break; - } - case TextRun: - { - if (characterHit.TrailingLength > 0) - { - previousCharacterHit = new CharacterHit(currentPosition, currentRun.Length); - - } - else - { - previousCharacterHit = new CharacterHit(currentPosition + currentRun.Length); - } - - break; - } - } - - if (characterIndex == previousCharacterHit.FirstCharacterIndex + previousCharacterHit.TrailingLength) - { - return characterHit; - } - - return previousCharacterHit; + return GetPreviousCharacterHit(characterHit, false); } /// public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) { - // same operation as move-to-previous - return GetPreviousCaretCharacterHit(characterHit); + return GetPreviousCharacterHit(characterHit, true); } public override IReadOnlyList GetTextBounds(int firstTextSourceIndex, int textLength) @@ -823,6 +755,95 @@ namespace Avalonia.Media.TextFormatting return result; } + private CharacterHit GetPreviousCharacterHit(CharacterHit characterHit, bool useGraphemeBoundaries) + { + if (_textRuns.Length == 0 || _indexedTextRuns is null) + { + return new CharacterHit(); + } + + if (characterHit.TrailingLength > 0 && characterHit.FirstCharacterIndex <= FirstTextSourceIndex) + { + return new CharacterHit(FirstTextSourceIndex); + } + + var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; + + if (characterIndex <= FirstTextSourceIndex) + { + return new CharacterHit(FirstTextSourceIndex); + } + + var currentCharacterHit = characterHit; + + var currentRun = GetRunAtCharacterIndex(characterIndex, LogicalDirection.Backward, out var currentPosition); + + var previousCharacterHit = characterHit; + + switch (currentRun) + { + case ShapedTextRun shapedRun: + { + var offset = Math.Max(0, currentPosition - shapedRun.GlyphRun.Metrics.FirstCluster); + + if (offset > 0) + { + currentCharacterHit = new CharacterHit(Math.Max(0, characterHit.FirstCharacterIndex - offset), characterHit.TrailingLength); + } + + previousCharacterHit = shapedRun.GlyphRun.GetPreviousCaretCharacterHit(currentCharacterHit); + + if (useGraphemeBoundaries) + { + var textPosition = Math.Max(0, previousCharacterHit.FirstCharacterIndex - shapedRun.GlyphRun.Metrics.FirstCluster); + + var text = shapedRun.GlyphRun.Characters.Slice(textPosition); + + var graphemeEnumerator = new GraphemeEnumerator(text.Span); + + var length = 0; + + var clusterLength = Math.Max(0, currentCharacterHit.FirstCharacterIndex + currentCharacterHit.TrailingLength - + previousCharacterHit.FirstCharacterIndex - previousCharacterHit.TrailingLength); + + while (graphemeEnumerator.MoveNext(out var grapheme)) + { + if (length + grapheme.Length < clusterLength) + { + length += grapheme.Length; + + continue; + } + + previousCharacterHit = new CharacterHit(previousCharacterHit.FirstCharacterIndex + length); + + break; + } + } + + if (offset > 0) + { + previousCharacterHit = new CharacterHit(previousCharacterHit.FirstCharacterIndex + offset, previousCharacterHit.TrailingLength); + } + + break; + } + case TextRun: + { + previousCharacterHit = new CharacterHit(currentPosition); + + break; + } + } + + if (characterIndex == previousCharacterHit.FirstCharacterIndex + previousCharacterHit.TrailingLength) + { + return characterHit; + } + + return previousCharacterHit; + } + private TextBounds GetTextRunBoundsRightToLeft(int firstRunIndex, int lastRunIndex, double endX, int firstTextSourceIndex, int currentPosition, int remainingLength, out int coveredLength, out int newPosition) { @@ -1146,13 +1167,6 @@ namespace Avalonia.Media.TextFormatting } else { - if (previousRun is not null && previousRun is not ShapedTextRun && codepointIndex == textPosition + firstCluster) - { - textPosition -= previousRun.Length; - - return previousRun; - } - if (codepointIndex > firstCluster && codepointIndex <= firstCluster + currentRun.Length) { return currentRun; @@ -1170,9 +1184,19 @@ namespace Avalonia.Media.TextFormatting } case TextRun: { - if (codepointIndex == textPosition) + if(direction == LogicalDirection.Forward) { - return currentRun; + if (textPosition == codepointIndex) + { + return currentRun; + } + } + else + { + if (textPosition + currentRun.Length == codepointIndex) + { + return currentRun; + } } if (runIndex + 1 >= _textRuns.Length) diff --git a/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj b/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj index 86a680fac5..0c575cd96e 100644 --- a/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj +++ b/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj @@ -10,6 +10,8 @@ + + diff --git a/tests/Avalonia.Skia.UnitTests/Fonts/DejaVuSans.ttf b/tests/Avalonia.Skia.UnitTests/Fonts/DejaVuSans.ttf new file mode 100644 index 0000000000..d35124b24c Binary files /dev/null and b/tests/Avalonia.Skia.UnitTests/Fonts/DejaVuSans.ttf differ diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs index a6cce760e4..a043d6cfa0 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.UnitTests; @@ -765,6 +766,62 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting } } + [Fact] + public void Line_Formatting_For_Oversized_Embedded_Runs_Does_Not_Produce_Empty_Lines() + { + var defaultRunProperties = new GenericTextRunProperties(Typeface.Default, foregroundBrush: Brushes.Black); + var paragraphProperties = new GenericTextParagraphProperties(defaultRunProperties, + textWrap: TextWrapping.WrapWithOverflow); + + using (Start()) + { + var source = new ListTextSource(new RectangleRun(new Rect(0, 0, 200, 10), Brushes.Aqua)); + var textLine = TextFormatter.Current.FormatLine(source, 0, 100, paragraphProperties); + Assert.Equal(200d, textLine.WidthIncludingTrailingWhitespace); + } + } + + class IncrementalTabProperties : TextParagraphProperties + { + public IncrementalTabProperties(TextRunProperties defaultTextRunProperties) + { + DefaultTextRunProperties = defaultTextRunProperties; + } + + public override FlowDirection FlowDirection { get; } + public override TextAlignment TextAlignment { get; } + public override double LineHeight { get; } + public override bool FirstLineInParagraph { get; } + public override TextRunProperties DefaultTextRunProperties { get; } + public override TextWrapping TextWrapping { get; } + public override double Indent { get; } + public override double DefaultIncrementalTab => 64; + } + + [Fact] + public void Line_With_IncrementalTab_Should_Return_Correct_Backspace_Position() + { + using (Start()) + { + var typeface = new Typeface(FontFamily.Parse("resm:Avalonia.Skia.UnitTests.Fonts?assembly=Avalonia.Skia.UnitTests#DejaVu Sans")); + + var defaultRunProperties = new GenericTextRunProperties(typeface, foregroundBrush: Brushes.Black); + var paragraphProperties = new IncrementalTabProperties(defaultRunProperties); + + var text = new TextCharacters("ff", + new GenericTextRunProperties(typeface, foregroundBrush: Brushes.Black)); + + var source = new ListTextSource(text); + + var textLine = TextFormatter.Current.FormatLine(source, 0, double.PositiveInfinity, paragraphProperties); + + var backspaceHit = textLine.GetBackspaceCaretCharacterHit(new CharacterHit(2)); + Assert.Equal(1, backspaceHit.FirstCharacterIndex); + Assert.Equal(0, backspaceHit.TrailingLength); + } + + } + protected readonly record struct SimpleTextSource : ITextSource { private readonly string _text; diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 96c592702b..21e283e949 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -469,18 +469,18 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var currentHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(3, 1)); - Assert.Equal(2, currentHit.FirstCharacterIndex); - Assert.Equal(1, currentHit.TrailingLength); + Assert.Equal(3, currentHit.FirstCharacterIndex); + Assert.Equal(0, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); - Assert.Equal(1, currentHit.FirstCharacterIndex); - Assert.Equal(1, currentHit.TrailingLength); + Assert.Equal(2, currentHit.FirstCharacterIndex); + Assert.Equal(0, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); - Assert.Equal(0, currentHit.FirstCharacterIndex); - Assert.Equal(1, currentHit.TrailingLength); + Assert.Equal(1, currentHit.FirstCharacterIndex); + Assert.Equal(0, currentHit.TrailingLength); currentHit = textLine.GetPreviousCaretCharacterHit(currentHit); @@ -786,21 +786,21 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var characterHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(20, 1)); - Assert.Equal(19, characterHit.FirstCharacterIndex); + Assert.Equal(20, characterHit.FirstCharacterIndex); - Assert.Equal(1, characterHit.TrailingLength); + Assert.Equal(0, characterHit.TrailingLength); characterHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(10, 1)); - Assert.Equal(9, characterHit.FirstCharacterIndex); + Assert.Equal(10, characterHit.FirstCharacterIndex); - Assert.Equal(1, characterHit.TrailingLength); + Assert.Equal(0, characterHit.TrailingLength); characterHit = textLine.GetPreviousCaretCharacterHit(characterHit); - Assert.Equal(8, characterHit.FirstCharacterIndex); + Assert.Equal(9, characterHit.FirstCharacterIndex); - Assert.Equal(1, characterHit.TrailingLength); + Assert.Equal(0, characterHit.TrailingLength); characterHit = textLine.GetPreviousCaretCharacterHit(new CharacterHit(21));