diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml b/samples/ControlCatalog/Pages/TextBlockPage.xaml index cb49ba96c6..32914428ed 100644 --- a/samples/ControlCatalog/Pages/TextBlockPage.xaml +++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml @@ -118,7 +118,7 @@ - + This is a TextBlock with several diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs index ac87d521a5..2a7f3360ad 100644 --- a/src/Avalonia.Base/Media/GlyphRun.cs +++ b/src/Avalonia.Base/Media/GlyphRun.cs @@ -445,7 +445,7 @@ namespace Avalonia.Media /// public int FindGlyphIndex(int characterIndex) { - if (GlyphClusters == null) + if (GlyphClusters == null || GlyphClusters.Count == 0) { return characterIndex; } @@ -614,17 +614,29 @@ namespace Avalonia.Media private GlyphRunMetrics CreateGlyphRunMetrics() { + var firstCluster = 0; + var lastCluster = Characters.Length - 1; + + if (!IsLeftToRight) + { + var cluster = firstCluster; + firstCluster = lastCluster; + lastCluster = cluster; + } + if (GlyphClusters != null && GlyphClusters.Count > 0) { - var firstCluster = GlyphClusters[0]; + firstCluster = GlyphClusters[0]; + lastCluster = GlyphClusters[GlyphClusters.Count - 1]; _offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster); } + var isReversed = firstCluster > lastCluster; var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale; var widthIncludingTrailingWhitespace = 0d; - var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount); + var trailingWhitespaceLength = GetTrailingWhitespaceLength(isReversed, out var newLineLength, out var glyphCount); for (var index = 0; index < GlyphIndices.Count; index++) { @@ -635,16 +647,16 @@ namespace Avalonia.Media var width = widthIncludingTrailingWhitespace; - if (IsLeftToRight) + if (isReversed) { - for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++) + for (var index = 0; index < glyphCount; index++) { width -= GetGlyphAdvance(index, out _); } } else { - for (var index = 0; index < glyphCount; index++) + for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++) { width -= GetGlyphAdvance(index, out _); } @@ -654,16 +666,15 @@ namespace Avalonia.Media height); } - private int GetTrailingWhitespaceLength(out int newLineLength, out int glyphCount) - { - glyphCount = 0; - newLineLength = 0; - - if (Characters.IsEmpty) + private int GetTrailingWhitespaceLength(bool isReversed, out int newLineLength, out int glyphCount) + { + if (isReversed) { - return 0; + return GetTralingWhitespaceLengthRightToLeft(out newLineLength, out glyphCount); } + glyphCount = 0; + newLineLength = 0; var trailingWhitespaceLength = 0; if (GlyphClusters == null) @@ -732,6 +743,78 @@ namespace Avalonia.Media return trailingWhitespaceLength; } + private int GetTralingWhitespaceLengthRightToLeft(out int newLineLength, out int glyphCount) + { + glyphCount = 0; + newLineLength = 0; + var trailingWhitespaceLength = 0; + + if (GlyphClusters == null) + { + for (var i = 0; i < Characters.Length;) + { + var codepoint = Codepoint.ReadAt(_characters, i, out var count); + + if (!codepoint.IsWhiteSpace) + { + break; + } + + if (codepoint.IsBreakChar) + { + newLineLength++; + } + + trailingWhitespaceLength++; + + i += count; + glyphCount++; + } + } + else + { + for (var i = 0; i < GlyphClusters.Count; i++) + { + var currentCluster = GlyphClusters[i]; + var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset); + var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _); + + if (!codepoint.IsWhiteSpace) + { + break; + } + + var clusterLength = 1; + + while (i - 1 >= 0) + { + var nextCluster = GlyphClusters[i - 1]; + + if (currentCluster == nextCluster) + { + clusterLength++; + i--; + + continue; + } + + break; + } + + if (codepoint.IsBreakChar) + { + newLineLength += clusterLength; + } + + trailingWhitespaceLength += clusterLength; + + glyphCount++; + } + } + + return trailingWhitespaceLength; + } + private void Set(ref T field, T value) { _glyphRunImpl?.Dispose(); diff --git a/src/Avalonia.Base/Media/TextDecoration.cs b/src/Avalonia.Base/Media/TextDecoration.cs index 8eeb86c555..4c9764af96 100644 --- a/src/Avalonia.Base/Media/TextDecoration.cs +++ b/src/Avalonia.Base/Media/TextDecoration.cs @@ -209,7 +209,7 @@ namespace Avalonia.Media var pen = new Pen(Stroke ?? defaultBrush, thickness, new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap); - drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Size.Width, 0)); + drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Metrics.Width, 0)); } } } diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs index f3af240c58..f3e8b5969c 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs @@ -63,7 +63,7 @@ namespace Avalonia.Media.TextFormatting MaxHeight = maxHeight; - MaxLines = maxLines; + MaxLines = maxLines; TextLines = CreateTextLines(); } @@ -80,7 +80,7 @@ namespace Avalonia.Media.TextFormatting /// The maximum number of text lines. public TextLayout( ITextSource textSource, - TextParagraphProperties paragraphProperties, + TextParagraphProperties paragraphProperties, TextTrimming? textTrimming = null, double maxWidth = double.PositiveInfinity, double maxHeight = double.PositiveInfinity, @@ -178,24 +178,18 @@ namespace Avalonia.Media.TextFormatting return new Rect(); } - if (textPosition < 0 || textPosition >= _textSourceLength) + if (textPosition < 0) { - var lastLine = TextLines[TextLines.Count - 1]; - - var lineX = lastLine.Width; - - var lineY = Bounds.Bottom - lastLine.Height; - - return new Rect(lineX, lineY, 0, lastLine.Height); + textPosition = _textSourceLength; } var currentY = 0.0; foreach (var textLine in TextLines) { - var end = textLine.FirstTextSourceIndex + textLine.Length - 1; + var end = textLine.FirstTextSourceIndex + textLine.Length; - if (end < textPosition) + if (end <= textPosition && end < _textSourceLength) { currentY += textLine.Height; @@ -224,7 +218,7 @@ namespace Avalonia.Media.TextFormatting } var result = new List(TextLines.Count); - + var currentY = 0d; foreach (var textLine in TextLines) @@ -239,7 +233,7 @@ namespace Avalonia.Media.TextFormatting var textBounds = textLine.GetTextBounds(start, length); - if(textBounds.Count > 0) + if (textBounds.Count > 0) { foreach (var bounds in textBounds) { @@ -262,7 +256,7 @@ namespace Avalonia.Media.TextFormatting } } - if(textLine.FirstTextSourceIndex + textLine.Length >= start + length) + if (textLine.FirstTextSourceIndex + textLine.Length >= start + length) { break; } @@ -305,7 +299,7 @@ namespace Avalonia.Media.TextFormatting return GetHitTestResult(currentLine, characterHit, point); } - + public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge) { if (charIndex < 0) @@ -327,7 +321,7 @@ namespace Avalonia.Media.TextFormatting continue; } - if (charIndex >= textLine.FirstTextSourceIndex && + if (charIndex >= textLine.FirstTextSourceIndex && charIndex <= textLine.FirstTextSourceIndex + textLine.Length - (trailingEdge ? 0 : 1)) { return index; @@ -398,7 +392,7 @@ namespace Avalonia.Media.TextFormatting /// The current left. /// The current width. /// The current height. - private static void UpdateBounds(TextLine textLine,ref double left, ref double width, ref double height) + private static void UpdateBounds(TextLine textLine, ref double left, ref double width, ref double height) { var lineWidth = textLine.WidthIncludingTrailingWhitespace; @@ -421,7 +415,7 @@ namespace Avalonia.Media.TextFormatting { var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties); - Bounds = new Rect(0,0,0, textLine.Height); + Bounds = new Rect(0, 0, 0, textLine.Height); return new List { textLine }; } @@ -439,9 +433,9 @@ namespace Avalonia.Media.TextFormatting var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth, _paragraphProperties, previousLine?.TextLineBreak); - if(textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph) + if (textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph) { - if(previousLine != null && previousLine.NewLineLength > 0) + if (previousLine != null && previousLine.NewLineLength > 0) { var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties); @@ -454,7 +448,7 @@ namespace Avalonia.Media.TextFormatting } _textSourceLength += textLine.Length; - + //Fulfill max height constraint if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight) { @@ -485,12 +479,17 @@ namespace Avalonia.Media.TextFormatting //Fulfill max lines constraint if (MaxLines > 0 && textLines.Count >= MaxLines) { + if(textLine.TextLineBreak is TextLineBreak lineBreak && lineBreak.RemainingRuns != null) + { + textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width)); + } + break; } } //Make sure the TextLayout always contains at least on empty line - if(textLines.Count == 0) + if (textLines.Count == 0) { var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties); @@ -501,7 +500,7 @@ namespace Avalonia.Media.TextFormatting Bounds = new Rect(left, 0, width, height); - if(_paragraphProperties.TextAlignment == TextAlignment.Justify) + if (_paragraphProperties.TextAlignment == TextAlignment.Justify) { var whitespaceWidth = 0d; @@ -509,7 +508,7 @@ namespace Avalonia.Media.TextFormatting { var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace; - if(lineWhitespaceWidth > whitespaceWidth) + if (lineWhitespaceWidth > whitespaceWidth) { whitespaceWidth = lineWhitespaceWidth; } @@ -517,7 +516,7 @@ namespace Avalonia.Media.TextFormatting var justificationWidth = width - whitespaceWidth; - if(justificationWidth > 0) + if (justificationWidth > 0) { var justificationProperties = new InterWordJustification(justificationWidth); diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index 7c686358e2..f3c62f4994 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -166,58 +166,74 @@ namespace Avalonia.Media.TextFormatting if (distance <= 0) { - // hit happens before the line, return the first position var firstRun = _textRuns[0]; - if (firstRun is ShapedTextCharacters shapedTextCharacters) - { - return shapedTextCharacters.GlyphRun.GetCharacterHitFromDistance(distance, out _); - } + return GetRunCharacterHit(firstRun, FirstTextSourceIndex, 0); + } - return _resolvedFlowDirection == FlowDirection.LeftToRight ? - new CharacterHit(FirstTextSourceIndex) : - new CharacterHit(FirstTextSourceIndex + Length); + if (distance > WidthIncludingTrailingWhitespace) + { + var lastRun = _textRuns[_textRuns.Count - 1]; + + return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.TextSourceLength, lastRun.Size.Width); } // process hit that happens within the line var characterHit = new CharacterHit(); var currentPosition = FirstTextSourceIndex; + var currentDistance = 0.0; foreach (var currentRun in _textRuns) { - switch (currentRun) + if (currentDistance + currentRun.Size.Width < distance) { - case ShapedTextCharacters shapedRun: - { - characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); + currentDistance += currentRun.Size.Width; + currentPosition += currentRun.TextSourceLength; - var offset = Math.Max(0, currentPosition - shapedRun.Text.Start); + continue; + } - characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength); + characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance); - break; - } - default: + break; + } + + return characterHit; + } + + private static CharacterHit GetRunCharacterHit(DrawableTextRun run, int currentPosition, double distance) + { + CharacterHit characterHit; + + switch (run) + { + case ShapedTextCharacters shapedRun: + { + characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _); + + var offset = Math.Max(0, currentPosition - shapedRun.Text.Start); + + if (!shapedRun.GlyphRun.IsLeftToRight) { - if (distance < currentRun.Size.Width / 2) - { - characterHit = new CharacterHit(currentPosition); - } - else - { - characterHit = new CharacterHit(currentPosition, currentRun.TextSourceLength); - } - break; + offset = Math.Max(0, offset - shapedRun.Text.End); } - } - if (distance <= currentRun.Size.Width) - { - break; - } + characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength); - distance -= currentRun.Size.Width; - currentPosition += currentRun.TextSourceLength; + break; + } + default: + { + if (distance < run.Size.Width / 2) + { + characterHit = new CharacterHit(currentPosition); + } + else + { + characterHit = new CharacterHit(currentPosition, run.TextSourceLength); + } + break; + } } return characterHit; @@ -226,136 +242,122 @@ namespace Avalonia.Media.TextFormatting /// public override double GetDistanceFromCharacterHit(CharacterHit characterHit) { - var isTrailingHit = characterHit.TrailingLength > 0; + var flowDirection = _paragraphProperties.FlowDirection; var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - var currentDistance = Start; var currentPosition = FirstTextSourceIndex; var remainingLength = characterIndex - FirstTextSourceIndex; - GlyphRun? lastRun = null; + var currentDistance = Start; - for (var index = 0; index < _textRuns.Count; index++) + if (flowDirection == FlowDirection.LeftToRight) + { + for (var index = 0; index < _textRuns.Count; index++) + { + var currentRun = _textRuns[index]; + + if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength, + flowDirection, out var distance, out _)) + { + return currentDistance + distance; + } + + //No hit hit found so we add the full width + currentDistance += currentRun.Size.Width; + currentPosition += currentRun.TextSourceLength; + remainingLength -= currentRun.TextSourceLength; + } + } + else { - var textRun = _textRuns[index]; + currentDistance += WidthIncludingTrailingWhitespace; - switch (textRun) + for (var index = _textRuns.Count - 1; index >= 0; index--) { - case ShapedTextCharacters shapedTextCharacters: - { - var currentRun = shapedTextCharacters.GlyphRun; + var currentRun = _textRuns[index]; - if (lastRun != null) - { - if (!lastRun.IsLeftToRight && currentRun.IsLeftToRight && - currentRun.Characters.Start == characterHit.FirstCharacterIndex && - characterHit.TrailingLength == 0) - { - return currentDistance; - } - } + if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength, + flowDirection, out var distance, out var currentGlyphRun)) + { + if (currentGlyphRun != null) + { + distance = currentGlyphRun.Size.Width - distance; + } - //Look for a hit in within the current run - if (currentPosition + remainingLength <= currentPosition + textRun.Text.Length) - { - characterHit = new CharacterHit(textRun.Text.Start + remainingLength); + return currentDistance - distance; + } - var distance = currentRun.GetDistanceFromCharacterHit(characterHit); + //No hit hit found so we add the full width + currentDistance -= currentRun.Size.Width; + currentPosition += currentRun.TextSourceLength; + remainingLength -= currentRun.TextSourceLength; + } + } - return currentDistance + distance; - } + return currentDistance; + } - //Look at the left and right edge of the current run - if (currentRun.IsLeftToRight) - { - if (_resolvedFlowDirection == FlowDirection.LeftToRight && (lastRun == null || lastRun.IsLeftToRight)) - { - if (characterIndex <= currentPosition) - { - return currentDistance; - } - } - else - { - if (characterIndex == currentPosition) - { - return currentDistance; - } - } + private static bool TryGetDistanceFromCharacterHit( + DrawableTextRun currentRun, + CharacterHit characterHit, + int currentPosition, + int remainingLength, + FlowDirection flowDirection, + out double distance, + out GlyphRun? currentGlyphRun) + { + var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength; + var isTrailingHit = characterHit.TrailingLength > 0; - if (characterIndex == currentPosition + textRun.Text.Length && isTrailingHit) - { - return currentDistance + currentRun.Size.Width; - } - } - else - { - if (characterIndex == currentPosition) - { - return currentDistance + currentRun.Size.Width; - } + distance = 0; + currentGlyphRun = null; - var nextRun = index + 1 < _textRuns.Count ? - _textRuns[index + 1] as ShapedTextCharacters : - null; + switch (currentRun) + { + case ShapedTextCharacters shapedTextCharacters: + { + currentGlyphRun = shapedTextCharacters.GlyphRun; - if (nextRun != null) - { - if (nextRun.ShapedBuffer.IsLeftToRight) - { - if (characterIndex == currentPosition + textRun.Text.Length) - { - return currentDistance; - } - } - else - { - if (currentPosition + nextRun.Text.Length == characterIndex) - { - return currentDistance; - } - } - } - else - { - if (characterIndex > currentPosition + textRun.Text.Length) - { - return currentDistance; - } - } - } + if (currentPosition + remainingLength <= currentPosition + currentRun.Text.Length) + { + characterHit = new CharacterHit(currentRun.Text.Start + remainingLength); - lastRun = currentRun; + distance = currentGlyphRun.GetDistanceFromCharacterHit(characterHit); - break; + return true; } - default: + + if (currentPosition + remainingLength == currentPosition + currentRun.Text.Length && isTrailingHit) { - if (characterIndex == currentPosition) + if (currentGlyphRun.IsLeftToRight || flowDirection == FlowDirection.RightToLeft) { - return currentDistance; + distance = currentGlyphRun.Size.Width; } - if (characterIndex == currentPosition + textRun.TextSourceLength) - { - return currentDistance + textRun.Size.Width; - } + return true; + } - break; + break; + } + default: + { + if (characterIndex == currentPosition) + { + return true; } - } - //No hit hit found so we add the full width - currentDistance += textRun.Size.Width; - currentPosition += textRun.TextSourceLength; - remainingLength -= textRun.TextSourceLength; + if (characterIndex == currentPosition + currentRun.TextSourceLength) + { + distance = currentRun.Size.Width; - if (remainingLength <= 0) - { - break; - } + return true; + + } + + break; + } } - return currentDistance; + return false; } /// @@ -460,20 +462,33 @@ namespace Avalonia.Media.TextFormatting var startIndex = currentRun.Text.Start + offset; - var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit( - currentShapedRun.ShapedBuffer.IsLeftToRight ? - new CharacterHit(startIndex + remainingLength) : - new CharacterHit(startIndex)); + double startOffset; + double endOffset; - endX += endOffset; + if (currentShapedRun.ShapedBuffer.IsLeftToRight) + { + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + } + else + { + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); - var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit( - currentShapedRun.ShapedBuffer.IsLeftToRight ? - new CharacterHit(startIndex) : - new CharacterHit(startIndex + remainingLength)); + if (currentPosition < startIndex) + { + startOffset = endOffset; + } + else + { + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + } + } startX += startOffset; + endX += endOffset; + var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); @@ -504,47 +519,40 @@ namespace Avalonia.Media.TextFormatting } //Lines that only contain a linebreak need to be covered here - if(characterLength == 0) + if (characterLength == 0) { characterLength = NewLineLength; } - var runwidth = endX - startX; - var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runwidth, Height), currentPosition, characterLength, currentRun); + var runWidth = endX - startX; + var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); - if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) + if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0) { - currentRect = currentRect.WithWidth(currentWidth + runwidth); + if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) + { + currentRect = currentRect.WithWidth(currentWidth + runWidth); - var textBounds = result[result.Count - 1]; + var textBounds = result[result.Count - 1]; - textBounds.Rectangle = currentRect; + textBounds.Rectangle = currentRect; - textBounds.TextRunBounds.Add(currentRunBounds); - } - else - { - currentRect = currentRunBounds.Rectangle; + textBounds.TextRunBounds.Add(currentRunBounds); + } + else + { + currentRect = currentRunBounds.Rectangle; - result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); + result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); + } } - currentWidth += runwidth; + currentWidth += runWidth; currentPosition += characterLength; - if (currentDirection == FlowDirection.LeftToRight) - { - if (currentPosition > characterIndex) - { - break; - } - } - else + if (currentPosition > characterIndex) { - if (currentPosition <= firstTextSourceIndex) - { - break; - } + break; } startX = endX; @@ -571,7 +579,7 @@ namespace Avalonia.Media.TextFormatting var currentPosition = FirstTextSourceIndex; var remainingLength = textLength; - var startX = Start + WidthIncludingTrailingWhitespace; + var startX = WidthIncludingTrailingWhitespace; double currentWidth = 0; var currentRect = Rect.Empty; @@ -582,7 +590,7 @@ namespace Avalonia.Media.TextFormatting continue; } - if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex) + if (currentPosition + currentRun.TextSourceLength < firstTextSourceIndex) { startX -= currentRun.Size.Width; @@ -601,20 +609,31 @@ namespace Avalonia.Media.TextFormatting currentPosition += offset; var startIndex = currentRun.Text.Start + offset; + double startOffset; + double endOffset; - var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit( - currentShapedRun.ShapedBuffer.IsLeftToRight ? - new CharacterHit(startIndex + remainingLength) : - new CharacterHit(startIndex)); + if (currentShapedRun.ShapedBuffer.IsLeftToRight) + { + if (currentPosition < startIndex) + { + startOffset = endOffset = 0; + } + else + { + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); - endX += endOffset - currentShapedRun.Size.Width; + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); + } + } + else + { + endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex)); - var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit( - currentShapedRun.ShapedBuffer.IsLeftToRight ? - new CharacterHit(startIndex) : - new CharacterHit(startIndex + remainingLength)); + startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength)); + } - startX += startOffset - currentShapedRun.Size.Width; + startX -= currentRun.Size.Width - startOffset; + endX -= currentRun.Size.Width - endOffset; var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _); var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _); @@ -652,41 +671,35 @@ namespace Avalonia.Media.TextFormatting } var runWidth = endX - startX; - var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); - if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX)) + var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun); + + if(!MathUtilities.IsZero(runWidth) || NewLineLength > 0) { - currentRect = currentRect.WithWidth(currentWidth + runWidth); + if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX)) + { + currentRect = currentRect.WithWidth(currentWidth + runWidth); - var textBounds = result[result.Count - 1]; + var textBounds = result[result.Count - 1]; - textBounds.Rectangle = currentRect; + textBounds.Rectangle = currentRect; - textBounds.TextRunBounds.Add(currentRunBounds); - } - else - { - currentRect = currentRunBounds.Rectangle; + textBounds.TextRunBounds.Add(currentRunBounds); + } + else + { + currentRect = currentRunBounds.Rectangle; - result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); - } + result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds })); + } + } currentWidth += runWidth; currentPosition += characterLength; - if (currentDirection == FlowDirection.LeftToRight) - { - if (currentPosition > characterIndex) - { - break; - } - } - else + if (currentPosition > characterIndex) { - if (currentPosition <= firstTextSourceIndex) - { - break; - } + break; } lastDirection = currentDirection; @@ -698,6 +711,8 @@ namespace Avalonia.Media.TextFormatting } } + result.Reverse(); + return result; } @@ -1302,8 +1317,14 @@ namespace Avalonia.Media.TextFormatting switch (textAlignment) { case TextAlignment.Center: - return Math.Max(0, (_paragraphWidth - width) / 2); + var start = (_paragraphWidth - width) / 2; + + if(paragraphFlowDirection == FlowDirection.RightToLeft) + { + start -= (widthIncludingTrailingWhitespace - width); + } + return Math.Max(0, start); case TextAlignment.Right: return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace); diff --git a/src/Avalonia.Controls/Documents/LineBreak.cs b/src/Avalonia.Controls/Documents/LineBreak.cs index aeb81f7313..108a38d86b 100644 --- a/src/Avalonia.Controls/Documents/LineBreak.cs +++ b/src/Avalonia.Controls/Documents/LineBreak.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Text; -using Avalonia.LogicalTree; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; @@ -22,7 +21,13 @@ namespace Avalonia.Controls.Documents internal override void BuildTextRun(IList textRuns) { - textRuns.Add(new TextEndOfLine()); + var text = Environment.NewLine.AsMemory(); + + var textRunProperties = CreateTextRunProperties(); + + var textCharacters = new TextCharacters(text, textRunProperties); + + textRuns.Add(textCharacters); } internal override void AppendText(StringBuilder stringBuilder) diff --git a/src/Avalonia.Controls/RichTextBlock.cs b/src/Avalonia.Controls/RichTextBlock.cs index 9b5afaef5d..0c8b1d125d 100644 --- a/src/Avalonia.Controls/RichTextBlock.cs +++ b/src/Avalonia.Controls/RichTextBlock.cs @@ -41,9 +41,6 @@ namespace Avalonia.Controls public static readonly StyledProperty SelectionBrushProperty = AvaloniaProperty.Register(nameof(SelectionBrush), Brushes.Blue); - public static readonly StyledProperty SelectionForegroundBrushProperty = - AvaloniaProperty.Register(nameof(SelectionForegroundBrush)); - /// /// Defines the property. /// @@ -63,12 +60,13 @@ namespace Avalonia.Controls private bool _canCopy; private int _selectionStart; private int _selectionEnd; + private int _wordSelectionStart = -1; static RichTextBlock() { FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true); - AffectsRender(SelectionStartProperty, SelectionEndProperty, SelectionForegroundBrushProperty, SelectionBrushProperty); + AffectsRender(SelectionStartProperty, SelectionEndProperty, SelectionBrushProperty, IsTextSelectionEnabledProperty); } public RichTextBlock() @@ -89,15 +87,6 @@ namespace Avalonia.Controls set => SetValue(SelectionBrushProperty, value); } - /// - /// Gets or sets a value that defines the brush used for selected text. - /// - public IBrush? SelectionForegroundBrush - { - get => GetValue(SelectionForegroundBrushProperty); - set => SetValue(SelectionForegroundBrushProperty, value); - } - /// /// Gets or sets a character index for the beginning of the current selection. /// @@ -200,7 +189,7 @@ namespace Avalonia.Controls } } - public override void Render(DrawingContext context) + protected override void RenderTextLayout(DrawingContext context, Point origin) { var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; @@ -215,13 +204,16 @@ namespace Avalonia.Controls var rects = TextLayout.HitTestTextRange(start, length); - foreach (var rect in rects) + using (context.PushPostTransform(Matrix.CreateTranslation(origin))) { - context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1)); + foreach (var rect in rects) + { + context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1)); + } } } - base.Render(context); + base.RenderTextLayout(context, origin); } /// @@ -297,8 +289,9 @@ namespace Avalonia.Controls /// A object. protected override TextLayout CreateTextLayout(string? text) { + var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); var defaultProperties = new GenericTextRunProperties( - new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), + typeface, FontSize, TextDecorations, Foreground); @@ -345,6 +338,8 @@ namespace Avalonia.Controls protected override void OnKeyDown(KeyEventArgs e) { + base.OnKeyDown(e); + var handled = false; var modifiers = e.KeyModifiers; var keymap = AvaloniaLocator.Current.GetRequiredService(); @@ -363,6 +358,8 @@ namespace Avalonia.Controls protected override void OnPointerPressed(PointerPressedEventArgs e) { + base.OnPointerPressed(e); + if (!IsTextSelectionEnabled) { return; @@ -373,7 +370,9 @@ namespace Avalonia.Controls if (text != null && clickInfo.Properties.IsLeftButtonPressed) { - var point = e.GetPosition(this); + var padding = Padding; + + var point = e.GetPosition(this) - new Point(padding.Left, padding.Top); var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); @@ -382,8 +381,6 @@ namespace Avalonia.Controls var hit = TextLayout.HitTestPoint(point); var index = hit.TextPosition; - SelectionStart = SelectionEnd = index; - #pragma warning disable CS0618 // Type or member is obsolete switch (e.ClickCount) #pragma warning restore CS0618 // Type or member is obsolete @@ -391,12 +388,34 @@ namespace Avalonia.Controls case 1: if (clickToSelect) { - SelectionStart = Math.Min(oldIndex, index); - SelectionEnd = Math.Max(oldIndex, index); + if (_wordSelectionStart >= 0) + { + var previousWord = StringUtils.PreviousWord(text, index); + + if (index > _wordSelectionStart) + { + SelectionEnd = StringUtils.NextWord(text, index); + } + + if (index < _wordSelectionStart || previousWord == _wordSelectionStart) + { + SelectionStart = previousWord; + } + } + else + { + SelectionStart = Math.Min(oldIndex, index); + SelectionEnd = Math.Max(oldIndex, index); + } } else { - SelectionStart = SelectionEnd = index; + if (_wordSelectionStart == -1 || index < SelectionStart || index > SelectionEnd) + { + SelectionStart = SelectionEnd = index; + + _wordSelectionStart = -1; + } } break; @@ -406,9 +425,13 @@ namespace Avalonia.Controls SelectionStart = StringUtils.PreviousWord(text, index); } + _wordSelectionStart = SelectionStart; + SelectionEnd = StringUtils.NextWord(text, index); break; case 3: + _wordSelectionStart = -1; + SelectAll(); break; } @@ -420,6 +443,8 @@ namespace Avalonia.Controls protected override void OnPointerMoved(PointerEventArgs e) { + base.OnPointerMoved(e); + if (!IsTextSelectionEnabled) { return; @@ -428,20 +453,49 @@ namespace Avalonia.Controls // selection should not change during pointer move if the user right clicks if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - var point = e.GetPosition(this); + var text = Text; + var padding = Padding; + + var point = e.GetPosition(this) - new Point(padding.Left, padding.Top); point = new Point( - MathUtilities.Clamp(point.X, 0, Math.Max(Bounds.Width - 1, 0)), - MathUtilities.Clamp(point.Y, 0, Math.Max(Bounds.Height - 1, 0))); + MathUtilities.Clamp(point.X, 0, Math.Max(TextLayout.Bounds.Width, 0)), + MathUtilities.Clamp(point.Y, 0, Math.Max(TextLayout.Bounds.Width, 0))); var hit = TextLayout.HitTestPoint(point); + var textPosition = hit.TextPosition; + + if (text != null && _wordSelectionStart >= 0) + { + var distance = textPosition - _wordSelectionStart; + + if (distance <= 0) + { + SelectionStart = StringUtils.PreviousWord(text, textPosition); + } + + if (distance >= 0) + { + if (SelectionStart != _wordSelectionStart) + { + SelectionStart = _wordSelectionStart; + } + + SelectionEnd = StringUtils.NextWord(text, textPosition); + } + } + else + { + SelectionEnd = textPosition; + } - SelectionEnd = hit.TextPosition; } } protected override void OnPointerReleased(PointerReleasedEventArgs e) { + base.OnPointerReleased(e); + if (!IsTextSelectionEnabled) { return; @@ -454,7 +508,9 @@ namespace Avalonia.Controls if (e.InitialPressMouseButton == MouseButton.Right) { - var point = e.GetPosition(this); + var padding = Padding; + + var point = e.GetPosition(this) - new Point(padding.Left, padding.Top); var hit = TextLayout.HitTestPoint(point); @@ -487,11 +543,6 @@ namespace Avalonia.Controls InvalidateTextLayout(); break; } - case nameof(TextProperty): - { - InvalidateTextLayout(); - break; - } } } diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 80edaf4f26..99c8068b3d 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -505,7 +505,12 @@ namespace Avalonia.Controls } } - TextLayout.Draw(context, new Point(padding.Left, top)); + RenderTextLayout(context, new Point(padding.Left, top)); + } + + protected virtual void RenderTextLayout(DrawingContext context, Point origin) + { + TextLayout.Draw(context, origin); } void IAddChild.AddChild(string text) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 9531f719b9..1b268db2f7 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -53,7 +53,7 @@ namespace Avalonia.Controls public static readonly StyledProperty PasswordCharProperty = AvaloniaProperty.Register(nameof(PasswordChar)); - + public static readonly StyledProperty SelectionBrushProperty = AvaloniaProperty.Register(nameof(SelectionBrush)); @@ -80,7 +80,7 @@ namespace Avalonia.Controls public static readonly StyledProperty MaxLinesProperty = AvaloniaProperty.Register(nameof(MaxLines), defaultValue: 0); - + public static readonly DirectProperty TextProperty = TextBlock.TextProperty.AddOwnerWithDataValidation( o => o.Text, @@ -105,7 +105,7 @@ namespace Avalonia.Controls public static readonly StyledProperty TextWrappingProperty = TextBlock.TextWrappingProperty.AddOwner(); - + /// /// Defines see property. /// @@ -202,6 +202,7 @@ namespace Avalonia.Controls private string _newLine = Environment.NewLine; private static readonly string[] invalidCharacters = new String[1] { "\u007f" }; + private int _wordSelectionStart = -1; private int _selectedTextChangesMadeSinceLastUndoSnapshot; private bool _hasDoneSnapshotOnce; private const int _maxCharsBeforeUndoSnapshot = 7; @@ -275,7 +276,7 @@ namespace Avalonia.Controls get => GetValue(IsReadOnlyProperty); set => SetValue(IsReadOnlyProperty, value); } - + public char PasswordChar { get => GetValue(PasswordCharProperty); @@ -307,7 +308,7 @@ namespace Avalonia.Controls { value = CoerceCaretIndex(value); var changed = SetAndRaise(SelectionStartProperty, ref _selectionStart, value); - + if (changed) { UpdateCommandStates(); @@ -327,12 +328,12 @@ namespace Avalonia.Controls { value = CoerceCaretIndex(value); var changed = SetAndRaise(SelectionEndProperty, ref _selectionEnd, value); - + if (changed) { UpdateCommandStates(); } - + if (SelectionStart == value && CaretIndex != value) { CaretIndex = value; @@ -351,7 +352,7 @@ namespace Avalonia.Controls get => GetValue(MaxLinesProperty); set => SetValue(MaxLinesProperty, value); } - + /// /// Gets or sets the line height. /// @@ -370,7 +371,7 @@ namespace Avalonia.Controls var caretIndex = CaretIndex; var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; - + CaretIndex = CoerceCaretIndex(caretIndex, value); SelectionStart = CoerceCaretIndex(selectionStart, value); SelectionEnd = CoerceCaretIndex(selectionEnd, value); @@ -567,7 +568,7 @@ namespace Avalonia.Controls _presenter = e.NameScope.Get("PART_TextPresenter"); _imClient.SetPresenter(_presenter, this); - + if (IsFocused) { _presenter?.ShowCaret(); @@ -577,7 +578,7 @@ namespace Avalonia.Controls protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); - + if (IsFocused) { _presenter?.ShowCaret(); @@ -587,7 +588,7 @@ namespace Avalonia.Controls protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - + _imClient.SetPresenter(null, null); } @@ -637,7 +638,7 @@ namespace Avalonia.Controls } UpdateCommandStates(); - + _imClient.SetPresenter(_presenter, this); _presenter?.ShowCaret(); @@ -657,7 +658,7 @@ namespace Avalonia.Controls UpdateCommandStates(); _presenter?.HideCaret(); - + _imClient.SetPresenter(null, null); } @@ -700,14 +701,14 @@ namespace Avalonia.Controls if (grapheme.FirstCodepoint.IsBreakChar) { - if(lineCount + 1 > MaxLines) + if (lineCount + 1 > MaxLines) { break; } else { lineCount++; - } + } } length += grapheme.Text.Length; @@ -736,7 +737,7 @@ namespace Avalonia.Controls text = Text ?? string.Empty; SetTextInternal(text.Substring(0, caretIndex) + input + text.Substring(caretIndex)); ClearSelection(); - + if (IsUndoEnabled) { _undoRedoHelper.DiscardRedo(); @@ -746,7 +747,7 @@ namespace Avalonia.Controls { RaisePropertyChanged(TextProperty, oldText, _text); } - + CaretIndex = caretIndex + input.Length; } } @@ -828,7 +829,7 @@ namespace Avalonia.Controls { return; } - + var text = Text ?? string.Empty; var caretIndex = CaretIndex; var movement = false; @@ -985,87 +986,87 @@ namespace Avalonia.Controls break; case Key.Up: - { - selection = DetectSelection(); - - _presenter.MoveCaretVertical(LogicalDirection.Backward); - - if (caretIndex != _presenter.CaretIndex) { - movement = true; - } + selection = DetectSelection(); - if (selection) - { - SelectionEnd = _presenter.CaretIndex; - } - else - { - CaretIndex = _presenter.CaretIndex; + _presenter.MoveCaretVertical(LogicalDirection.Backward); + + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } + + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + else + { + CaretIndex = _presenter.CaretIndex; + } + + break; } - - break; - } case Key.Down: - { - selection = DetectSelection(); - - _presenter.MoveCaretVertical(); - - if (caretIndex != _presenter.CaretIndex) - { - movement = true; - } - - if (selection) - { - SelectionEnd = _presenter.CaretIndex; - } - else { - CaretIndex = _presenter.CaretIndex; + selection = DetectSelection(); + + _presenter.MoveCaretVertical(); + + if (caretIndex != _presenter.CaretIndex) + { + movement = true; + } + + if (selection) + { + SelectionEnd = _presenter.CaretIndex; + } + else + { + CaretIndex = _presenter.CaretIndex; + } + + break; } - - break; - } case Key.Back: - { - SnapshotUndoRedo(); - - if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { - SetSelectionForControlBackspace(); - } - - if (!DeleteSelection()) - { - var characterHit = _presenter.GetNextCharacterHit(LogicalDirection.Backward); + SnapshotUndoRedo(); - var backspacePosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) + { + SetSelectionForControlBackspace(); + } - if (caretIndex != backspacePosition) + if (!DeleteSelection()) { - var start = Math.Min(backspacePosition, caretIndex); - var end = Math.Max(backspacePosition, caretIndex); + var characterHit = _presenter.GetNextCharacterHit(LogicalDirection.Backward); - var length = end - start; + var backspacePosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - var editedText = text.Substring(0, start) + text.Substring(Math.Min(end, text.Length)); + if (caretIndex != backspacePosition) + { + var start = Math.Min(backspacePosition, caretIndex); + var end = Math.Max(backspacePosition, caretIndex); - SetTextInternal(editedText); + var length = end - start; - CaretIndex = start; - } - } - - SnapshotUndoRedo(); + var editedText = text.Substring(0, start) + text.Substring(Math.Min(end, text.Length)); - handled = true; - break; - } + SetTextInternal(editedText); + + CaretIndex = start; + } + } + + SnapshotUndoRedo(); + + handled = true; + break; + } case Key.Delete: SnapshotUndoRedo(); - + if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlDelete(); @@ -1077,7 +1078,7 @@ namespace Avalonia.Controls var nextPosition = characterHit.FirstCharacterIndex + characterHit.TrailingLength; - if(nextPosition != caretIndex) + if (nextPosition != caretIndex) { var start = Math.Min(nextPosition, caretIndex); var end = Math.Max(nextPosition, caretIndex); @@ -1144,7 +1145,7 @@ namespace Avalonia.Controls { return; } - + var text = Text; var clickInfo = e.GetCurrentPoint(this); @@ -1170,24 +1171,50 @@ namespace Avalonia.Controls case 1: if (clickToSelect) { - SelectionStart = Math.Min(oldIndex, index); - SelectionEnd = Math.Max(oldIndex, index); + if (_wordSelectionStart >= 0) + { + var previousWord = StringUtils.PreviousWord(text, index); + + if (index > _wordSelectionStart) + { + SelectionEnd = StringUtils.NextWord(text, index); + } + + if (index < _wordSelectionStart || previousWord == _wordSelectionStart) + { + SelectionStart = previousWord; + } + } + else + { + SelectionStart = Math.Min(oldIndex, index); + SelectionEnd = Math.Max(oldIndex, index); + } } else { - SelectionStart = SelectionEnd = index; + if(_wordSelectionStart == -1 || index < SelectionStart || index > SelectionEnd) + { + SelectionStart = SelectionEnd = index; + _wordSelectionStart = -1; + } } break; - case 2: + case 2: + if (!StringUtils.IsStartOfWord(text, index)) { SelectionStart = StringUtils.PreviousWord(text, index); } + _wordSelectionStart = SelectionStart; + SelectionEnd = StringUtils.NextWord(text, index); break; case 3: + _wordSelectionStart = -1; + SelectAll(); break; } @@ -1203,7 +1230,7 @@ namespace Avalonia.Controls { return; } - + // selection should not change during pointer move if the user right clicks if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { @@ -1215,7 +1242,32 @@ namespace Avalonia.Controls _presenter.MoveCaretToPoint(point); - SelectionEnd = _presenter.CaretIndex; + var caretIndex = _presenter.CaretIndex; + var text = Text; + + if (text != null && _wordSelectionStart >= 0) + { + var distance = caretIndex - _wordSelectionStart; + + if (distance <= 0) + { + SelectionStart = StringUtils.PreviousWord(text, caretIndex); + } + + if (distance >= 0) + { + if(SelectionStart != _wordSelectionStart) + { + SelectionStart = _wordSelectionStart; + } + + SelectionEnd = StringUtils.NextWord(text, caretIndex); + } + } + else + { + SelectionEnd = _presenter.CaretIndex; + } } } @@ -1234,9 +1286,9 @@ namespace Avalonia.Controls if (e.InitialPressMouseButton == MouseButton.Right) { var point = e.GetPosition(_presenter); - + _presenter.MoveCaretToPoint(point); - + var caretIndex = _presenter.CaretIndex; // see if mouse clicked inside current selection @@ -1250,7 +1302,7 @@ namespace Avalonia.Controls CaretIndex = SelectionEnd = SelectionStart = caretIndex; } } - + e.Pointer.Capture(null); } @@ -1309,7 +1361,7 @@ namespace Avalonia.Controls { return; } - + var text = Text ?? string.Empty; var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; @@ -1319,11 +1371,11 @@ namespace Avalonia.Controls if (isSelecting) { _presenter.MoveCaretToTextPosition(selectionEnd); - + _presenter.MoveCaretHorizontal(direction > 0 ? LogicalDirection.Forward : LogicalDirection.Backward); - + SelectionEnd = _presenter.CaretIndex; } else @@ -1347,7 +1399,7 @@ namespace Avalonia.Controls else { int offset; - + if (direction > 0) { offset = StringUtils.NextWord(text, selectionEnd) - selectionEnd; @@ -1356,7 +1408,7 @@ namespace Avalonia.Controls { offset = StringUtils.PreviousWord(text, selectionEnd) - selectionEnd; } - + SelectionEnd += offset; _presenter.MoveCaretToTextPosition(SelectionEnd); @@ -1378,7 +1430,7 @@ namespace Avalonia.Controls { return; } - + var caretIndex = CaretIndex; if (document) @@ -1401,7 +1453,7 @@ namespace Avalonia.Controls { return; } - + var text = Text ?? string.Empty; var caretIndex = CaretIndex; @@ -1432,8 +1484,9 @@ namespace Avalonia.Controls private bool DeleteSelection(bool raiseTextChanged = true) { - if (IsReadOnly) return true; - + if (IsReadOnly) + return true; + var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; @@ -1444,40 +1497,40 @@ namespace Avalonia.Controls var text = Text!; SetTextInternal(text.Substring(0, start) + text.Substring(end), raiseTextChanged); - + _presenter?.MoveCaretToTextPosition(start); - - CaretIndex= start; - + + CaretIndex = start; + ClearSelection(); - + return true; } - + CaretIndex = SelectionStart; - + return false; } private string GetSelection() { var text = Text; - + if (string.IsNullOrEmpty(text)) { return ""; } - + var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; var start = Math.Min(selectionStart, selectionEnd); var end = Math.Max(selectionStart, selectionEnd); - + if (start == end || (Text?.Length ?? 0) < end) { return ""; } - + return text.Substring(start, end - start); } @@ -1496,7 +1549,7 @@ namespace Avalonia.Controls private void SetSelectionForControlBackspace() { var selectionStart = CaretIndex; - + MoveHorizontal(-1, true, false); SelectionStart = selectionStart; @@ -1508,9 +1561,9 @@ namespace Avalonia.Controls { return; } - + SelectionStart = CaretIndex; - + MoveHorizontal(1, true, true); if (SelectionEnd < _text.Length && _text[SelectionEnd] == ' ') diff --git a/src/Avalonia.Themes.Default/Controls/RichTextBlock.xaml b/src/Avalonia.Themes.Default/Controls/RichTextBlock.xaml new file mode 100644 index 0000000000..d7bf6e5cf9 --- /dev/null +++ b/src/Avalonia.Themes.Default/Controls/RichTextBlock.xaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 468b723f5b..f266402aef 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -66,4 +66,5 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index bc2352d5d0..0499495239 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -68,6 +68,7 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml b/src/Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml new file mode 100644 index 0000000000..75af2efcb1 --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/RichTextBlock.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/tests/Avalonia.RenderTests/Assets/NotoKufiArabic-Regular.ttf b/tests/Avalonia.RenderTests/Assets/NotoKufiArabic-Regular.ttf new file mode 100644 index 0000000000..6d2ad86f94 Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/NotoKufiArabic-Regular.ttf differ diff --git a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs index 04b408a666..13cc14b03e 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/CustomFontManagerImpl.cs @@ -15,6 +15,8 @@ namespace Avalonia.Skia.UnitTests.Media private readonly Typeface _defaultTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); + private readonly Typeface _arabicTypeface = + new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Kufi Arabic"); private readonly Typeface _italicTypeface = new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Sans", FontStyle.Italic); private readonly Typeface _emojiTypeface = @@ -22,7 +24,7 @@ namespace Avalonia.Skia.UnitTests.Media public CustomFontManagerImpl() { - _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _defaultTypeface }; + _customTypefaces = new[] { _emojiTypeface, _italicTypeface, _arabicTypeface, _defaultTypeface }; _defaultFamilyName = _defaultTypeface.FontFamily.FamilyNames.PrimaryFamilyName; } diff --git a/tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs b/tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs index df286d709e..649e1fbf3d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/FontManagerImplTests.cs @@ -33,15 +33,11 @@ namespace Avalonia.Skia.UnitTests.Media { var fontManager = new FontManagerImpl(); - //we need to have a valid font name different from the default one - string fontName = fontManager.GetInstalledFontFamilyNames().First(); - var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( - new Typeface(new FontFamily($"A, B, {fontName}"), weight: FontWeight.Bold)); + new Typeface(new FontFamily($"A, B, Arial"), weight: FontWeight.Bold)); var skTypeface = glyphTypeface.Typeface; - - Assert.Equal(fontName, skTypeface.FamilyName); + Assert.True(skTypeface.FontWeight >= 600); } diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs index 7e1103d624..7d33f094fa 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLayoutTests.cs @@ -154,7 +154,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting { j += inner.Current.Text.Length; - if(j + i > text.Length) + if (j + i > text.Length) { break; } @@ -738,7 +738,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textLine = layout.TextLines[0]; var start = textLine.GetDistanceFromCharacterHit(new CharacterHit(5, 1)); - + var end = textLine.GetDistanceFromCharacterHit(new CharacterHit(6, 1)); var rects = layout.HitTestTextRange(0, 7).ToArray(); @@ -746,7 +746,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(1, rects.Length); var expected = rects[0]; - + Assert.Equal(expected.Left, start); Assert.Equal(expected.Right, end); } @@ -818,11 +818,11 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var expected = text.Substring(textLine.FirstTextSourceIndex, textLine.Length); Assert.Equal(expected, actual); - } + } } } } - + [Fact] public void Should_Layout_Empty_String() { @@ -833,11 +833,128 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Typeface.Default, 12, Brushes.Black); - + Assert.True(layout.Bounds.Height > 0); } } + [Fact] + public void Should_HitTestPoint_RightToLeft() + { + using (Start()) + { + var text = "אאא AAA"; + + var layout = new TextLayout( + text, + Typeface.Default, + 12, + Brushes.Black, + flowDirection: FlowDirection.RightToLeft); + + var firstRun = layout.TextLines[0].TextRuns[0] as ShapedTextCharacters; + + var hit = layout.HitTestPoint(new Point()); + + Assert.Equal(4, hit.TextPosition); + + var currentX = 0.0; + + for (var i = 0; i < firstRun.GlyphRun.GlyphClusters.Count; i++) + { + var cluster = firstRun.GlyphRun.GlyphClusters[i]; + var advance = firstRun.GlyphRun.GlyphAdvances[i]; + + hit = layout.HitTestPoint(new Point(currentX, 0)); + + Assert.Equal(cluster, hit.TextPosition); + + var hitRange = layout.HitTestTextRange(hit.TextPosition, 1); + + var distance = hitRange.First().Left; + + Assert.Equal(currentX, distance); + + currentX += advance; + } + + var secondRun = layout.TextLines[0].TextRuns[1] as ShapedTextCharacters; + + hit = layout.HitTestPoint(new Point(firstRun.Size.Width, 0)); + + Assert.Equal(7, hit.TextPosition); + + hit = layout.HitTestPoint(new Point(layout.TextLines[0].WidthIncludingTrailingWhitespace, 0)); + + Assert.Equal(0, hit.TextPosition); + + currentX = firstRun.Size.Width + 0.5; + + for (var i = 0; i < secondRun.GlyphRun.GlyphClusters.Count; i++) + { + var cluster = secondRun.GlyphRun.GlyphClusters[i]; + var advance = secondRun.GlyphRun.GlyphAdvances[i]; + + hit = layout.HitTestPoint(new Point(currentX, 0)); + + Assert.Equal(cluster, hit.CharacterHit.FirstCharacterIndex); + + var hitRange = layout.HitTestTextRange(hit.CharacterHit.FirstCharacterIndex, hit.CharacterHit.TrailingLength); + + var distance = hitRange.First().Left + 0.5; + + Assert.Equal(currentX, distance); + + currentX += advance; + } + } + } + + [Fact] + public void Should_Get_CharacterHit_From_Distance_RTL() + { + using (Start()) + { + var text = "أَبْجَدِيَّة عَرَبِيَّة"; + + var layout = new TextLayout( + text, + Typeface.Default, + 12, + Brushes.Black); + + var textLine = layout.TextLines[0]; + + var firstRun = (ShapedTextCharacters)textLine.TextRuns[0]; + + var firstCluster = firstRun.ShapedBuffer.GlyphClusters[0]; + + var characterHit = textLine.GetCharacterHitFromDistance(0); + + Assert.Equal(firstCluster, characterHit.FirstCharacterIndex); + + Assert.Equal(text.Length, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + + var distance = textLine.GetDistanceFromCharacterHit(characterHit); + + Assert.Equal(0, distance); + + distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex)); + + var firstAdvance = firstRun.ShapedBuffer.GlyphAdvances[0]; + + Assert.Equal(firstAdvance, distance, 5); + + var rect = layout.HitTestTextPosition(22); + + Assert.Equal(firstAdvance, rect.Left, 5); + + rect = layout.HitTestTextPosition(23); + + Assert.Equal(0, rect.Left, 5); + } + } + private static IDisposable Start() { var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface diff --git a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs index 58cb84c4a4..d744ede87d 100644 --- a/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs +++ b/tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextLineTests.cs @@ -867,28 +867,29 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting var textBounds = textLine.GetTextBounds(0, 4); - var firstRun = textLine.TextRuns[1] as ShapedTextCharacters; + var secondRun = textLine.TextRuns[1] as ShapedTextCharacters; Assert.Equal(1, textBounds.Count); - Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); + Assert.Equal(secondRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); textBounds = textLine.GetTextBounds(4, 3); - var secondRun = textLine.TextRuns[0] as ShapedTextCharacters; + var firstRun = textLine.TextRuns[0] as ShapedTextCharacters; Assert.Equal(1, textBounds.Count); Assert.Equal(3, textBounds[0].TextRunBounds.Sum(x=> x.Length)); - Assert.Equal(secondRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); + Assert.Equal(firstRun.Size.Width, textBounds.Sum(x => x.Rectangle.Width)); textBounds = textLine.GetTextBounds(0, 5); Assert.Equal(2, textBounds.Count); Assert.Equal(5, textBounds.Sum(x=> x.TextRunBounds.Sum(x => x.Length))); - Assert.Equal(firstRun.Size.Width, textBounds[0].Rectangle.Width); - Assert.Equal(7.201171875, textBounds[1].Rectangle.Width); - Assert.Equal(textLine.Start + 7.201171875, textBounds[1].Rectangle.Right); + Assert.Equal(secondRun.Size.Width, textBounds[1].Rectangle.Width); + Assert.Equal(7.201171875, textBounds[0].Rectangle.Width); + Assert.Equal(textLine.Start + 7.201171875, textBounds[0].Rectangle.Right); + Assert.Equal(textLine.Start + firstRun.Size.Width, textBounds[1].Rectangle.Left); textBounds = textLine.GetTextBounds(0, text.Length); @@ -896,7 +897,7 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting Assert.Equal(7, textBounds.Sum(x => x.TextRunBounds.Sum(x => x.Length))); Assert.Equal(textLine.WidthIncludingTrailingWhitespace, textBounds.Sum(x => x.Rectangle.Width)); } - } + } private class FixedRunsTextSource : ITextSource {