From bc7d4261b2c29889e0157ec0d8c265c95aaf8a8e Mon Sep 17 00:00:00 2001 From: Ramez Ragaa Date: Tue, 3 Oct 2023 18:59:10 +0300 Subject: [PATCH] feat(textbox): start skia-based TextBox implementation --- src/Uno.UI/FeatureConfiguration.cs | 6 + .../Xaml/Controls/TextBlock/TextBlock.skia.cs | 2 +- .../UI/Xaml/Controls/TextBox/TextBox.cs | 41 ++- .../UI/Xaml/Controls/TextBox/TextBox.skia.cs | 304 +++++++++++++++++- .../Xaml/Controls/TextBox/TextBoxView.skia.cs | 10 +- .../Xaml/Documents/InlineCollection.skia.cs | 257 ++++++++++++++- 6 files changed, 595 insertions(+), 25 deletions(-) diff --git a/src/Uno.UI/FeatureConfiguration.cs b/src/Uno.UI/FeatureConfiguration.cs index 054d93ddbb9e..05c8745436e0 100644 --- a/src/Uno.UI/FeatureConfiguration.cs +++ b/src/Uno.UI/FeatureConfiguration.cs @@ -503,6 +503,12 @@ public static class TextBox /// This feature is used to avoid screenshot comparisons false positives public static bool HideCaret { get; set; } + /// + /// Determines if a native (Gtk/Wpf) TextBox overlay should be used on the skia targets instead of the + /// Uno skia-based TextBox implementation. + /// + public static bool UseOverlayOnSkia { get; set; } = false; + #if __ANDROID__ /// /// The legacy prevents invalid input on hardware keyboard. diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs index 4a556c8ab7ed..f6a445a918b4 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs @@ -104,7 +104,7 @@ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs arg private Hyperlink? FindHyperlinkAt(Point point) { var padding = Padding; - var span = Inlines.GetRenderSegmentSpanAt(point - new Point(padding.Left, padding.Top), false); + var span = Inlines.GetRenderSegmentSpanAt(point - new Point(padding.Left, padding.Top), false)?.span; if (span == null) { diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.cs b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.cs index 8b0c7bc464bd..414a95043bee 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.cs @@ -22,6 +22,7 @@ using Uno.UI.Helpers; using Uno.UI.Xaml.Media; using Windows.ApplicationModel.DataTransfer; +using Uno.UI; #if HAS_UNO_WINUI using Microsoft.UI.Input; @@ -116,6 +117,10 @@ public TextBox() DefaultStyleKey = typeof(TextBox); SizeChanged += OnSizeChanged; + +#if __SKIA__ + _timer.Tick += TimerOnTick; +#endif } private protected override void OnLoaded() @@ -150,10 +155,15 @@ private protected override void OnLoaded() // When support for TemplateBinding for attached DPs was added, TextBox broke (test: TextBox_AutoGrow_Vertically_Wrapping_Test) because of // change in the values of these properties. The following code serves as a workaround to set the values to what they used to be // before the support for TemplateBinding for attached DPs. - scrollViewer.HorizontalScrollMode = ScrollMode.Enabled; // The template sets this to Auto - scrollViewer.VerticalScrollMode = ScrollMode.Enabled; // The template sets this to Auto - scrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; // The template sets this to Hidden - scrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; // The template sets this to Hidden +#if __SKIA__ + if (FeatureConfiguration.TextBox.UseOverlayOnSkia) +#endif + { + scrollViewer.HorizontalScrollMode = ScrollMode.Enabled; // The template sets this to Auto + scrollViewer.VerticalScrollMode = ScrollMode.Enabled; // The template sets this to Auto + scrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; // The template sets this to Hidden + scrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; // The template sets this to Hidden + } #endif } } @@ -896,6 +906,14 @@ private void OnFocusStateChanged(FocusState oldValue, FocusState newValue, bool } UpdateVisualState(); +#if __SKIA__ + if (!FeatureConfiguration.TextBox.UseOverlayOnSkia) + { + // this is needed so that we UpdateScrolling after the button appears. + UpdateLayout(); + UpdateScrolling(); + } +#endif } partial void OnFocusStateChangedPartial(FocusState focusState); @@ -946,8 +964,12 @@ protected override void OnPointerPressed(PointerRoutedEventArgs args) } args.Handled = true; + + OnPointerPressedNative(args); } + partial void OnPointerPressedNative(PointerRoutedEventArgs e); + /// protected override void OnPointerReleased(PointerRoutedEventArgs args) { @@ -971,7 +993,16 @@ protected override void OnTapped(TappedRoutedEventArgs e) partial void OnTappedPartial(); /// - protected override void OnKeyDown(KeyRoutedEventArgs args) + protected override void OnKeyDown(KeyRoutedEventArgs args) => OnKeyDownPartial(args); + + private partial void OnKeyDownPartial(KeyRoutedEventArgs args); + +#if !__SKIA__ + private partial void OnKeyDownPartial(KeyRoutedEventArgs args) => OnKeyDownInternal(args); + +#endif + + private void OnKeyDownInternal(KeyRoutedEventArgs args) { base.OnKeyDown(args); diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs index 58d74151959e..1958f0cc3145 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs @@ -1,10 +1,24 @@ -using Windows.UI.Xaml.Media; +using System; +using System.Collections.Generic; +using Windows.Foundation; +using Windows.System; +using Windows.UI.Xaml.Documents; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Uno.UI; namespace Windows.UI.Xaml.Controls; public partial class TextBox { private TextBoxView _textBoxView; + private (int start, int length) _selection; + private bool _selectionEndsAtTheStart; + private bool _showCaret = true; + private readonly DispatcherTimer _timer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(0.5) + }; internal TextBoxView TextBoxView => _textBoxView; @@ -38,24 +52,304 @@ private void UpdateTextBoxView() } } - partial void OnFocusStateChangedPartial(FocusState focusState) => TextBoxView?.OnFocusStateChanged(focusState); + partial void OnFocusStateChangedPartial(FocusState focusState) + { + if (FeatureConfiguration.TextBox.UseOverlayOnSkia) + { + TextBoxView?.OnFocusStateChanged(focusState); + } + else + { + if (focusState != FocusState.Unfocused) + { + _showCaret = true; + _timer.Start(); + } + else + { + _showCaret = false; + _timer.Stop(); + } + UpdateDisplaySelection(); + } + } partial void SelectPartial(int start, int length) { - TextBoxView?.Select(start, length); + _selectionEndsAtTheStart = false; + _selection = (start, length); + if (FeatureConfiguration.TextBox.UseOverlayOnSkia) + { + TextBoxView?.Select(start, length); + } + else + { + _timer.Stop(); + _showCaret = true; + _timer.Start(); + UpdateDisplaySelection(); + UpdateLayout(); + UpdateScrolling(); + } } partial void SelectAllPartial() => Select(0, Text.Length); public int SelectionStart { - get => TextBoxView?.GetSelectionStart() ?? 0; + get => FeatureConfiguration.TextBox.UseOverlayOnSkia ? TextBoxView?.GetSelectionStart() ?? 0 : _selection.start; set => Select(start: value, length: SelectionLength); } public int SelectionLength { - get => TextBoxView?.GetSelectionLength() ?? 0; + get => FeatureConfiguration.TextBox.UseOverlayOnSkia ? TextBoxView?.GetSelectionLength() ?? 0 : _selection.length; set => Select(SelectionStart, value); } + + internal void UpdateDisplaySelection() + { + if (TextBoxView?.DisplayBlock.Inlines is { } inlines) + { + inlines.Selection = (0, SelectionStart, 0, SelectionStart + SelectionLength); + inlines.RenderSelectionAndCaret = FocusState != FocusState.Unfocused; + var showCaret = _showCaret && !FeatureConfiguration.TextBox.HideCaret && !IsReadOnly && _selection.length == 0; + inlines.Caret = (!_selectionEndsAtTheStart, showCaret ? Colors.Black : Colors.Transparent); + TextBoxView?.DisplayBlock.InvalidateInlines(true); + } + } + + private void UpdateScrolling() + { + if (!FeatureConfiguration.TextBox.UseOverlayOnSkia && _contentElement is ScrollViewer sv) + { + var selectionEnd = _selectionEndsAtTheStart ? _selection.start : _selection.start + _selection.length; + + var horizontalOffset = sv.HorizontalOffset; + var verticalOffset = sv.VerticalOffset; + + var rect = TextBoxView.DisplayBlock.Inlines.GetRectForTextBlockIndex(selectionEnd); + + var newHorizontalOffset = horizontalOffset.AtMost(rect.Left).AtLeast(rect.Left - sv.ViewportWidth + rect.Height * InlineCollection.CaretThicknessAsRatioOfLineHeight); + var newVerticalOffset = verticalOffset.AtMost(rect.Top).AtLeast(rect.Top - sv.ViewportWidth); + + sv.ChangeView(newHorizontalOffset, newVerticalOffset, null); + } + } + + private partial void OnKeyDownPartial(KeyRoutedEventArgs args) + { + if (FeatureConfiguration.TextBox.UseOverlayOnSkia) + { + OnKeyDownInternal(args); + return; + } + + base.OnKeyDown(args); + + // Note: On windows only keys that are "moving the cursor" are handled + // AND ** only KeyDown ** is handled (not KeyUp) + + // move to possibly-negative selection length format + var (selectionStart, selectionLength) = _selectionEndsAtTheStart ? (_selection.start + _selection.length, -_selection.length) : (_selection.start, _selection.length); + + var text = Text; + var shift = args.KeyboardModifiers.HasFlag(VirtualKeyModifiers.Shift); + switch (args.Key) + { + case VirtualKey.Up: + if (shift) + { + selectionLength = -selectionStart; + } + else + { + selectionStart = Math.Min(selectionStart, selectionStart + selectionLength); + selectionLength = 0; + } + break; + case VirtualKey.Down: + if (selectionStart != text.Length || selectionLength != 0) + { + args.Handled = true; + if (shift) + { + selectionLength = text.Length - selectionStart; + } + else + { + selectionStart = text.Length; + } + } + break; + case VirtualKey.Left: + var moveOutLeft = !shift && selectionStart == 0 && selectionLength == 0 || shift && selectionStart + selectionLength == 0; + if (!moveOutLeft) + { + args.Handled = true; + + if (shift) + { + selectionLength -= 1; + } + else + { + if (selectionLength != 0) + { + selectionStart = Math.Min(selectionStart, selectionStart + selectionLength); + } + else + { + selectionStart -= 1; + } + selectionLength = 0; + } + } + break; + case VirtualKey.Right: + var moveOutRight = !shift && selectionStart == text.Length && selectionLength == 0 || shift && selectionStart + selectionLength == Text.Length; + if (!moveOutRight) + { + args.Handled = true; + + if (shift) + { + selectionLength += 1; + } + else + { + if (selectionLength != 0) + { + selectionStart = Math.Max(selectionStart, selectionStart + selectionLength); + } + else + { + selectionStart += 1; + } + selectionLength = 0; + } + } + break; + case VirtualKey.Home: + args.Handled = true; + if (shift) + { + selectionLength = -selectionStart; + } + else + { + selectionStart = 0; + selectionLength = 0; + } + break; + case VirtualKey.End: + args.Handled = true; + if (shift) + { + selectionLength = text.Length - selectionStart; + } + else + { + selectionStart = text.Length; + selectionLength = 0; + } + break; + case VirtualKey.Back when !IsReadOnly: + args.Handled = true; + if (selectionLength != 0) + { + var start = Math.Min(selectionStart, selectionStart + selectionLength); + var end = Math.Max(selectionStart, selectionStart + selectionLength); + text = text[..start] + text[end..]; + selectionLength = 0; + selectionStart = start; + } + else if (selectionStart != 0) + { + text = text[..(selectionStart - 1)] + text[selectionStart..]; + selectionStart -= 1; + } + break; + case VirtualKey.Delete when !IsReadOnly: + args.Handled = true; + if (selectionLength != 0) + { + var start = Math.Min(selectionStart, selectionStart + selectionLength); + var end = Math.Max(selectionStart, selectionStart + selectionLength); + text = text[..start] + text[end..]; + selectionLength = 0; + selectionStart = start; + } + else if (selectionStart != text.Length) + { + text = text[..selectionStart] + text[(selectionStart + 1)..]; + selectionStart += 1; + } + break; + case VirtualKey.A when args.KeyboardModifiers.HasFlag(VirtualKeyModifiers.Control): + args.Handled = true; + selectionStart = 0; + selectionLength = text.Length; + break; + default: + var key = (int)args.Key; + if (!IsReadOnly && key is >= 'A' and <= 'Z' || args.Key == VirtualKey.Space) + { + args.Handled = true; + var c = args.Key == VirtualKey.Space ? ' ' : shift ? (char)key : char.ToLower((char)key); + + + var start = Math.Min(selectionStart, selectionStart + selectionLength); + var end = Math.Max(selectionStart, selectionStart + selectionLength); + + text = text[..start] + c + text[end..]; + selectionStart = start + 1; + selectionLength = 0; + } + break; + } + + Text = text; + + selectionStart = Math.Max(0, Math.Min(text.Length, selectionStart)); + selectionLength = Math.Max(-selectionStart, Math.Min(text.Length - selectionStart, selectionLength)); + SelectInternal(selectionStart, selectionLength); + } + + /// + /// Takes a possibly-negative selection length, indicating a selection that goes backwards. + /// This makes the calculations a lot more natural. + /// + private void SelectInternal(int selectionStart, int selectionLength) + { + Select(Math.Min(selectionStart, selectionStart + selectionLength), Math.Abs(selectionLength)); + _selectionEndsAtTheStart = selectionLength < 0; // set here because Select clears it + UpdateScrolling(); + } + + private void TimerOnTick(object sender, object e) + { + _showCaret = !_showCaret; + UpdateDisplaySelection(); + } + + protected override void OnPointerMoved(PointerRoutedEventArgs e) + { + base.OnPointerMoved(e); + var displayBlock = TextBoxView.DisplayBlock; + var point = e.GetCurrentPoint(displayBlock); + var index = displayBlock.Inlines.GetIndexForTextBlock(point.Position - new Point(displayBlock.Padding.Left, displayBlock.Padding.Top)); + if (point.Properties.IsLeftButtonPressed) + { + var selectionInternalStart = _selectionEndsAtTheStart ? _selection.start + _selection.length : _selection.start; + SelectInternal(selectionInternalStart, index - selectionInternalStart); + } + } + + partial void OnPointerPressedNative(PointerRoutedEventArgs e) + { + var displayBlock = TextBoxView.DisplayBlock; + var index = displayBlock.Inlines.GetIndexForTextBlock(e.GetCurrentPoint(displayBlock).Position - new Point(displayBlock.Padding.Left, displayBlock.Padding.Top)); + Select(index, 0); + } } diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBoxView.skia.cs b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBoxView.skia.cs index d8e077c3fb8d..ccfea08ecb2f 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBoxView.skia.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBoxView.skia.cs @@ -7,6 +7,7 @@ using Uno.Foundation.Logging; using Uno.UI.Xaml.Controls.Extensions; using Windows.UI.Xaml.Media; +using Uno.UI; namespace Windows.UI.Xaml.Controls { @@ -25,7 +26,7 @@ public TextBoxView(TextBox textBox) _textBox = new WeakReference(textBox); _isPasswordBox = textBox is PasswordBox; - if (!ApiExtensibility.CreateInstance(this, out _textBoxExtension)) + if (FeatureConfiguration.TextBox.UseOverlayOnSkia && !ApiExtensibility.CreateInstance(this, out _textBoxExtension)) { if (this.Log().IsEnabled(LogLevel.Warning)) { @@ -37,7 +38,7 @@ public TextBoxView(TextBox textBox) } public (int start, int length) SelectionBeforeKeyDown => - (_textBoxExtension!.GetSelectionStartBeforeKeyDown(), _textBoxExtension.GetSelectionLengthBeforeKeyDown()); + (_textBoxExtension?.GetSelectionStartBeforeKeyDown() ?? 0, _textBoxExtension?.GetSelectionLengthBeforeKeyDown() ?? 0); internal IOverlayTextBoxViewExtension? Extension => _textBoxExtension; @@ -108,6 +109,11 @@ internal void OnSelectionHighlightColorChanged(SolidColorBrush brush) internal void OnFocusStateChanged(FocusState focusState) { + if (!FeatureConfiguration.TextBox.UseOverlayOnSkia) + { + return; + } + if (focusState != FocusState.Unfocused) { DisplayBlock.Opacity = 0; diff --git a/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs b/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs index 8a675760d603..de2b883ea7ec 100644 --- a/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs +++ b/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs @@ -6,6 +6,7 @@ using Windows.Foundation; using Windows.UI.Composition; using Windows.UI.Text; +using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Documents.TextFormatting; using Windows.UI.Xaml.Media; using Uno.UI.Composition; @@ -16,6 +17,8 @@ namespace Windows.UI.Xaml.Documents { partial class InlineCollection { + internal const float CaretThicknessAsRatioOfLineHeight = 0.05f; + // This is safe as a static field. // 1) It's only accessed from UI thread. // 2) Once we call SKTextBlobBuilder.Build(), the instance is reset to its initial state. @@ -33,6 +36,11 @@ partial class InlineCollection private Size _lastDesiredSize; private Size _lastArrangedSize; + // these should only be used by TextBox. + internal (int startLine, int startIndex, int endLine, int endIndex)? Selection { get; set; } + internal (bool atEndOfSelection, Color color)? Caret { get; set; } + internal bool RenderSelectionAndCaret { get; set; } + /// /// Measures a block-level inline collection, i.e. one that belongs to a TextBlock (or Paragraph, in the future). /// @@ -63,7 +71,7 @@ internal Size Measure(Size availableSize, float defaultLineHeight) bool previousLineWrapped = false; float availableWidth = wrapping == TextWrapping.NoWrap ? float.PositiveInfinity : (float)availableSize.Width; - float widestLineWidth = 0; + float widestLineWidth = 0, widestLineHeight = 0; float x = 0; float height = 0; @@ -230,18 +238,13 @@ internal Size Measure(Size availableSize, float defaultLineHeight) } else { - _lastDesiredSize = new Size(widestLineWidth, height); + _lastDesiredSize = new Size(widestLineWidth + (RenderSelectionAndCaret && Caret is { } c && c.color != Colors.Transparent ? widestLineHeight * CaretThicknessAsRatioOfLineHeight : 0), height); } return _lastDesiredSize; // Local functions - static float GetGlyphWidthWithSpacing(GlyphInfo glyph, float characterSpacing) - { - return glyph.AdvanceX > 0 ? glyph.AdvanceX + characterSpacing : glyph.AdvanceX; - } - // Gets rendering info for a segment, excluding any trailing spaces. static (int Length, float Width) GetSegmentRenderInfo(Segment segment, int startGlyph, float characterSpacing) @@ -269,6 +272,7 @@ void MoveToNextLine(bool currentLineWrapped) if (x > widestLineWidth) { widestLineWidth = x; + widestLineHeight = lineHeight; } x = 0; @@ -277,6 +281,11 @@ void MoveToNextLine(bool currentLineWrapped) } } + private static float GetGlyphWidthWithSpacing(GlyphInfo glyph, float characterSpacing) + { + return glyph.AdvanceX > 0 ? glyph.AdvanceX + characterSpacing : glyph.AdvanceX; + } + internal Size Arrange(Size finalSize) { _lastArrangedSize = finalSize; @@ -303,7 +312,7 @@ internal void InvalidateMeasure() /// /// Renders a block-level inline collection, i.e. one that belongs to a TextBlock (or Paragraph, in the future). /// - internal void Draw(in DrawingSession session) + internal virtual void Draw(in DrawingSession session) { if (_renderLines.Count == 0) { @@ -325,8 +334,9 @@ internal void Draw(in DrawingSession session) float y = 0; - foreach (var line in _renderLines) + for (var lineIndex = 0; lineIndex < _renderLines.Count; lineIndex++) { + var line = _renderLines[lineIndex]; // TODO: (Performance) Stop rendering when the lines exceed the available height (float x, float justifySpaceOffset) = line.GetOffsets((float)_lastArrangedSize.Width, alignment); @@ -334,10 +344,14 @@ internal void Draw(in DrawingSession session) y += line.Height; float baselineOffsetY = line.BaselineOffsetY; + var characterCountSoFar = 0; for (int s = 0; s < line.RenderOrderedSegmentSpans.Count; s++) { var segmentSpan = line.RenderOrderedSegmentSpans[s]; + var currentCharacterCount = segmentSpan.GlyphsLength; + characterCountSoFar += currentCharacterCount; + if (segmentSpan.GlyphsLength == 0) { continue; @@ -437,9 +451,129 @@ internal void Draw(in DrawingSession session) } } + { + if (RenderSelectionAndCaret && Selection is { } bg && bg.startLine <= lineIndex && lineIndex <= bg.endLine) + { + var spanStartingIndex = characterCountSoFar - currentCharacterCount; + + float left; + if (bg.startLine == lineIndex) + { + if (bg.startIndex - spanStartingIndex < 0) + { + if (positions.Length > 0) + { + left = positions[0].X; + } + else + { + // no glyphs, so we're at the start + left = x; + } + } + else if (bg.startIndex - spanStartingIndex < positions.Length) + { + left = positions[bg.startIndex - spanStartingIndex].X; + } + else if (bg.startIndex - spanStartingIndex < currentCharacterCount) // positions.Length + TrailingSpaces + { + // x is set to the end of the glyph sequence (no accounting for spaces yet) + left = x + justifySpaceOffset * (currentCharacterCount - (bg.startIndex - spanStartingIndex)); + } + else + { + left = x + justifySpaceOffset * segmentSpan.TrailingSpaces; + } + } + else + { + if (positions.Length > 0) + { + left = positions[0].X; + } + else + { + // no glyphs, so we're at the start + left = x; + } + } + + float right; + if (bg.endLine == lineIndex) + { + if (bg.endIndex - spanStartingIndex < 0) + { + if (positions.Length > 0) + { + right = positions[0].X; + } + else + { + // no glyphs, so we're at the start + right = x; + } + } + else if (bg.endIndex - spanStartingIndex < positions.Length) + { + right = positions[bg.endIndex - spanStartingIndex].X; + } + else if (bg.endIndex - spanStartingIndex < currentCharacterCount) // positions.Length + TrailingSpaces + { + // x is set to the end of the glyph sequence + right = x + justifySpaceOffset * (currentCharacterCount - (bg.endIndex - spanStartingIndex)); + } + else + { + right = x + justifySpaceOffset * segmentSpan.TrailingSpaces; + } + } + else + { + right = x + justifySpaceOffset * segmentSpan.TrailingSpaces; + } + + canvas.DrawRect(new SKRect(left, 0, right, line.Height), new SKPaint + { + Color = ((TextBlock)parent).SelectionHighlightColor.Color.ToSKColor(), + Style = SKPaintStyle.Fill + }); + } + } + using var textBlob = _textBlobBuilder.Build(); canvas.DrawText(textBlob, 0, y + baselineOffsetY, paint); + { + var spanStartingIndex = characterCountSoFar - currentCharacterCount; + if (RenderSelectionAndCaret && Caret is { } caret && Selection is { } selection) + { + var (l, i) = caret.atEndOfSelection ? (selection.endLine, selection.endIndex) : (selection.startLine, selection.startIndex); + + float caretLocation = -1f; + + if (l == lineIndex && i >= spanStartingIndex && i <= characterCountSoFar) + { + if (i >= spanStartingIndex + positions.Length) + { + caretLocation = x + justifySpaceOffset * (i - (spanStartingIndex + positions.Length)) - line.Height * 0.05f; + } + else + { + caretLocation = positions[i - spanStartingIndex].X; + } + } + + if (caretLocation != -1f) + { + canvas.DrawRect(new SKRect(caretLocation, 0, caretLocation + line.Height * 0.05f, line.Height), new SKPaint + { + Color = new SKColor(caret.color.R, caret.color.G, caret.color.B, caret.color.A), + Style = SKPaintStyle.Fill + }); + } + } + } + x += justifySpaceOffset * segmentSpan.TrailingSpaces; } } @@ -453,6 +587,105 @@ static void DrawDecoration(SKCanvas canvas, float x, float y, float width, float } } + internal int GetIndexForTextBlock(Point p) + { + var line = GetRenderLineAt(p.Y, true)!; + + if (line is not { }) + { + return 0; + } + + var characterCount = 0; + foreach (var currentLine in _renderLines.TakeWhile(l => l != line)) + { + foreach (var SegmentSpan in currentLine.SegmentSpans) + { + characterCount += SegmentSpan.Segment.Glyphs.Count; + } + } + + var (span, x) = GetRenderSegmentSpanAt(p, true)!.Value; + + foreach (var currentSpan in line.SegmentSpans.TakeWhile(s => !s.Equals(span))) + { + characterCount += currentSpan.Segment.Glyphs.Count; + } + + var segment = span.Segment; + var run = (Run)segment.Inline; + var characterSpacing = (float)run.FontSize * run.CharacterSpacing / 1000; + + var glyphStart = span.GlyphsStart; + var glyphEnd = glyphStart + span.GlyphsLength; + for (var i = glyphStart; i < glyphEnd; i++) + { + var glyph = segment.Glyphs[i]; + var glyphWidth = GetGlyphWidthWithSpacing(glyph, characterSpacing); + if (p.X < x + glyphWidth / 2) // the point is closer to the left side of the glyph. + { + return characterCount; + } + + x += glyphWidth; + characterCount++; + } + + return characterCount; + } + + internal Rect GetRectForTextBlockIndex(int index) + { + var characterCount = 0; + float y = 0, x = 0; + var parent = (IBlock)_collection.GetParent(); + + for (var lineIndex = 0; lineIndex < _renderLines.Count; lineIndex++) + { + var line = _renderLines[lineIndex]; + (x, var justifySpaceOffset) = line.GetOffsets((float)_lastArrangedSize.Width, parent.TextAlignment); + + var spans = line.RenderOrderedSegmentSpans; + for (var spanIndex = 0; spanIndex < spans.Count; spanIndex++) + { + var span = spans[spanIndex]; + var glyphCount = span.Segment.Glyphs.Count; + + if (index < characterCount + glyphCount) + { + // we found the right span + var segment = span.Segment; + var run = (Run)segment.Inline; + var characterSpacing = (float)run.FontSize * run.CharacterSpacing / 1000; + + var glyphStart = span.GlyphsStart; + var glyphEnd = glyphStart + span.GlyphsLength; + for (var i = glyphStart; i < glyphEnd; i++) + { + var glyph = segment.Glyphs[i]; + var glyphWidth = GetGlyphWidthWithSpacing(glyph, characterSpacing); + + if (index == characterCount) + { + return new Rect(x, y, glyphWidth, line.Height); + } + + x += glyphWidth; + characterCount++; + } + } + + characterCount += glyphCount; + x += span.Width; + } + + y += line.Height; + } + + // width and height default to 0 if there's nothing there + return new Rect(x, y, 0, _renderLines.Count > 0 ? _renderLines[^1].Height : 0); + } + internal RenderLine? GetRenderLineAt(double y, bool extendedSelection) { if (_renderLines.Count == 0) @@ -478,7 +711,7 @@ static void DrawDecoration(SKCanvas canvas, float x, float y, float width, float return extendedSelection ? line : null; } - internal RenderSegmentSpan? GetRenderSegmentSpanAt(Point point, bool extendedSelection) + internal (RenderSegmentSpan span, float x)? GetRenderSegmentSpanAt(Point point, bool extendedSelection) { var parent = (IBlock)_collection.GetParent(); @@ -500,13 +733,13 @@ static void DrawDecoration(SKCanvas canvas, float x, float y, float width, float if (point.X <= spanX && (extendedSelection || point.X >= spanX - span.Width)) { - return span; + return (span, spanX - span.Width); } spanX += justifySpaceOffset * span.TrailingSpaces; } while (i < line.RenderOrderedSegmentSpans.Count); - return extendedSelection ? span : null; + return extendedSelection ? (span, spanX - span.Width) : null; } } }