diff --git a/samples/Sandbox/MainWindow.axaml b/samples/Sandbox/MainWindow.axaml index 6929f192c7..0fc78795a6 100644 --- a/samples/Sandbox/MainWindow.axaml +++ b/samples/Sandbox/MainWindow.axaml @@ -1,4 +1,14 @@ + + + + + + + + + + diff --git a/samples/Sandbox/MainWindow.axaml.cs b/samples/Sandbox/MainWindow.axaml.cs index 3d54036d29..7a8b56bbb6 100644 --- a/samples/Sandbox/MainWindow.axaml.cs +++ b/samples/Sandbox/MainWindow.axaml.cs @@ -1,7 +1,11 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Presenters; using Avalonia.Markup.Xaml; -using Avalonia.Win32.WinRT.Composition; +using Avalonia.VisualTree; namespace Sandbox { @@ -11,6 +15,19 @@ namespace Sandbox { this.InitializeComponent(); this.AttachDevTools(); + + var textBox = this.FindControl("txtBox"); + + textBox.TemplateApplied += TextBox_TemplateApplied; + } + + private void TextBox_TemplateApplied(object sender, Avalonia.Controls.Primitives.TemplateAppliedEventArgs e) + { + var textBox = sender as TextBox; + + var textPresenter = e.NameScope.Find("PART_TextPresenter") as TextPresenter; + + DataContext = new TestViewModel(textPresenter); } private void InitializeComponent() @@ -18,4 +35,60 @@ namespace Sandbox AvaloniaXamlLoader.Load(this); } } + + public class TestViewModel : ViewModelBase + { + private readonly TextPresenter _textPresenter; + private double _distance = 45; + + public TestViewModel(TextPresenter textPresenter) + { + _textPresenter = textPresenter; + } + + public double Distance + { + get => _distance; + set + { + OnDistanceChanged(value); + RaisePropertyChanged(); + } + } + + private void OnDistanceChanged(double distance) + { + if(distance < 0) + { + distance = 0; + } + + if(distance > _textPresenter.TextLayout.Bounds.Width) + { + distance = _textPresenter.TextLayout.Bounds.Width; + } + + var height = _textPresenter.TextLayout.Bounds.Height; + + var distanceY = height / 2; + + _textPresenter.MoveCaretToPoint(new Point(distance, distanceY)); + + var caretIndex = _textPresenter.CaretIndex; + + Debug.WriteLine(caretIndex); + + _distance = distance; + } + } + + public class ViewModelBase : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + protected void RaisePropertyChanged([CallerMemberName]string propertyName = "") + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } } diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index 2a7f3360ad..cae7a8fe75 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -265,7 +265,7 @@ namespace Avalonia.Media //RightToLeft var glyphIndex = FindGlyphIndex(characterIndex); - if (GlyphClusters != null) + if (GlyphClusters != null && GlyphClusters.Count > 0) { if (characterIndex > GlyphClusters[0]) { diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs index 97df87d3d9..7ab67ea34d 100644 --- a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs +++ b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Utilities; namespace Avalonia.Media.TextFormatting @@ -116,7 +117,30 @@ namespace Avalonia.Media.TextFormatting length = text.Length; } + length = CoerceLength(text, length); + return new ValueSpan(firstTextSourceIndex, length, currentProperties); } + + private static int CoerceLength(ReadOnlySlice text, int length) + { + var finalLength = 0; + + var graphemeEnumerator = new GraphemeEnumerator(text); + + while (graphemeEnumerator.MoveNext()) + { + var grapheme = graphemeEnumerator.Current; + + finalLength += grapheme.Text.Length; + + if (finalLength >= length) + { + return finalLength; + } + } + + return length; + } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index f3c62f4994..0ee791d935 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -183,8 +183,47 @@ namespace Avalonia.Media.TextFormatting var currentPosition = FirstTextSourceIndex; var currentDistance = 0.0; - foreach (var currentRun in _textRuns) + for (var i = 0; i < _textRuns.Count; i++) { + var currentRun = _textRuns[i]; + + if(currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight) + { + var rightToLeftIndex = i; + var rightToLeftDistance = shapedRun.Size.Width; + + while (rightToLeftIndex + 1 <= _textRuns.Count - 1) + { + var nextShaped = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters; + + if (nextShaped == null || nextShaped.ShapedBuffer.IsLeftToRight) + { + break; + } + + rightToLeftIndex++; + + rightToLeftDistance += nextShaped.Size.Width; + } + + for (var j = rightToLeftIndex; rightToLeftIndex >= 0; j--) + { + currentRun = _textRuns[j]; + + if(distance <= currentDistance + rightToLeftDistance - currentRun.Size.Width) + { + currentPosition += currentRun.TextSourceLength; + rightToLeftDistance -= currentRun.Size.Width; + + continue; + } + + characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); + + break; + } + } + if (currentDistance + currentRun.Size.Width < distance) { currentDistance += currentRun.Size.Width; @@ -255,6 +294,37 @@ namespace Avalonia.Media.TextFormatting { var currentRun = _textRuns[index]; + if (currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight) + { + var i = index; + + while (i + 1 <= _textRuns.Count - 1) + { + var nextRun = _textRuns[i + 1]; + + if (nextRun is ShapedTextCharacters nextShapedRun) + { + if (nextShapedRun.ShapedBuffer.IsLeftToRight) + { + break; + } + } + + i++; + } + + while (i > index) + { + var rightToLeftRun = _textRuns[i]; + + currentPosition += rightToLeftRun.TextSourceLength; + + remainingLength -= rightToLeftRun.TextSourceLength; + + i--; + } + } + if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength, flowDirection, out var distance, out _)) { @@ -442,92 +512,130 @@ namespace Avalonia.Media.TextFormatting continue; } - if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) - { - startX += currentRun.Size.Width; - - currentPosition += currentRun.TextSourceLength; - - continue; - } - var characterLength = 0; var endX = startX; + var runWidth = 0.0; + TextRunBounds? currentRunBounds = null; - if (currentRun is ShapedTextCharacters currentShapedRun) + var currentShapedRun = currentRun as ShapedTextCharacters; + + if (currentShapedRun != null && !currentShapedRun.ShapedBuffer.IsLeftToRight) { - var offset = Math.Max(0, firstTextSourceIndex - currentPosition); + var rightToLeftIndex = index; + startX += currentShapedRun.Size.Width; - currentPosition += offset; + while (rightToLeftIndex + 1 <= _textRuns.Count - 1) + { + var nextShapedRun = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters; - var startIndex = currentRun.Text.Start + offset; + if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight) + { + break; + } - double startOffset; - double endOffset; + startX += nextShapedRun.Size.Width; - if (currentShapedRun.ShapedBuffer.IsLeftToRight) + rightToLeftIndex++; + } + + if (TryGetTextRunBoundsRightToLeft(startX, firstTextSourceIndex, characterIndex, rightToLeftIndex, ref currentPosition, ref remainingLength, out currentRunBounds)) { - startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + startX = currentRunBounds!.Rectangle.Left; + endX = currentRunBounds.Rectangle.Right; - endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + runWidth = currentRunBounds.Rectangle.Width; } - else + + currentDirection = FlowDirection.RightToLeft; + } + else + { + if (currentShapedRun != null) { - endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) + { + startX += currentRun.Size.Width; - if (currentPosition < startIndex) + currentPosition += currentRun.TextSourceLength; + + continue; + } + + var offset = Math.Max(0, firstTextSourceIndex - currentPosition); + + currentPosition += offset; + + var startIndex = currentRun.Text.Start + offset; + + double startOffset; + double endOffset; + + if (currentShapedRun.ShapedBuffer.IsLeftToRight) { - startOffset = endOffset; + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); } else { - startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + + if (currentPosition < startIndex) + { + startOffset = endOffset; + } + else + { + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + } } - } - startX += startOffset; + startX += startOffset; - endX += endOffset; + endX += endOffset; - var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); - var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); + var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); + var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); - characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength); + characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength); - currentDirection = currentShapedRun.ShapedBuffer.IsLeftToRight ? - FlowDirection.LeftToRight : - FlowDirection.RightToLeft; - } - else - { - if (currentPosition < firstTextSourceIndex) + currentDirection = FlowDirection.LeftToRight; + } + else { - startX += currentRun.Size.Width; + if (currentPosition < firstTextSourceIndex) + { + startX += currentRun.Size.Width; + } + + if (currentPosition + currentRun.TextSourceLength <= characterIndex) + { + endX += currentRun.Size.Width; + + characterLength = currentRun.TextSourceLength; + } } - if (currentPosition + currentRun.TextSourceLength <= characterIndex) + if (endX < startX) { - endX += currentRun.Size.Width; + (endX, startX) = (startX, endX); + } - characterLength = currentRun.TextSourceLength; + //Lines that only contain a linebreak need to be covered here + if (characterLength == 0) + { + characterLength = NewLineLength; } - } - if (endX < startX) - { - (endX, startX) = (startX, endX); - } + runWidth = endX - startX; + currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); - //Lines that only contain a linebreak need to be covered here - if (characterLength == 0) - { - characterLength = NewLineLength; - } + currentPosition += characterLength; - var runWidth = endX - startX; - var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + remainingLength -= characterLength; + } - if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0) + if (currentRunBounds != null && !MathUtilities.IsZero(runWidth) || NewLineLength > 0) { if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) { @@ -537,32 +645,26 @@ namespace Avalonia.Media.TextFormatting textBounds.Rectangle = currentRect; - textBounds.TextRunBounds.Add(currentRunBounds); + textBounds.TextRunBounds.Add(currentRunBounds!); } else { - currentRect = currentRunBounds.Rectangle; + currentRect = currentRunBounds!.Rectangle; result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); } } currentWidth += runWidth; - currentPosition += characterLength; + - if (currentPosition > characterIndex) + if (remainingLength <= 0 || currentPosition >= characterIndex) { break; } startX = endX; lastDirection = currentDirection; - remainingLength -= characterLength; - - if (remainingLength <= 0) - { - break; - } } return result; @@ -674,7 +776,7 @@ namespace Avalonia.Media.TextFormatting var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); - if(!MathUtilities.IsZero(runWidth) || NewLineLength > 0) + if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0) { if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX)) { @@ -692,7 +794,7 @@ namespace Avalonia.Media.TextFormatting result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); } - } + } currentWidth += runWidth; currentPosition += characterLength; @@ -716,6 +818,107 @@ namespace Avalonia.Media.TextFormatting return result; } + private bool TryGetTextRunBoundsRightToLeft(double startX, int firstTextSourceIndex, int characterIndex, int runIndex, ref int currentPosition, ref int remainingLength, out TextRunBounds? textRunBounds) + { + textRunBounds = null; + + for (var index = runIndex; index >= 0; index--) + { + if (TextRuns[index] is not DrawableTextRun currentRun) + { + continue; + } + + if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) + { + startX -= currentRun.Size.Width; + + currentPosition += currentRun.TextSourceLength; + + continue; + } + + var characterLength = 0; + var endX = startX; + + if (currentRun is ShapedTextCharacters currentShapedRun) + { + var offset = Math.Max(0, firstTextSourceIndex - currentPosition); + + currentPosition += offset; + + var startIndex = currentRun.Text.Start + offset; + double startOffset; + double endOffset; + + if (currentShapedRun.ShapedBuffer.IsLeftToRight) + { + if (currentPosition < startIndex) + { + startOffset = endOffset = 0; + } + else + { + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + } + } + else + { + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + } + + startX -= currentRun.Size.Width - startOffset; + endX -= currentRun.Size.Width - endOffset; + + var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); + var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); + + characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength); + } + else + { + if (currentPosition + currentRun.TextSourceLength <= characterIndex) + { + endX -= currentRun.Size.Width; + } + + if (currentPosition < firstTextSourceIndex) + { + startX -= currentRun.Size.Width; + + characterLength = currentRun.TextSourceLength; + } + } + + if (endX < startX) + { + (endX, startX) = (startX, endX); + } + + //Lines that only contain a linebreak need to be covered here + if (characterLength == 0) + { + characterLength = NewLineLength; + } + + var runWidth = endX - startX; + + remainingLength -= characterLength; + + currentPosition += characterLength; + + textRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + + return true; + } + + return false; + } + public override IReadOnlyList GetTextBounds(int firstTextSourceIndex, int textLength) { if (_paragraphProperties.FlowDirection == FlowDirection.LeftToRight) @@ -1319,12 +1522,12 @@ namespace Avalonia.Media.TextFormatting case TextAlignment.Center: var start = (_paragraphWidth - width) / 2; - if(paragraphFlowDirection == FlowDirection.RightToLeft) + if (paragraphFlowDirection == FlowDirection.RightToLeft) { start -= (widthIncludingTrailingWhitespace - width); } - return Math.Max(0, start); + return Math.Max(0, start); case TextAlignment.Right: return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace); diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index e463bc5731..4f9be86641 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -9,6 +9,7 @@ using Avalonia.VisualTree; using Avalonia.Layout; using Avalonia.Media.Immutable; using Avalonia.Controls.Documents; +using Avalonia.Media.TextFormatting.Unicode; namespace Avalonia.Controls.Presenters { @@ -496,14 +497,14 @@ namespace Avalonia.Controls.Presenters var length = Math.Max(selectionStart, selectionEnd) - start; IReadOnlyList>? textStyleOverrides = null; - + if (length > 0) { textStyleOverrides = new[] { new ValueSpan(start, length, new GenericTextRunProperties(typeface, FontSize, - foregroundBrush: SelectionForegroundBrush ?? Brushes.White)) + foregroundBrush: SelectionForegroundBrush ?? Brushes.Red)) }; } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 1b268db2f7..8490bfd3a0 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -17,6 +17,7 @@ using Avalonia.Controls.Metadata; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Automation.Peers; +using System.Diagnostics; namespace Avalonia.Controls { @@ -1240,9 +1241,12 @@ namespace Avalonia.Controls MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)), MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0))); - _presenter.MoveCaretToPoint(point); + var hit = _presenter.TextLayout.HitTestPoint(point); + + var caretIndex = hit.TextPosition; + + Debug.WriteLine($"TextPos: {caretIndex}, X: {point.X}"); - var caretIndex = _presenter.CaretIndex; var text = Text; if (text != null && _wordSelectionStart >= 0) @@ -1266,7 +1270,7 @@ namespace Avalonia.Controls } else { - SelectionEnd = _presenter.CaretIndex; + SelectionEnd = caretIndex; } } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index 7d33f094fa..6d057d900e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using Avalonia.Media; @@ -914,14 +915,14 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting public void Should_Get_CharacterHit_From_Distance_RTL() { using (Start()) - { + { var text = "أَبْجَدِيَّة عَرَبِيَّة"; var layout = new TextLayout( - text, - Typeface.Default, - 12, - Brushes.Black); + text, + Typeface.Default, + 12, + Brushes.Black); var textLine = layout.TextLines[0]; @@ -952,6 +953,63 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting rect = layout.HitTestTextPosition(23); Assert.Equal(0, rect.Left, 5); + + } + } + + [Fact] + public void Should_Get_CharacterHit_From_Distance_RTL_With_TextStyles() + { + using (Start()) + { + var text = "أَبْجَدِيَّة عَرَبِيَّة"; + + var i = 0; + + var graphemeEnumerator = new GraphemeEnumerator(text.AsMemory()); + + while (graphemeEnumerator.MoveNext()) + { + var grapheme = graphemeEnumerator.Current; + + var textStyleOverrides = new[] { new ValueSpan(i, grapheme.Text.Length, new GenericTextRunProperties(Typeface.Default, 12, foregroundBrush: Brushes.Red)) }; + + i += grapheme.Text.Length; + + var layout = new TextLayout( + text, + Typeface.Default, + 12, + Brushes.Black, + textStyleOverrides: textStyleOverrides); + + var textLine = layout.TextLines[0]; + + var shapedRuns = textLine.TextRuns.Cast().ToList(); + + var clusters = shapedRuns.SelectMany(x => x.ShapedBuffer.GlyphClusters).ToList(); + + var glyphAdvances = shapedRuns.SelectMany(x => x.ShapedBuffer.GlyphAdvances).ToList(); + + var currentX = 0.0; + + for (int j = 0; j < clusters.Count; j++) + { + var cluster = clusters[j]; + + var characterHit = textLine.GetCharacterHitFromDistance(currentX); + + Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + + var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); + + Assert.Equal(currentX, distance); + + var glyphAdvance = glyphAdvances[j]; + + currentX += glyphAdvance; + } + } } }