From 2324b9e1f60e404288e08eb53fdc1386627ca6d5 Mon Sep 17 00:00:00 2001 From: Ramez Ragaa Date: Sat, 11 Nov 2023 21:07:44 +0200 Subject: [PATCH] feat(textbox): add support for text selection --- .../Input/GtkCorePointerInputSource.cs | 2 +- .../Windows.UI.Xaml.Controls/TextBlock.cs | 4 +- .../Controls/TextBlock/TextBlock.Android.cs | 5 + .../UI/Xaml/Controls/TextBlock/TextBlock.cs | 153 +++++++--- .../Controls/TextBlock/TextBlock.iOSmacOS.cs | 5 + .../Xaml/Controls/TextBlock/TextBlock.skia.cs | 72 ++++- .../UI/Xaml/Controls/TextBox/TextBox.skia.cs | 29 +- .../Xaml/Documents/InlineCollection.skia.cs | 276 ++++++++++++++---- .../Documents/TextFormatting/IBlock.skia.cs | 2 + src/Uno.UWP/UI/Input/GestureRecognizer.cs | 2 +- 10 files changed, 428 insertions(+), 122 deletions(-) diff --git a/src/Uno.UI.Runtime.Skia.Gtk/Input/GtkCorePointerInputSource.cs b/src/Uno.UI.Runtime.Skia.Gtk/Input/GtkCorePointerInputSource.cs index b40feb5cac0a..ef1c75d72ead 100644 --- a/src/Uno.UI.Runtime.Skia.Gtk/Input/GtkCorePointerInputSource.cs +++ b/src/Uno.UI.Runtime.Skia.Gtk/Input/GtkCorePointerInputSource.cs @@ -486,7 +486,7 @@ private void UseDevice(PointerPoint pointer, Gdk.Device device) var pointerPoint = new Windows.UI.Input.PointerPoint( frameId: time, - timestamp: time, + timestamp: time * (ulong)TimeSpan.TicksPerMillisecond, // time is in ms, timestamp is in ticks device: pointerDevice, pointerId: pointerId, rawPosition: rawPosition, diff --git a/src/Uno.UI/Generated/3.0.0.0/Windows.UI.Xaml.Controls/TextBlock.cs b/src/Uno.UI/Generated/3.0.0.0/Windows.UI.Xaml.Controls/TextBlock.cs index 8415726bfa60..f9684eb227e2 100644 --- a/src/Uno.UI/Generated/3.0.0.0/Windows.UI.Xaml.Controls/TextBlock.cs +++ b/src/Uno.UI/Generated/3.0.0.0/Windows.UI.Xaml.Controls/TextBlock.cs @@ -125,7 +125,7 @@ public double BaselineOffset } } #endif -#if __ANDROID__ || __IOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__ || __MACOS__ +#if __ANDROID__ || __IOS__ || IS_UNIT_TESTS || __WASM__ || false || __NETSTD_REFERENCE__ || __MACOS__ [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__", "__MACOS__")] public global::Windows.UI.Xaml.Media.SolidColorBrush SelectionHighlightColor { @@ -265,7 +265,7 @@ public bool IsTextTrimmed typeof(global::Windows.UI.Xaml.Controls.TextBlock), new Windows.UI.Xaml.FrameworkPropertyMetadata(default(global::Windows.UI.Xaml.OpticalMarginAlignment))); #endif -#if __ANDROID__ || __IOS__ || IS_UNIT_TESTS || __WASM__ || __SKIA__ || __NETSTD_REFERENCE__ || __MACOS__ +#if __ANDROID__ || __IOS__ || IS_UNIT_TESTS || __WASM__ || false || __NETSTD_REFERENCE__ || __MACOS__ [global::Uno.NotImplemented("__ANDROID__", "__IOS__", "IS_UNIT_TESTS", "__WASM__", "__SKIA__", "__NETSTD_REFERENCE__", "__MACOS__")] public static global::Windows.UI.Xaml.DependencyProperty SelectionHighlightColorProperty { get; } = Windows.UI.Xaml.DependencyProperty.Register( diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.Android.cs b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.Android.cs index 7fed36f3c3e9..5cbd847281de 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.Android.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.Android.cs @@ -67,6 +67,11 @@ static TextBlock() } } + public TextBlock() + { + _hyperlinks.CollectionChanged += HyperlinksOnCollectionChanged; + } + /// /// Finds a private constructor that allows for the specification of MaxLines. /// diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.cs b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.cs index 8ac0c9c3a9db..ec95a68e79b8 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.cs @@ -46,6 +46,19 @@ public partial class TextBlock : DependencyObject private Run _reusableRun; private bool _skipInlinesChangedTextSetter; + private bool _subscribeToPointerEvents; + private bool _isPressed; + private (int start, int end) Selection + { + get => _selection; + set + { + _selection = value; +#if __SKIA__ + Inlines.Selection = (Math.Min(value.start, value.end), Math.Max(value.start, value.end)); +#endif + } + } #if !UNO_REFERENCE_API public TextBlock() @@ -93,6 +106,10 @@ public InlineCollection Inlines { _inlines = new InlineCollection(this); UpdateInlines(Text); + +#if __SKIA__ + SetupInlines(); +#endif } return _inlines; @@ -251,6 +268,8 @@ protected virtual void OnTextChanged(string oldValue, string newValue) { UpdateInlines(newValue); + Selection = (0, 0); + OnTextChangedPartial(); InvalidateTextBlock(); } @@ -474,10 +493,17 @@ public bool IsTextSelectionEnabled typeof(TextBlock), new FrameworkPropertyMetadata( defaultValue: false, - propertyChangedCallback: (s, e) => ((TextBlock)s).OnIsTextSelectionEnabledChangedPartial() + propertyChangedCallback: (s, _) => ((TextBlock)s).OnIsTextSelectionEnabledChanged() ) ); + private void OnIsTextSelectionEnabledChanged() + { + Selection = (0, 0); + + OnIsTextSelectionEnabledChangedPartial(); + } + partial void OnIsTextSelectionEnabledChangedPartial(); #endregion @@ -807,7 +833,7 @@ private void UpdateHyperlinks() { } // Events are subscribed in Hyperlink's ctor #else private static readonly PointerEventHandler OnPointerPressed = (object sender, PointerRoutedEventArgs e) => { - if (sender is not TextBlock { HasHyperlink: true } that) + if (sender is not TextBlock that) { return; } @@ -818,26 +844,43 @@ private void UpdateHyperlinks() { } // Events are subscribed in Hyperlink's ctor return; } - var hyperlink = that.FindHyperlinkAt(point.Position); - if (hyperlink is null) + that._isPressed = true; + if (that.IsTextSelectionEnabled) { - return; + var index = that.Inlines.GetIndexAt(point.Position, false); + that.Selection = (index, index); } - if (!that.CapturePointer(e.Pointer)) + if (that.FindHyperlinkAt(point.Position) is Hyperlink hyperlink) { - return; - } + if (!that.CapturePointer(e.Pointer)) + { + return; + } - hyperlink.SetPointerPressed(e.Pointer); - e.Handled = true; - that.CompleteGesture(); // Make sure to mute Tapped + hyperlink.SetPointerPressed(e.Pointer); + e.Handled = true; + that.CompleteGesture(); // Make sure to mute Tapped + } }; private static readonly PointerEventHandler OnPointerReleased = (object sender, PointerRoutedEventArgs e) => { - if (sender is TextBlock that - && that.IsCaptured(e.Pointer)) + if (sender is not TextBlock that) + { + return; + } + + + if (that._isPressed && that.IsTextSelectionEnabled && that.FindHyperlinkAt(e.GetCurrentPoint(that).Position) is Hyperlink hyperlink) + { + // if we release on a hyperlink, we don't select anything + that.Selection = (0, 0); + } + + that._isPressed = false; + + if (that.IsCaptured(e.Pointer)) { // On UWP we don't get the Tapped event, so make sure to abort it. that.CompleteGesture(); @@ -869,7 +912,7 @@ private void UpdateHyperlinks() { } // Events are subscribed in Hyperlink's ctor private static readonly PointerEventHandler OnPointerMoved = (sender, e) => { - if (sender is not TextBlock { HasHyperlink: true } that) + if (sender is not TextBlock that) { return; } @@ -883,6 +926,12 @@ private void UpdateHyperlinks() { } // Events are subscribed in Hyperlink's ctor that._hyperlinkOver = hyperlink; hyperlink?.SetPointerOver(e.Pointer); } + + if (that._isPressed && that.IsTextSelectionEnabled) + { + var index = that.Inlines.GetIndexAt(point.Position, false); + that.Selection = (that.Selection.start, index); + } }; private static readonly PointerEventHandler OnPointerEntered = (sender, e) => @@ -932,8 +981,15 @@ private bool AbortHyperlinkCaptures(Pointer pointer) return aborted; } - private readonly List<(int start, int end, Hyperlink hyperlink)> _hyperlinks = - new List<(int start, int end, Hyperlink hyperlink)>(); + private readonly ObservableCollection<(int start, int end, Hyperlink hyperlink)> _hyperlinks = new(); + private (int start, int end) _selection; + + private void HyperlinksOnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => RecalculateSubscribeToPointerEvents(); + + private void RecalculateSubscribeToPointerEvents() + { + SubscribeToPointerEvents = HasHyperlink || IsTextSelectionEnabled; + } private void UpdateHyperlinks() { @@ -943,13 +999,6 @@ private void UpdateHyperlinks() { if (HasHyperlink) { - RemoveHandler(PointerPressedEvent, OnPointerPressed); - RemoveHandler(PointerReleasedEvent, OnPointerReleased); - RemoveHandler(PointerMovedEvent, OnPointerMoved); - RemoveHandler(PointerEnteredEvent, OnPointerEntered); - RemoveHandler(PointerExitedEvent, OnPointerExit); - RemoveHandler(PointerCaptureLostEvent, OnPointerCaptureLost); - // Make sure to clear the pressed state of removed hyperlinks foreach (var hyperlink in _hyperlinks) { @@ -963,7 +1012,6 @@ private void UpdateHyperlinks() return; } - var previousHasHyperlinks = HasHyperlink; var previousHyperLinks = _hyperlinks.Select(h => h.hyperlink).ToList(); _hyperlinkOver = null; _hyperlinks.Clear(); @@ -990,27 +1038,6 @@ private void UpdateHyperlinks() { removed.AbortAllPointerState(); } - - // Update events subscriptions if needed - // Note: we subscribe to those events only if needed as they increase marshaling on Android and WASM - if (HasHyperlink && !previousHasHyperlinks) - { - InsertHandler(PointerPressedEvent, OnPointerPressed); - InsertHandler(PointerReleasedEvent, OnPointerReleased); - InsertHandler(PointerMovedEvent, OnPointerMoved); - InsertHandler(PointerEnteredEvent, OnPointerEntered); - InsertHandler(PointerExitedEvent, OnPointerExit); - InsertHandler(PointerCaptureLostEvent, OnPointerCaptureLost); - } - else if (!HasHyperlink && previousHasHyperlinks) - { - RemoveHandler(PointerPressedEvent, OnPointerPressed); - RemoveHandler(PointerReleasedEvent, OnPointerReleased); - RemoveHandler(PointerMovedEvent, OnPointerMoved); - RemoveHandler(PointerEnteredEvent, OnPointerEntered); - RemoveHandler(PointerExitedEvent, OnPointerExit); - RemoveHandler(PointerCaptureLostEvent, OnPointerCaptureLost); - } } private bool HasHyperlink @@ -1025,6 +1052,42 @@ private bool HasHyperlink } } + private bool SubscribeToPointerEvents + { + get => _subscribeToPointerEvents; + set + { + if (_subscribeToPointerEvents == value) + { + return; + } + + _subscribeToPointerEvents = value; + + // Update events subscriptions if needed + // Note: we subscribe to those events only if needed as they increase marshaling on Android and WASM + if (value) + { + InsertHandler(PointerPressedEvent, OnPointerPressed); + InsertHandler(PointerReleasedEvent, OnPointerReleased); + InsertHandler(PointerMovedEvent, OnPointerMoved); + InsertHandler(PointerEnteredEvent, OnPointerEntered); + InsertHandler(PointerExitedEvent, OnPointerExit); + InsertHandler(PointerCaptureLostEvent, OnPointerCaptureLost); + } + else + { + RemoveHandler(PointerPressedEvent, OnPointerPressed); + RemoveHandler(PointerReleasedEvent, OnPointerReleased); + RemoveHandler(PointerMovedEvent, OnPointerMoved); + RemoveHandler(PointerEnteredEvent, OnPointerEntered); + RemoveHandler(PointerExitedEvent, OnPointerExit); + RemoveHandler(PointerCaptureLostEvent, OnPointerCaptureLost); + } + } + } + + #if !__SKIA__ private Hyperlink FindHyperlinkAt(Point point) { diff --git a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.iOSmacOS.cs b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.iOSmacOS.cs index b46c5da90694..05b7ce3db1e0 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.iOSmacOS.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.iOSmacOS.cs @@ -21,5 +21,10 @@ private CGRect GetDrawRect(CGRect rect) return rect; } + + public TextBlock() + { + _hyperlinks.CollectionChanged += HyperlinksOnCollectionChanged; + } } } 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 80d8c0bd7fc7..372059a47a73 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBlock/TextBlock.skia.cs @@ -1,20 +1,15 @@ using System; -using System.Collections.Generic; -using System.Globalization; using Windows.Foundation; using Windows.UI.Xaml.Documents; -using Uno.Extensions; -using System.Linq; -using Windows.UI.Xaml.Hosting; using SkiaSharp; using Windows.UI.Composition; using System.Numerics; -using Windows.UI.Composition.Interactions; -using Uno.Disposables; using Windows.UI.Xaml.Media; using Uno.UI; using Windows.UI.Xaml.Documents.TextFormatting; +using Windows.UI.Xaml.Input; using Uno.UI.Xaml.Core; +using Uno.UI.Xaml.Media; #nullable enable @@ -23,6 +18,7 @@ namespace Windows.UI.Xaml.Controls partial class TextBlock : FrameworkElement, IBlock { private readonly TextVisual _textVisual; + private Action? _selectionHighlightColorChanged; public TextBlock() { @@ -30,6 +26,10 @@ public TextBlock() _textVisual = new TextVisual(Visual.Compositor, this); Visual.Children.InsertAtBottom(_textVisual); + + _hyperlinks.CollectionChanged += HyperlinksOnCollectionChanged; + + AddHandler(DoubleTappedEvent, new DoubleTappedEventHandler((s, e) => ((TextBlock)s).OnDoubleTapped(e)), true); } #if DEBUG @@ -68,6 +68,8 @@ protected override Size MeasureOverride(Size availableSize) return desiredSize; } + partial void OnIsTextSelectionEnabledChangedPartial() => RecalculateSubscribeToPointerEvents(); + private void ApplyFlowDirection(float width) { if (this.FlowDirection == FlowDirection.RightToLeft) @@ -169,5 +171,61 @@ partial void OnLineStackingStrategyChangedPartial() { Inlines.InvalidateMeasure(); } + + partial void OnSelectionHighlightColorChangedPartial(SolidColorBrush brush) + { + Inlines.InvalidateMeasure(); + } + + public void Invalidate(bool updateText) => InvalidateInlines(updateText); + + private void SetupInlines() + { + _inlines.FireDrawingEventsOnEveryRedraw = true; + _inlines.RenderSelection = true; + _inlines.SelectionFound += t => + { + var canvas = t.canvas; + var rect = t.rect; + canvas.DrawRect(new SKRect((float)rect.Left, (float)rect.Top, (float)rect.Right, (float)rect.Bottom), new SKPaint + { + Color = SelectionHighlightColor.Color.ToSKColor(), Style = SKPaintStyle.Fill + }); + }; + } + + private void OnDoubleTapped(DoubleTappedRoutedEventArgs e) + { + var nullableSpan = Inlines.GetRenderSegmentSpanAt(e.GetPosition(this), false); + if (nullableSpan.HasValue) + { + Selection = Inlines.GetStartAndEndIndicesForSpan(nullableSpan.Value.span); + } + } + + // The following should be moved to TextBlock.cs when we implement SelectionHighlightColor for the other platforms + public SolidColorBrush SelectionHighlightColor + { + get => (SolidColorBrush)GetValue(SelectionHighlightColorProperty); + set => SetValue(SelectionHighlightColorProperty, value); + } + + public static DependencyProperty SelectionHighlightColorProperty { get; } = + DependencyProperty.Register( + nameof(SelectionHighlightColor), + typeof(SolidColorBrush), + typeof(TextBlock), + new FrameworkPropertyMetadata( + DefaultBrushes.SelectionHighlightColor, + propertyChangedCallback: (s, e) => ((TextBlock)s)?.OnSelectionHighlightColorChanged((SolidColorBrush)e.OldValue, (SolidColorBrush)e.NewValue))); + + private void OnSelectionHighlightColorChanged(SolidColorBrush? oldBrush, SolidColorBrush? newBrush) + { + oldBrush ??= DefaultBrushes.SelectionHighlightColor; + newBrush ??= DefaultBrushes.SelectionHighlightColor; + Brush.SetupBrushChanged(oldBrush, newBrush, ref _selectionHighlightColorChanged, () => OnSelectionHighlightColorChangedPartial(newBrush)); + } + + partial void OnSelectionHighlightColorChangedPartial(SolidColorBrush brush); } } 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 3e8dd53175f1..c13fb95958dc 100644 --- a/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs +++ b/src/Uno.UI/UI/Xaml/Controls/TextBox/TextBox.skia.cs @@ -31,9 +31,6 @@ private enum ContextMenuItem SelectAll } - // made up value, but feels close enough. - private const ulong MultiTapMaxDelayTicks = TimeSpan.TicksPerMillisecond / 20; - private TextBoxView _textBoxView; private readonly Rectangle _caretRect = new Rectangle { Fill = new SolidColorBrush(Colors.Black) }; @@ -224,8 +221,9 @@ private void UpdateTextBoxView() _cachedRects.RemoveRange(_usedRects, _cachedRects.Count - _usedRects); }; - inlines.SelectionFound += rect => + inlines.SelectionFound += t => { + var rect = t.rect; if (_cachedRects.Count <= _usedRects) { _cachedRects.Add(new Rectangle()); @@ -318,13 +316,10 @@ internal void UpdateDisplaySelection() { if (IsSkiaTextBox && TextBoxView?.DisplayBlock.Inlines is { } inlines) { - var startLine = inlines.GetRenderLineAt(inlines.GetRectForTextBlockIndex(SelectionStart).GetCenter().Y, true)?.index ?? 0; - var endLine = inlines.GetRenderLineAt(inlines.GetRectForTextBlockIndex(SelectionStart + SelectionLength).GetCenter().Y, true)?.index ?? 0; - inlines.Selection = new InlineCollection.SelectionDetails(startLine, SelectionStart, endLine, SelectionStart + SelectionLength); + inlines.Selection = (SelectionStart, SelectionStart + SelectionLength); inlines.RenderSelection = FocusState != FocusState.Unfocused || (_contextMenu?.IsOpen ?? false); inlines.RenderCaret = inlines.RenderSelection && _showCaret && !FeatureConfiguration.TextBox.HideCaret && !IsReadOnly && _selection.length == 0; inlines.CaretAtEndOfSelection = !_selectionEndsAtTheStart; - TextBoxView?.DisplayBlock.InvalidateInlines(true); } } @@ -337,7 +332,7 @@ private void UpdateScrolling() var horizontalOffset = sv.HorizontalOffset; var verticalOffset = sv.VerticalOffset; - var rect = DisplayBlockInlines.GetRectForTextBlockIndex(selectionEnd); + var rect = DisplayBlockInlines.GetRectForIndex(selectionEnd); // TODO: we are sometimes horizontally overscrolling, but it's more visually pleasant that underscrolling as we want the caret to be fully showing. var newHorizontalOffset = horizontalOffset.AtMost(rect.Left).AtLeast(Math.Ceiling(rect.Left - sv.ViewportWidth + Math.Ceiling(DisplayBlockInlines.AverageLineHeight * InlineCollection.CaretThicknessAsRatioOfLineHeight))); @@ -806,7 +801,7 @@ protected override void OnPointerMoved(PointerRoutedEventArgs e) { var displayBlock = TextBoxView.DisplayBlock; var point = e.GetCurrentPoint(displayBlock); - var index = displayBlock.Inlines.GetIndexForTextBlock(point.Position, false); + var index = displayBlock.Inlines.GetIndexAt(point.Position, false); if (_multiTapChunk is { } mtc) { (int start, int length) chunk; @@ -903,7 +898,7 @@ private static bool IsMultiTapGesture((ulong id, ulong ts, Point position) previ var currentPosition = down.Position; return previousTap.id == currentId - && currentTs - previousTap.ts <= MultiTapMaxDelayTicks + && currentTs - previousTap.ts <= GestureRecognizer.MultiTapMaxDelayTicks && !GestureRecognizer.Gesture.IsOutOfTapRange(previousTap.position, currentPosition); } @@ -921,7 +916,7 @@ partial void OnPointerPressedPartial(PointerRoutedEventArgs args) // multiple left presses var displayBlock = TextBoxView.DisplayBlock; - var index = displayBlock.Inlines.GetIndexForTextBlock(args.GetCurrentPoint(displayBlock).Position, false); + var index = displayBlock.Inlines.GetIndexAt(args.GetCurrentPoint(displayBlock).Position, false); if (_lastPointerDown.repeatedPresses == 1) { @@ -945,7 +940,7 @@ partial void OnPointerPressedPartial(PointerRoutedEventArgs args) { // single click var displayBlock = TextBoxView.DisplayBlock; - var index = displayBlock.Inlines.GetIndexForTextBlock(args.GetCurrentPoint(displayBlock).Position, true); + var index = displayBlock.Inlines.GetIndexAt(args.GetCurrentPoint(displayBlock).Position, true); Select(index, 0); _lastPointerDown = (currentPoint, 0); } @@ -1017,10 +1012,10 @@ private int GetUpResult(string text, int selectionStart, int selectionLength, bo var newLineIndex = selectionLength < 0 || shift ? Math.Max(0, endLineIndex - 1) : Math.Max(0, startLineIndex - 1); - var rect = DisplayBlockInlines.GetRectForTextBlockIndex(selectionStart + selectionLength); + var rect = DisplayBlockInlines.GetRectForIndex(selectionStart + selectionLength); var x = shift && selectionLength > 0 ? rect.Right : rect.Left; var y = (newLineIndex + 0.5) * rect.Height; // 0.5 is to get the center of the line, rect.Height is line height - var index = DisplayBlockInlines.GetIndexForTextBlock(new Point(x, y), true); + var index = DisplayBlockInlines.GetIndexAt(new Point(x, y), true); if (text.Length > index - 1 && index - 1 >= 0 && index == lines[newLineIndex].start + lines[newLineIndex].length @@ -1052,10 +1047,10 @@ private int GetDownResult(string text, int selectionStart, int selectionLength, var newLineIndex = selectionLength > 0 || shift ? Math.Min(lines.Count, endLineIndex + 1) : Math.Min(lines.Count, startLineIndex + 1); - var rect = DisplayBlockInlines.GetRectForTextBlockIndex(selectionStart + selectionLength); + var rect = DisplayBlockInlines.GetRectForIndex(selectionStart + selectionLength); var x = shift && selectionLength > 0 ? rect.Right : rect.Left; var y = (newLineIndex + 0.5) * rect.Height; // 0.5 is to get the center of the line, rect.Height is line height - var index = DisplayBlockInlines.GetIndexForTextBlock(new Point(x, y), true); + var index = DisplayBlockInlines.GetIndexAt(new Point(x, y), true); if (text.Length > index - 1 && index - 1 >= 0 && index == lines[newLineIndex].start + lines[newLineIndex].length diff --git a/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs b/src/Uno.UI/UI/Xaml/Documents/InlineCollection.skia.cs index 424ff51279e1..ce8babc26f5d 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.UI.Text; using Windows.UI.Xaml.Documents.TextFormatting; using Windows.UI.Xaml.Media; +using Uno.Extensions; using Uno.Foundation.Logging; using Uno.UI.Composition; @@ -46,17 +47,64 @@ partial class InlineCollection private (bool wentThroughMeasure, bool wentThroughDraw) _drawingValid; private (SelectionDetails? selection, bool caretAtEndOfSelection, bool renderSelection, bool renderCaret) _lastDrawingState; - // these should only be used by TextBox. - internal SelectionDetails? Selection { get; set; } - internal bool CaretAtEndOfSelection { get; set; } - internal bool RenderSelection { get; set; } - internal bool RenderCaret { get; set; } + private SelectionDetails? _selection; + private bool _renderSelection; + private bool _caretAtEndOfSelection; + private bool _renderCaret; + internal bool CaretAtEndOfSelection + { + get => _caretAtEndOfSelection; + set + { + _caretAtEndOfSelection = value; + ((IBlock)_collection.GetParent()).Invalidate(false); + } + } + internal bool RenderSelection + { + get => _renderSelection; + set + { + _renderSelection = value; + ((IBlock)_collection.GetParent()).Invalidate(false); + } + } + internal bool RenderCaret + { + get => _renderCaret; + set + { + _renderCaret = value; + ((IBlock)_collection.GetParent()).Invalidate(false); + } + } + + /// + /// Depending on the event listeners, one might want to send the drawing events + /// every time we . In the case of a TextBox, we need a layout + /// cycle every time the events fire, so we only fire when something changes. + /// In the case of a TextBlock with selection, we directly draw on the SKCanvas + /// so we need the events to fire everytime we redraw. + /// + internal bool FireDrawingEventsOnEveryRedraw { get; set; } internal event Action DrawingStarted; - internal event Action SelectionFound; + internal event Action<(Rect rect, SKCanvas canvas)> SelectionFound; internal event Action DrawingFinished; internal event Action CaretFound; + internal (int start, int end) Selection + { + set + { + // TODO: we're passing twice to look for the start and end lines. Could easily be done in 1 pass + var startLine = GetRenderLineAt(GetRectForIndex(value.start).GetCenter().Y, true)?.index ?? 0; + var endLine = GetRenderLineAt(GetRectForIndex(value.end).GetCenter().Y, true)?.index ?? 0; + _selection = new SelectionDetails(startLine, value.start, endLine, value.end); + ((IBlock)_collection.GetParent()).Invalidate(false); + } + } + /// /// Measures a block-level inline collection, i.e. one that belongs to a TextBlock (or Paragraph, in the future). /// @@ -334,8 +382,9 @@ internal void InvalidateMeasure() /// internal void Draw(in DrawingSession session) { - var newDrawingState = (Selection, CaretAtEndOfSelection, RenderSelection, RenderCaret); - var fireEvents = _drawingValid is not { wentThroughDraw: true, wentThroughMeasure: true } && _lastDrawingState != newDrawingState; + var newDrawingState = (_selection, CaretAtEndOfSelection, RenderSelection, RenderCaret); + var somethingChanged = _drawingValid is not { wentThroughDraw: true, wentThroughMeasure: true } && !_lastDrawingState.Equals(newDrawingState); + var fireEvents = FireDrawingEventsOnEveryRedraw || somethingChanged; _drawingValid.wentThroughDraw = true; _lastDrawingState = newDrawingState; @@ -389,6 +438,7 @@ internal void Draw(in DrawingSession session) for (int s = 0; s < line.RenderOrderedSegmentSpans.Count; s++) { + var xBeforeGlyphOffsets = x; var segmentSpan = line.RenderOrderedSegmentSpans[s]; var segment = segmentSpan.Segment; @@ -411,32 +461,8 @@ internal void Draw(in DrawingSession session) alpha: (byte)(scb.Color.A * scb.Opacity * session.Filters.Opacity)); } - var decorations = inline.TextDecorations; - const TextDecorations allDecorations = TextDecorations.Underline | TextDecorations.Strikethrough; - - if ((decorations & allDecorations) != 0) - { - var metrics = fontInfo.SKFontMetrics; - float width = s == line.RenderOrderedSegmentSpans.Count - 1 ? segmentSpan.WidthWithoutTrailingSpaces : segmentSpan.Width; - - if ((decorations & TextDecorations.Underline) != 0) - { - // TODO: what should default thickness/position be if metrics does not contain it? - float yPos = y + baselineOffsetY + (metrics.UnderlinePosition ?? 0); - DrawDecoration(canvas, x, yPos, width, metrics.UnderlineThickness ?? 1, paint); - } - - if ((decorations & TextDecorations.Strikethrough) != 0) - { - // TODO: what should default thickness/position be if metrics does not contain it? - float yPos = y + baselineOffsetY + (metrics.StrikeoutPosition ?? fontInfo.SKFontSize / -2); - DrawDecoration(canvas, x, yPos, width, metrics.StrikeoutThickness ?? 1, paint); - } - } - - var run = _textBlobBuilder.AllocatePositionedRunFast(fontInfo.SKFont, segmentSpan.GlyphsLength); - var glyphs = run.GetGlyphSpan(segmentSpan.GlyphsLength); - var positions = run.GetPositionSpan(segmentSpan.GlyphsLength); + var glyphs = new ushort[segmentSpan.GlyphsLength]; + var positions = new SKPoint[segmentSpan.GlyphsLength]; if (segment.Direction == FlowDirection.LeftToRight) { @@ -485,12 +511,39 @@ internal void Draw(in DrawingSession session) } } - HandleSelection(lineIndex, characterCountSoFar, positions, x, justifySpaceOffset, segmentSpan, segment, fontInfo, fireEvents, y, line); + // Skia doesn't have the concept of a Z-axis here. Drawings are drawn on top of one another, + // so we need to draw from the bottom layer to the top layer (from inside the screen to outside) + // 1. Selection never covers anything, so it goes first + // 2. Text and text decorations don't generally overlap, so they're interchangeable. + // 3. The caret goes on top so that it's always fully showing without anything covering it. + // Note that carets and text decorations never occur at the same time for now (TextBox has a caret but no + // decorations, TextBlock doesn't have a caret), but a RichTextBox can have both, so that should be kept in mind - if (glyphs.Length != 0) + HandleSelection(lineIndex, characterCountSoFar, positions, x, justifySpaceOffset, segmentSpan, segment, fontInfo, fireEvents, y, line, canvas); + + RenderText(lineIndex, characterCountSoFar, segmentSpan, fontInfo, positions, glyphs, canvas, y, baselineOffsetY, paint); + + var decorations = inline.TextDecorations; + const TextDecorations allDecorations = TextDecorations.Underline | TextDecorations.Strikethrough; + + if ((decorations & allDecorations) != 0) { - using var textBlob = _textBlobBuilder.Build(); - canvas.DrawText(textBlob, 0, y + baselineOffsetY, paint); + var metrics = fontInfo.SKFontMetrics; + float width = s == line.RenderOrderedSegmentSpans.Count - 1 ? segmentSpan.WidthWithoutTrailingSpaces : segmentSpan.Width; + + if ((decorations & TextDecorations.Underline) != 0) + { + // TODO: what should default thickness/position be if metrics does not contain it? + float yPos = y + baselineOffsetY + (metrics.UnderlinePosition ?? 0); + DrawDecoration(canvas, xBeforeGlyphOffsets, yPos, width, metrics.UnderlineThickness ?? 1, paint); + } + + if ((decorations & TextDecorations.Strikethrough) != 0) + { + // TODO: what should default thickness/position be if metrics does not contain it? + float yPos = y + baselineOffsetY + (metrics.StrikeoutPosition ?? fontInfo.SKFontSize / -2); + DrawDecoration(canvas, xBeforeGlyphOffsets, yPos, width, metrics.StrikeoutThickness ?? 1, paint); + } } HandleCaret(characterCountSoFar, lineIndex, segmentSpan, positions, x, justifySpaceOffset, fireEvents, y, line); @@ -514,9 +567,9 @@ static void DrawDecoration(SKCanvas canvas, float x, float y, float width, float } } - private void HandleSelection(int lineIndex, int characterCountSoFar, Span positions, float x, float justifySpaceOffset, RenderSegmentSpan segmentSpan, Segment segment, FontDetails fontInfo, bool fireEvents, float y, RenderLine line) + private void HandleSelection(int lineIndex, int characterCountSoFar, SKPoint[] positions, float x, float justifySpaceOffset, RenderSegmentSpan segmentSpan, Segment segment, FontDetails fontInfo, bool fireEvents, float y, RenderLine line, SKCanvas canvas) { - if (RenderSelection && Selection is { } bg && bg.StartLine <= lineIndex && lineIndex <= bg.EndLine) + if (RenderSelection && _selection is { } bg && bg.StartLine <= lineIndex && lineIndex <= bg.EndLine) { var spanStartingIndex = characterCountSoFar; @@ -565,15 +618,98 @@ private void HandleSelection(int lineIndex, int characterCountSoFar, Span 0.01 && fireEvents) { - SelectionFound?.Invoke(new Rect(new Point(left, y - line.Height), new Point(right, y))); + SelectionFound?.Invoke((new Rect(new Point(left, y - line.Height), new Point(right, y)), canvas)); + } + } + } + + private void RenderText(int lineIndex, int characterCountSoFar, RenderSegmentSpan segmentSpan, FontDetails fontInfo, SKPoint[] positions, ushort[] glyphs, SKCanvas canvas, float y, float baselineOffsetY, SKPaint paint) + { + if (!RenderSelection || _selection is not { } bg || bg.StartLine > lineIndex || lineIndex > bg.EndLine) + { + if (segmentSpan.GlyphsLength > 0) + { + var run1 = _textBlobBuilder.AllocatePositionedRunFast(fontInfo.SKFont, segmentSpan.GlyphsLength); + positions.CopyTo(run1.GetPositionSpan(segmentSpan.GlyphsLength)); + glyphs.CopyTo(run1.GetGlyphSpan(segmentSpan.GlyphsLength)); + using var textBlob = _textBlobBuilder.Build(); + canvas.DrawText(textBlob, 0, y + baselineOffsetY, paint); + } + } + else + { + var spanStartingIndex = characterCountSoFar; + int startOfSelection; + int endOfSelection; + + if (bg.StartIndex < spanStartingIndex) + { + // the selection starts from a previous span, so this span is selected from the very beginning + startOfSelection = 0; + } + else if (bg.StartIndex - spanStartingIndex < segmentSpan.GlyphsLength) + { + // part or all of this span is selected + startOfSelection = bg.StartIndex - spanStartingIndex; + } + else + { + // this span is not a part of the selection + startOfSelection = segmentSpan.GlyphsLength; + } + + if (bg.EndIndex - spanStartingIndex < 0) + { + // this span is not a part of the selection + endOfSelection = 0; + } + else if (bg.EndIndex - spanStartingIndex < segmentSpan.GlyphsLength) + { + // part or all of this span is selected + endOfSelection = bg.EndIndex - spanStartingIndex; + } + else + { + // the selection ends after this span, so this span is selected to the very end + endOfSelection = segmentSpan.GlyphsLength; + } + + if (startOfSelection > 0) // pre selection + { + var run1 = _textBlobBuilder.AllocatePositionedRunFast(fontInfo.SKFont, startOfSelection); + new Span(positions, 0, startOfSelection).CopyTo(run1.GetPositionSpan(startOfSelection)); + new Span(glyphs, 0, startOfSelection).CopyTo(run1.GetGlyphSpan(startOfSelection)); + using var textBlob1 = _textBlobBuilder.Build(); + canvas.DrawText(textBlob1, 0, y + baselineOffsetY, paint); + } + + if (endOfSelection - startOfSelection > 0) // selection + { + var run2 = _textBlobBuilder.AllocatePositionedRunFast(fontInfo.SKFont, endOfSelection - startOfSelection); + new Span(positions, startOfSelection, endOfSelection - startOfSelection).CopyTo(run2.GetPositionSpan(endOfSelection - startOfSelection)); + new Span(glyphs, startOfSelection, endOfSelection - startOfSelection).CopyTo(run2.GetGlyphSpan(endOfSelection - startOfSelection)); + using var textBlob2 = _textBlobBuilder.Build(); + var color = paint.Color; + paint.Color = new SKColor(255, 255, 255, 255); // selection is always white + canvas.DrawText(textBlob2, 0, y + baselineOffsetY, paint); + paint.Color = color; + } + + if (segmentSpan.GlyphsLength - endOfSelection > 0) // post selection + { + var run3 = _textBlobBuilder.AllocatePositionedRunFast(fontInfo.SKFont, segmentSpan.GlyphsLength - endOfSelection); + new Span(positions, endOfSelection, segmentSpan.GlyphsLength - endOfSelection).CopyTo(run3.GetPositionSpan(segmentSpan.GlyphsLength - endOfSelection)); + new Span(glyphs, endOfSelection, segmentSpan.GlyphsLength - endOfSelection).CopyTo(run3.GetGlyphSpan(segmentSpan.GlyphsLength - endOfSelection)); + using var textBlob3 = _textBlobBuilder.Build(); + canvas.DrawText(textBlob3, 0, y + baselineOffsetY, paint); } } } - private void HandleCaret(int characterCountSoFar, int lineIndex, RenderSegmentSpan segmentSpan, Span positions, float x, float justifySpaceOffset, bool fireEvents, float y, RenderLine line) + private void HandleCaret(int characterCountSoFar, int lineIndex, RenderSegmentSpan segmentSpan, SKPoint[] positions, float x, float justifySpaceOffset, bool fireEvents, float y, RenderLine line) { var spanStartingIndex = characterCountSoFar; - if (RenderCaret && Selection is { } selection) + if (RenderCaret && _selection is { } selection) { var (l, i) = CaretAtEndOfSelection ? (selection.EndLine, selection.EndIndex) : (selection.StartLine, selection.StartIndex); @@ -604,8 +740,7 @@ private void HandleCaret(int characterCountSoFar, int lineIndex, RenderSegmentSp } } - // Warning: this is only tested and currently used by TextBox - internal int GetIndexForTextBlock(Point p, bool ignoreEndingSpace) + internal int GetIndexAt(Point p, bool ignoreEndingSpace) { var line = GetRenderLineAt(p.Y, true)?.line; @@ -618,14 +753,18 @@ internal int GetIndexForTextBlock(Point p, bool ignoreEndingSpace) .TakeWhile(l => l != line) // all previous lines .Sum(currentLine => currentLine.SegmentSpans.Sum(GlyphsLengthWithCR)); // all characters in line - var (span, x) = GetRenderSegmentSpanAt(p, true)!.Value; + var (span, x) = GetRenderSegmentSpanAt(p, true)!.Value; // never null because we already found a line characterCount += line.SegmentSpans .TakeWhile(s => !s.Equals(span)) // all previous spans in line .Sum(GlyphsLengthWithCR); // all characters in span var segment = span.Segment; - var run = (Run)segment.Inline; + if (segment.Inline is not Run run) + { + return characterCount; + } + var characterSpacing = (float)run.FontSize * run.CharacterSpacing / 1000; // The rest of the function uses GlyphsLength and not FullGlyphsLength as we can only really find a rendered glyph with a pointer. @@ -655,7 +794,7 @@ internal int GetIndexForTextBlock(Point p, bool ignoreEndingSpace) } // Warning: this is only tested and currently used by TextBox - internal Rect GetRectForTextBlockIndex(int index) + internal Rect GetRectForIndex(int index) { var characterCount = 0; float y = 0, x = 0; @@ -764,6 +903,30 @@ internal Rect GetRectForTextBlockIndex(int index) return extendedSelection ? (span, spanX - span.Width) : null; } + internal (int start, int end) GetStartAndEndIndicesForSpan(RenderSegmentSpan span) + { + var characterCount = 0; + var lineIndex = 0; + RenderLine line; + for (; lineIndex < _renderLines.Count; lineIndex++) + { + line = _renderLines[lineIndex]; + if (line.SegmentSpans.Contains(span)) + { + break; + } + characterCount += line.SegmentSpans.Sum(GlyphsLengthWithCR); + } + + line = _renderLines[lineIndex]; + + characterCount += line.SegmentSpans + .TakeWhile(s => !s.Equals(span)) // all previous spans in line + .Sum(GlyphsLengthWithCR); // all characters in span + + return (characterCount, characterCount + GlyphsLengthWithCR(span)); + } + // Warning: this is only tested and currently used by TextBox internal List<(int start, int length)> GetLineIntervals() { @@ -810,6 +973,21 @@ private static bool SpanEndsInCR(Segment segment, RenderSegmentSpan segmentSpan) } } - internal record SelectionDetails(int StartLine, int StartIndex, int EndLine, int EndIndex); + private record SelectionDetails(int StartLine, int StartIndex, int EndLine, int EndIndex) + { + public virtual bool Equals(SelectionDetails? other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return StartLine == other.StartLine && StartIndex == other.StartIndex && EndLine == other.EndLine && EndIndex == other.EndIndex; + } + public override int GetHashCode() => HashCode.Combine(StartLine, StartIndex, EndLine, EndIndex); + } } } diff --git a/src/Uno.UI/UI/Xaml/Documents/TextFormatting/IBlock.skia.cs b/src/Uno.UI/UI/Xaml/Documents/TextFormatting/IBlock.skia.cs index dfb980c36863..8e4b22d0ea17 100644 --- a/src/Uno.UI/UI/Xaml/Documents/TextFormatting/IBlock.skia.cs +++ b/src/Uno.UI/UI/Xaml/Documents/TextFormatting/IBlock.skia.cs @@ -26,5 +26,7 @@ internal interface IBlock FlowDirection FlowDirection { get; } int MaxLines { get; } + + void Invalidate(bool updateText); } } diff --git a/src/Uno.UWP/UI/Input/GestureRecognizer.cs b/src/Uno.UWP/UI/Input/GestureRecognizer.cs index f70f2dce9525..28e7ea82d78e 100644 --- a/src/Uno.UWP/UI/Input/GestureRecognizer.cs +++ b/src/Uno.UWP/UI/Input/GestureRecognizer.cs @@ -24,7 +24,7 @@ public partial class GestureRecognizer internal const int TapMaxXDelta = 10; internal const int TapMaxYDelta = 10; - internal const ulong MultiTapMaxDelayTicks = TimeSpan.TicksPerMillisecond * 1000; + internal const ulong MultiTapMaxDelayTicks = TimeSpan.TicksPerMillisecond * 500; internal const long HoldMinDelayTicks = TimeSpan.TicksPerMillisecond * 800; internal const float HoldMinPressure = .75f;