diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 20b7d33c60..9a0e4e7f8d 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -173,7 +173,7 @@ - + diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml index 346e561dfc..1b0b936965 100644 --- a/samples/ControlCatalog/Pages/TextBoxPage.xaml +++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml @@ -8,97 +8,10 @@ - - - - Custom context flyout - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs index a00a7be6c2..af060c04b3 100644 --- a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs +++ b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs @@ -133,8 +133,6 @@ namespace Avalonia.Input.GestureRecognizers _trackedRootPoint = new Point( _trackedRootPoint.X - (_trackedRootPoint.X >= rootPoint.X ? ScrollStartDistance : -ScrollStartDistance), _trackedRootPoint.Y - (_trackedRootPoint.Y >= rootPoint.Y ? ScrollStartDistance : -ScrollStartDistance)); - - Capture(e.Pointer); } } @@ -145,9 +143,14 @@ namespace Avalonia.Input.GestureRecognizers _velocityTracker?.AddPosition(TimeSpan.FromMilliseconds(e.Timestamp), _pointerPressedPoint - rootPoint); _lastMoveTimestamp = e.Timestamp; - Target!.RaiseEvent(new ScrollGestureEventArgs(_gestureId, vector)); + var scrollEventArgs = new ScrollGestureEventArgs(_gestureId, vector); + Target!.RaiseEvent(scrollEventArgs); _trackedRootPoint = rootPoint; - e.Handled = true; + e.Handled = scrollEventArgs.Handled; + if(e.Handled) + { + Capture(e.Pointer); + } } } } diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 05c74b39e5..1247476c0b 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -1,14 +1,14 @@ using System; using System.Collections.Generic; -using System.Data; using Avalonia.Controls.Documents; using Avalonia.Controls.Primitives; -using Avalonia.Interactivity; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Immutable; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; +using Avalonia.Platform; using Avalonia.Threading; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -334,6 +334,7 @@ namespace Avalonia.Controls.Presenters protected override bool BypassFlowDirectionPolicies => true; internal TextSelectionHandleCanvas? TextSelectionHandleCanvas { get; set; } + internal TextBoxTextInputMethodClient? CurrentImClient { get; set; } /// /// Creates the used to render the text. @@ -1026,15 +1027,7 @@ namespace Avalonia.Controls.Presenters OnPreeditChanged(PreeditText, PreeditTextCursorPosition); } - if(change.Property == TextProperty) - { - if (!string.IsNullOrEmpty(PreeditText)) - { - SetCurrentValue(PreeditTextProperty, null); - } - } - - if(change.Property == CaretIndexProperty) + if(change.Property == TextProperty || change.Property == CaretIndexProperty) { if (!string.IsNullOrEmpty(PreeditText)) { @@ -1067,7 +1060,7 @@ namespace Avalonia.Controls.Presenters case nameof(SelectionStart): case nameof(SelectionEnd): case nameof(SelectionForegroundBrush): - case nameof(ShowSelectionHighlightProperty): + case nameof(ShowSelectionHighlight): case nameof(PasswordChar): case nameof(RevealPassword): diff --git a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs index 049188008a..89897e0ee4 100644 --- a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs +++ b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; -using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; +using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives @@ -11,6 +12,7 @@ namespace Avalonia.Controls.Primitives internal class TextSelectionHandleCanvas : Canvas { private const int ContextMenuPadding = 16; + private static bool s_isInTouchMode; private readonly TextSelectionHandle _caretHandle; private readonly TextSelectionHandle _startHandle; @@ -18,7 +20,8 @@ namespace Avalonia.Controls.Primitives private TextPresenter? _presenter; private TextBox? _textBox; private bool _showHandle; - private bool _canShowContextMenu = true; + private IDisposable? _showDisposable; + private PresenterVisualListener _layoutListener; internal bool ShowHandles { @@ -66,30 +69,46 @@ namespace Avalonia.Controls.Primitives _caretHandle.SetTopLeft(default); _endHandle.SetTopLeft(default); - _startHandle.PointerReleased += Handle_PointerReleased; - _caretHandle.PointerReleased += Handle_PointerReleased; - _endHandle.PointerReleased += Handle_PointerReleased; - - _startHandle.Holding += Caret_Holding; - _caretHandle.Holding += Caret_Holding; - _endHandle.Holding += Caret_Holding; + _startHandle.ContextCanceled += Caret_ContextCanceled; + _caretHandle.ContextCanceled += Caret_ContextCanceled; + _endHandle.ContextCanceled += Caret_ContextCanceled; + _startHandle.ContextRequested += Caret_ContextRequested; + _caretHandle.ContextRequested += Caret_ContextRequested; + _endHandle.ContextRequested += Caret_ContextRequested; IsVisible = ShowHandles; ClipToBounds = false; + + _layoutListener = new PresenterVisualListener(); + _layoutListener.Invalidated += LayoutListener_Invalidated; } - private void Handle_PointerReleased(object? sender, PointerReleasedEventArgs e) + private void LayoutListener_Invalidated(object? sender, EventArgs e) { - ShowContextMenu(); + if (ShowHandles) + MoveHandlesToSelection(); + } + + private void Caret_ContextCanceled(object? sender, RoutedEventArgs e) + { + CloseFlyout(); + } + + private void Caret_ContextRequested(object? sender, ContextRequestedEventArgs e) + { + ShowFlyout(e); + e.Handled = true; } private void Handle_DragStarted(object? sender, VectorEventArgs e) { - if (_textBox?.ContextFlyout is { } flyout) - { - flyout.Hide(); - } + CloseFlyout(); + } + + private void CloseFlyout() + { + _presenter?.RaiseEvent(new Interactivity.RoutedEventArgs(InputElement.ContextCanceledEvent)); } private void EndHandle_DragDelta(object? sender, VectorEventArgs e) @@ -106,41 +125,99 @@ namespace Avalonia.Controls.Primitives private void CaretHandle_DragDelta(object? sender, VectorEventArgs e) { - _canShowContextMenu = false; if (_presenter != null && _textBox != null) { var point = ToPresenter(_caretHandle.IndicatorPosition); + using var _ = BeginChange(); _presenter.MoveCaretToPoint(point); - _textBox.SelectionStart = _textBox.SelectionEnd = _presenter.CaretIndex; - var points = _presenter.GetCaretPoints(); + var caretIndex = _presenter.CaretIndex; + _textBox.SetCurrentValue(TextBox.CaretIndexProperty, caretIndex); + _textBox.SetCurrentValue(TextBox.SelectionStartProperty, caretIndex); + _textBox.SetCurrentValue(TextBox.SelectionEndProperty, caretIndex); + ClampHandle(_caretHandle); + } + } - _caretHandle?.SetTopLeft(ToLayer(points.Item2)); + public void Hide() + { + ShowHandles = false; + } + + internal void ShowOnFocused() + { + if (s_isInTouchMode) + { + MoveHandlesToSelection(); + } + } + + private IDisposable? BeginChange() + { + return _presenter?.CurrentImClient?.BeginChange(); + } + + private void ClampHandle(TextSelectionHandle handle) + { + var bounds = _presenter?.GetTransformedBounds(); + + if (bounds.HasValue) + { + var point = _caretHandle.IndicatorPosition; + var rect = bounds.Value.Clip; + if (point.X < rect.X) + point = point.WithX(rect.X); + if (point.X > rect.Right) + point = point.WithX(rect.Right); + if (point.Y < rect.Y) + point = point.WithY(rect.Y); + if (point.Y > rect.Bottom) + point = point.WithY(rect.Bottom); + + + handle?.SetTopLeft(point); } } private void Handle_DragCompleted(object? sender, VectorEventArgs e) { MoveHandlesToSelection(); - - ShowContextMenu(); } private void EnsureVisible() { - if (_textBox is { } t && t.VisualRoot is Visual r) + _showDisposable?.Dispose(); + _showDisposable = null; + + if (_presenter is { } presenter && presenter.VisualRoot is InputElement root) { - var bounds = t.Bounds; - var topLeft = t.TranslatePoint(default, r) ?? default; - bounds = bounds.WithX(topLeft.X).WithY(topLeft.Y); - - var hasSelection = _textBox.SelectionStart != _textBox.SelectionEnd; - - _startHandle.IsVisible = bounds.Contains(new Point(GetLeft(_startHandle), GetTop(_startHandle))) && - ShowHandles && hasSelection; - _endHandle.IsVisible = bounds.Contains(new Point(GetLeft(_endHandle), GetTop(_endHandle))) && - ShowHandles && hasSelection; - _caretHandle.IsVisible = bounds.Contains(new Point(GetLeft(_caretHandle), GetTop(_caretHandle))) && - ShowHandles && !hasSelection; + var bounds = presenter.GetTransformedBounds(); + + if (bounds == null) + return; + + var hasSelection = _presenter.SelectionStart != _presenter.SelectionEnd; + + _startHandle.IsVisible = ShowHandles && hasSelection && + !IsOccluded(new Point(GetLeft(_startHandle), GetTop(_startHandle))); + _endHandle.IsVisible = ShowHandles && hasSelection && + !IsOccluded(new Point(GetLeft(_endHandle), GetTop(_endHandle))); + _caretHandle.IsVisible = ShowHandles && !hasSelection && + !IsOccluded(new Point(GetLeft(_caretHandle), GetTop(_caretHandle))); + + bool IsOccluded(Point point) + { + return !bounds.Value.Clip.Contains(point); + } + + + if (ShowHandles && !hasSelection) + { + _showDisposable = DispatcherTimer.RunOnce(() => + { + ShowHandles = false; + _showDisposable?.Dispose(); + }, TimeSpan.FromSeconds(5), DispatcherPriority.Background); + } } } @@ -148,10 +225,7 @@ namespace Avalonia.Controls.Primitives { if (_presenter != null && _textBox != null) { - if (_textBox.ContextFlyout is { } flyout) - { - flyout.Hide(); - } + CloseFlyout(); var point = ToPresenter(handle.IndicatorPosition); point = point.WithY(point.Y - _presenter.FontSize / 2); @@ -159,18 +233,19 @@ namespace Avalonia.Controls.Primitives var position = hit.CharacterHit.FirstCharacterIndex + hit.CharacterHit.TrailingLength; var otherHandle = handle == _startHandle ? _endHandle : _startHandle; + using var _ = BeginChange(); if (handle.SelectionHandleType == SelectionHandleType.Start) { if (position >= _textBox.SelectionEnd) position = _textBox.SelectionEnd - 1; - _textBox.SelectionStart = position; + _textBox.SetCurrentValue(TextBox.SelectionStartProperty, position); } else { if (position <= _textBox.SelectionStart) position = _textBox.SelectionStart + 1; - _textBox.SelectionEnd = position; + _textBox.SetCurrentValue(TextBox.SelectionEndProperty, position); } var selectionStart = _textBox.SelectionStart; @@ -182,7 +257,7 @@ namespace Avalonia.Controls.Primitives if (rects.Count > 0) { var first = rects[0]; - var last = rects[rects.Count -1]; + var last = rects[rects.Count - 1]; if (handle.SelectionHandleType == SelectionHandleType.Start) handle?.SetTopLeft(ToLayer(first.BottomLeft)); @@ -196,9 +271,9 @@ namespace Avalonia.Controls.Primitives } _presenter?.MoveCaretToTextPosition(position); - } - EnsureVisible(); + EnsureVisible(); + } } private Point ToLayer(Point point) @@ -211,24 +286,19 @@ namespace Avalonia.Controls.Primitives return (_presenter is { } p) ? (p.VisualRoot as Visual)?.TranslatePoint(point, p) ?? point : point; } - private Point ToTextBox(Point point) - { - return (_textBox is { } p) ? (p.VisualRoot as Visual)?.TranslatePoint(point, p) ?? point : point; - } - public void MoveHandlesToSelection() { if (_presenter == null - || _textBox == null || _startHandle.IsDragging - || _endHandle.IsDragging - || _textBox.ContextFlyout?.IsOpen == true - || _textBox.ContextMenu?.IsOpen == true) + || _caretHandle.IsDragging + || _endHandle.IsDragging) { return; } - var hasSelection = _textBox.SelectionStart != _textBox.SelectionEnd; + var selectionStart = _presenter.SelectionStart; + var selectionEnd = _presenter.SelectionEnd; + var hasSelection = selectionStart != selectionEnd; var points = _presenter.GetCaretPoints(); @@ -236,8 +306,6 @@ namespace Avalonia.Controls.Primitives if (hasSelection) { - var selectionStart = _textBox.SelectionStart; - var selectionEnd = _textBox.SelectionEnd; var start = Math.Min(selectionStart, selectionEnd); var length = Math.Max(selectionStart, selectionEnd) - start; @@ -265,6 +333,10 @@ namespace Avalonia.Controls.Primitives } } } + + ShowHandles = true; + + EnsureVisible(); } internal void SetPresenter(TextPresenter? textPresenter) @@ -272,58 +344,77 @@ namespace Avalonia.Controls.Primitives if (_presenter == textPresenter) return; - if (_textBox != null) + if (_presenter != null) { - _textBox.RemoveHandler(TextBox.TextChangingEvent, TextChanged); - _textBox.RemoveHandler(KeyDownEvent, TextBoxKeyDown); - - _textBox.PropertyChanged -= TextBoxPropertyChanged; - _textBox.EffectiveViewportChanged -= TextBoxEffectiveViewportChanged; - _textBox.SizeChanged -= TextBox_SizeChanged; + _layoutListener.Detach(); + _presenter.RemoveHandler(KeyDownEvent, PresenterKeyDown); + _presenter.RemoveHandler(TappedEvent, PresenterTapped); + _presenter.RemoveHandler(PointerPressedEvent, PresenterPressed); + _presenter.RemoveHandler(GotFocusEvent, PresenterFocused); + if (_textBox != null) + { + _textBox.PropertyChanged -= TextBox_PropertyChanged; + } _textBox = null; + + _presenter = null; } _presenter = textPresenter; if (_presenter != null) { + _layoutListener.Attach(_presenter); + _presenter.AddHandler(KeyDownEvent, PresenterKeyDown, handledEventsToo: true); + _presenter.AddHandler(TappedEvent, PresenterTapped); + _presenter.AddHandler(PointerPressedEvent, PresenterPressed); + _presenter.AddHandler(GotFocusEvent, PresenterFocused, handledEventsToo: true); + _textBox = _presenter.FindAncestorOfType(); if (_textBox != null) { - _textBox.AddHandler(TextBox.TextChangingEvent, TextChanged, handledEventsToo: true); - _textBox.AddHandler(KeyDownEvent, TextBoxKeyDown, handledEventsToo: true); - - _textBox.PropertyChanged += TextBoxPropertyChanged; - _textBox.EffectiveViewportChanged += TextBoxEffectiveViewportChanged; - _textBox.SizeChanged += TextBox_SizeChanged; + _textBox.PropertyChanged += TextBox_PropertyChanged; } } } - private void TextBox_SizeChanged(object? sender, SizeChangedEventArgs e) + private void PresenterPressed(object? sender, PointerPressedEventArgs e) { - InvalidateMeasure(); + s_isInTouchMode = e.Pointer.Type != PointerType.Mouse; } - private void TextBoxEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) + private void PresenterFocused(object? sender, GotFocusEventArgs e) { - if (ShowHandles) + if (_presenter != null && _presenter.SelectionStart != _presenter.SelectionEnd) { - MoveHandlesToSelection(); + ShowHandles = true; EnsureVisible(); } } - private void Caret_Holding(object? sender, HoldingRoutedEventArgs e) + private void PresenterTapped(object? sender, TappedEventArgs e) + { + s_isInTouchMode = e.Pointer.Type != PointerType.Mouse; + + if (s_isInTouchMode) + MoveHandlesToSelection(); + else + { + ShowHandles = false; + _showDisposable?.Dispose(); + _showDisposable = null; + } + } + + private void Presenter_SizeChanged(object? sender, SizeChangedEventArgs e) { - if (ShowContextMenu()) - e.Handled = true; + InvalidateMeasure(); } - internal bool ShowContextMenu() + internal bool ShowFlyout(ContextRequestedEventArgs e) { - if (_textBox != null && _canShowContextMenu) + if (_textBox != null) { if (_textBox.ContextFlyout is PopupFlyoutBase flyout) { @@ -349,7 +440,7 @@ namespace Avalonia.Controls.Primitives if (handle != null) { - var topLeft = ToTextBox(handle.GetTopLeft()); + var topLeft = ToPresenter(handle.GetTopLeft()); flyout.VerticalOffset = topLeft.Y - verticalOffset; flyout.HorizontalOffset = topLeft.X; flyout.Placement = PlacementMode.TopEdgeAlignedLeft; @@ -360,12 +451,10 @@ namespace Avalonia.Controls.Primitives } else { - _textBox.RaiseEvent(new ContextRequestedEventArgs()); + _textBox.RaiseEvent(new ContextRequestedEventArgs(e)); } } - _canShowContextMenu = true; - return false; } @@ -377,26 +466,105 @@ namespace Avalonia.Controls.Primitives EnsureVisible(); } - private void TextBoxPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + private void TextBox_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { - if (ShowHandles && (e.Property == TextBox.SelectionStartProperty || - e.Property == TextBox.SelectionEndProperty)) + if (s_isInTouchMode && (e.Property == TextPresenter.SelectionStartProperty || + e.Property == TextPresenter.SelectionEndProperty)) { MoveHandlesToSelection(); EnsureVisible(); } + else if (e.Property == TextPresenter.TextProperty) + { + ShowHandles = false; + if (_presenter?.ContextFlyout is { } flyout && flyout.IsOpen) + flyout.Hide(); + } } - private void TextBoxKeyDown(object? sender, KeyEventArgs e) + private void PresenterKeyDown(object? sender, KeyEventArgs e) { ShowHandles = false; + s_isInTouchMode = false; } - private void TextChanged(object? sender, TextChangingEventArgs e) + private class PresenterVisualListener { - ShowHandles = false; - if (_textBox?.ContextFlyout is { } flyout && flyout.IsOpen) - flyout.Hide(); + private List _attachedVisuals = new List(); + private TextPresenter? _presenter; + private object _lock = new object(); + + public event EventHandler? Invalidated; + + public void Attach(TextPresenter presenter) + { + lock (_lock) + { + if (_presenter != null) + throw new InvalidOperationException("Listener is already attached to a TextPresenter"); + + _presenter = presenter; + presenter.SizeChanged += Presenter_SizeChanged; + presenter.EffectiveViewportChanged += Visual_EffectiveViewportChanged; + + + void AttachViewportHandler(Visual visual) + { + if (visual is Layoutable layoutable) + { + layoutable.EffectiveViewportChanged += Visual_EffectiveViewportChanged; + } + + _attachedVisuals.Add(visual); + } + + var visualParent = presenter.VisualParent; + while (visualParent != null) + { + AttachViewportHandler(visualParent); + + visualParent = visualParent.VisualParent; + } + } + } + + private void Visual_EffectiveViewportChanged(object? sender, Layout.EffectiveViewportChangedEventArgs e) + { + OnInvalidated(); + } + + private void Presenter_SizeChanged(object? sender, SizeChangedEventArgs e) + { + OnInvalidated(); + } + + public void Detach() + { + lock (_lock) + { + if (_presenter is { } presenter) + { + presenter.SizeChanged -= Presenter_SizeChanged; + presenter.EffectiveViewportChanged -= Visual_EffectiveViewportChanged; + } + + foreach (var visual in _attachedVisuals) + { + if (visual is Layoutable layoutable) + { + layoutable.EffectiveViewportChanged -= Visual_EffectiveViewportChanged; + } + } + + _presenter = null; + _attachedVisuals.Clear(); + } + } + + private void OnInvalidated() + { + Invalidated?.Invoke(this, EventArgs.Empty); + } } } } diff --git a/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs b/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs index 6518b96bce..a36bb1c8f8 100644 --- a/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs +++ b/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs @@ -10,6 +10,8 @@ namespace Avalonia.Controls.Primitives /// public class TextSelectionHandle : Thumb { + private const int DragDetectionRadius = 2; + internal SelectionHandleType SelectionHandleType { get; set; } private Point _startPosition; @@ -59,9 +61,13 @@ namespace Avalonia.Controls.Primitives protected override void OnDragDelta(VectorEventArgs e) { base.OnDragDelta(e); + var newDelta = e.Vector; - _delta = e.Vector; - UpdateTextSelectionHandlePosition(); + if (Math.Abs((newDelta - _delta).Length) > DragDetectionRadius) + { + _delta = e.Vector; + UpdateTextSelectionHandlePosition(); + } } protected override void OnDragCompleted(VectorEventArgs e) @@ -134,6 +140,9 @@ namespace Avalonia.Controls.Primitives protected override void OnPointerMoved(PointerEventArgs e) { + if (e.Pointer.Captured != this) + return; + VectorEventArgs ev; if (!_lastPoint.HasValue) @@ -163,8 +172,8 @@ namespace Avalonia.Controls.Primitives protected override void OnPointerPressed(PointerPressedEventArgs e) { - e.Handled = true; PseudoClasses.Add(":pressed"); + e.Pointer.Capture(this); } protected override void OnPointerReleased(PointerReleasedEventArgs e) @@ -184,6 +193,7 @@ namespace Avalonia.Controls.Primitives } PseudoClasses.Remove(":pressed"); + e.Pointer.Capture(null); } } } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 447a6a41fc..8bcd3b2c97 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -1,24 +1,26 @@ -using Avalonia.Input.Platform; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.Linq; -using Avalonia.Reactive; +using Avalonia.Automation.Peers; +using Avalonia.Controls.Metadata; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Interactivity; -using Avalonia.Media; -using Avalonia.Metadata; -using Avalonia.Data; using Avalonia.Layout; -using Avalonia.Utilities; -using Avalonia.Controls.Metadata; +using Avalonia.Media; using Avalonia.Media.TextFormatting; -using Avalonia.Automation.Peers; using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Metadata; +using Avalonia.Platform; +using Avalonia.Reactive; using Avalonia.Threading; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -30,6 +32,11 @@ namespace Avalonia.Controls [PseudoClasses(":empty")] public class TextBox : TemplatedControl, UndoRedoHelper.IUndoRedoHost { + /// + /// The radius for touch input. Used to determine if selection should change from moving a touch pointer. + /// + private readonly static int s_touchRadius = (int)((AvaloniaLocator.Current?.GetService()?.GetTapSize(PointerType.Touch).Height ?? 10) / 2) + 5; + /// /// Gets a platform-specific for the Cut action /// @@ -368,9 +375,13 @@ namespace Avalonia.Controls private int _wordSelectionStart = -1; private int _selectedTextChangesMadeSinceLastUndoSnapshot; private bool _hasDoneSnapshotOnce; - private static bool _isHolding; private int _currentClickCount; private bool _isDoubleTapped; + private bool _isInTouchMode; + private Point _lastPoint; + private bool _isInTouchSelectionMode; + private bool _isInTouchCaretMode; + private bool _hasTouchSelection; private const int _maxCharsBeforeUndoSnapshot = 7; static TextBox() @@ -467,7 +478,7 @@ namespace Avalonia.Controls SetCurrentValue(SelectionStartProperty, newValue); SetCurrentValue(SelectionEndProperty, newValue); - _presenter?.SetCurrentValue(TextPresenter.CaretIndexProperty, newValue); + _presenter?.SetCurrentValue(TextPresenter.CaretIndexProperty, newValue); } /// @@ -953,18 +964,8 @@ namespace Avalonia.Controls { _presenter = e.NameScope.Get("PART_TextPresenter"); - if (_scrollViewer != null) - { - _scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged; - } - _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); - if (_scrollViewer != null) - { - _scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged; - } - _imClient.SetPresenter(_presenter, this); if (IsFocused) @@ -973,11 +974,6 @@ namespace Avalonia.Controls } } - private void ScrollViewer_ScrollChanged(object? sender, ScrollChangedEventArgs e) - { - _presenter?.TextSelectionHandleCanvas?.MoveHandlesToSelection(); - } - protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -1092,7 +1088,7 @@ namespace Avalonia.Controls { base.OnGotFocus(e); - if(_presenter != null) + if (_presenter != null) { _presenter.ShowSelectionHighlight = true; } @@ -1112,6 +1108,9 @@ namespace Avalonia.Controls _imClient.SetPresenter(_presenter, this); _presenter?.ShowCaret(); + + if (SelectionStart != SelectionEnd) + _presenter?.TextSelectionHandleCanvas?.ShowOnFocused(); } protected override void OnLostFocus(RoutedEventArgs e) @@ -1670,6 +1669,60 @@ namespace Avalonia.Controls } } + protected override void OnHolding(HoldingRoutedEventArgs e) + { + base.OnHolding(e); + + if (_presenter == null || e.HoldingState != HoldingState.Started) + { + _isInTouchSelectionMode = e.HoldingState == HoldingState.Canceled; + _hasTouchSelection = false; + return; + } + + var text = Text; + + using var _ = _imClient.BeginChange(); + + if (text != null) + { + var clickInfo = e.PointerEventArgs.GetCurrentPoint(this); + _presenter.MoveCaretToPoint(clickInfo.Position); + var caretIndex = _presenter.CaretIndex; + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; + var isInSelection = selectionStart != selectionEnd && + caretIndex >= selectionStart && caretIndex <= selectionEnd; + + if (isInSelection) + { + _presenter.RaiseEvent(new ContextRequestedEventArgs(e.PointerEventArgs)); + } + else + { + // We select the current held word, or the whole hidden content + if (IsPasswordBox && !RevealPassword) + { + _wordSelectionStart = -1; + + SelectAll(); + } + else + { + selectionStart = selectionEnd = caretIndex; + + SelectWord(text, caretIndex, selectionStart, selectionEnd); + + _presenter?.TextSelectionHandleCanvas?.ShowOnFocused(); + } + } + + _hasTouchSelection = true; + + e.Handled = true; + } + } + protected override void OnPointerPressed(PointerPressedEventArgs e) { if (_presenter == null) @@ -1682,112 +1735,184 @@ namespace Avalonia.Controls using var _ = _imClient.BeginChange(); - if (text != null && (e.Pointer.Type == PointerType.Mouse || e.ClickCount >= 2) && clickInfo.Properties.IsLeftButtonPressed && - !(clickInfo.Pointer?.Captured is Border)) + _isInTouchMode = false; + _isInTouchSelectionMode = false; + _isDoubleTapped = e.ClickCount == 2; + if (text != null && clickInfo.Pointer?.Captured is not Border) { - _currentClickCount = e.ClickCount; - var point = e.GetPosition(_presenter); + if (e.Pointer.Type == PointerType.Mouse && clickInfo.Properties.IsLeftButtonPressed) + { + _currentClickCount = e.ClickCount; + var point = e.GetPosition(_presenter); - _presenter.MoveCaretToPoint(point); + _presenter.MoveCaretToPoint(point); - var caretIndex = _presenter.CaretIndex; - var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); - var selectionStart = SelectionStart; - var selectionEnd = SelectionEnd; + var caretIndex = _presenter.CaretIndex; + var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; - switch (e.ClickCount) - { - case 1: - if (clickToSelect) - { - if (_wordSelectionStart >= 0) + switch (e.ClickCount) + { + case 1: + if (clickToSelect) { - UpdateWordSelectionRange(caretIndex, ref selectionStart, ref selectionEnd); + if (_wordSelectionStart >= 0) + { + UpdateWordSelectionRange(caretIndex, ref selectionStart, ref selectionEnd); - SetCurrentValue(SelectionStartProperty, selectionStart); - SetCurrentValue(SelectionEndProperty, selectionEnd); + SetCurrentValue(SelectionStartProperty, selectionStart); + SetCurrentValue(SelectionEndProperty, selectionEnd); + } + else + { + SetCurrentValue(SelectionEndProperty, caretIndex); + } } else { + SetCurrentValue(SelectionStartProperty, caretIndex); SetCurrentValue(SelectionEndProperty, caretIndex); + _wordSelectionStart = -1; } - } - else - { - SetCurrentValue(SelectionStartProperty, caretIndex); - SetCurrentValue(SelectionEndProperty, caretIndex); - _wordSelectionStart = -1; - } - break; - case 2: - if (IsPasswordBox && !RevealPassword) - { - // double-clicking in a cloaked single-line password box selects all text - // see https://github.com/AvaloniaUI/Avalonia/issues/14956 - goto case 3; - } + break; + case 2: + SelectWord(text, caretIndex, selectionStart, selectionEnd); - if (!StringUtils.IsStartOfWord(text, caretIndex)) - { - selectionStart = StringUtils.PreviousWord(text, caretIndex); - } + break; + case 3: + _wordSelectionStart = -1; - if (!StringUtils.IsEndOfWord(text, caretIndex)) - { - selectionEnd = StringUtils.NextWord(text, caretIndex); - } + SelectAll(); + break; + } + } + else if (e.Pointer.Type != PointerType.Mouse) + { + _isInTouchMode = true; + _lastPoint = e.GetCurrentPoint(_presenter).Position; - if (selectionStart != selectionEnd) + if (_isDoubleTapped) + { + var oldCaret = _presenter.CaretIndex; + _presenter.MoveCaretToPoint(_lastPoint); + var caretIndex = _presenter.CaretIndex; + + if(Math.Abs(oldCaret - caretIndex) > 3) { - _wordSelectionStart = selectionStart; + return; } + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; - SetCurrentValue(SelectionStartProperty, selectionStart); - SetCurrentValue(SelectionEndProperty, selectionEnd); - - break; - case 3: - _wordSelectionStart = -1; - - SelectAll(); - break; + SelectWord(text, caretIndex, selectionStart, selectionEnd); + } } } - _isDoubleTapped = e.ClickCount == 2; e.Pointer.Capture(_presenter); e.Handled = true; } + private void SelectWord(string text, int caretIndex, int selectionStart, int selectionEnd) + { + if (IsPasswordBox && !RevealPassword) + { + // double-clicking in a cloaked single-line password box selects all text + // see https://github.com/AvaloniaUI/Avalonia/issues/14956 + _wordSelectionStart = -1; + + SelectAll(); + } + + if (!StringUtils.IsStartOfWord(text, caretIndex)) + { + selectionStart = StringUtils.PreviousWord(text, caretIndex); + } + + if (!StringUtils.IsEndOfWord(text, caretIndex)) + { + selectionEnd = StringUtils.NextWord(text, caretIndex); + } + + if (selectionStart != selectionEnd) + { + _wordSelectionStart = selectionStart; + } + + SetCurrentValue(SelectionStartProperty, selectionStart); + SetCurrentValue(SelectionEndProperty, selectionEnd); + } + protected override void OnPointerMoved(PointerEventArgs e) { - if (_presenter == null || _isHolding) + if (_presenter == null) { return; } using var _ = _imClient.BeginChange(); + var point = e.GetPosition(_presenter); - // selection should not change during pointer move if the user right clicks - if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + if (e.Pointer.Type == PointerType.Mouse) { - var point = e.GetPosition(_presenter); + // selection should not change during pointer move if the user right clicks + if (e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + point = new Point( + MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)), + MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0))); - point = new Point( - MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)), - MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0))); + var previousIndex = _presenter.CaretIndex; - var previousIndex = _presenter.CaretIndex; + _presenter.MoveCaretToPoint(point); - _presenter.MoveCaretToPoint(point); + var caretIndex = _presenter.CaretIndex; - var caretIndex = _presenter.CaretIndex; + if (Math.Abs(caretIndex - previousIndex) == 1) + e.PreventGestureRecognition(); + + if (e.Pointer.Type == PointerType.Mouse || _isDoubleTapped) + { + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; - if (Math.Abs(caretIndex - previousIndex) == 1) - e.PreventGestureRecognition(); + if (_wordSelectionStart >= 0) + { + UpdateWordSelectionRange(caretIndex, ref selectionStart, ref selectionEnd); - if (e.Pointer.Type == PointerType.Mouse || _isDoubleTapped) + SetCurrentValue(SelectionStartProperty, selectionStart); + SetCurrentValue(SelectionEndProperty, selectionEnd); + } + else + { + SetCurrentValue(SelectionEndProperty, caretIndex); + } + } + else + { + SetCurrentValue(SelectionStartProperty, caretIndex); + SetCurrentValue(SelectionEndProperty, caretIndex); + } + } + } + else if (_isInTouchMode) + { + if (_isInTouchSelectionMode) { + point = new Point( + MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)), + MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0))); + + var previousIndex = _presenter.CaretIndex; + + _presenter.MoveCaretToPoint(point); + + var caretIndex = _presenter.CaretIndex; + + if (Math.Abs(caretIndex - previousIndex) == 1) + e.PreventGestureRecognition(); + var selectionStart = SelectionStart; var selectionEnd = SelectionEnd; @@ -1805,8 +1930,28 @@ namespace Avalonia.Controls } else { - SetCurrentValue(SelectionStartProperty, caretIndex); - SetCurrentValue(SelectionEndProperty, caretIndex); + if (!_isInTouchCaretMode) + { + + var touchRect = new Rect(_lastPoint.X, _lastPoint.Y, 0, 0).Inflate(s_touchRadius); + var isInRect = touchRect.X < point.X && + touchRect.Y < point.Y && + touchRect.Right > point.X && + touchRect.Bottom > point.Y; + if (!isInRect) + { + _isInTouchCaretMode = true; + } + } + + if (_isInTouchCaretMode) + { + e.PreventGestureRecognition(); + _presenter.MoveCaretToPoint(point); + var caretIndex = _presenter.CaretIndex; + SetCurrentValue(SelectionStartProperty, caretIndex); + SetCurrentValue(SelectionEndProperty, caretIndex); + } } } } @@ -1851,52 +1996,22 @@ namespace Avalonia.Controls using var _ = _imClient.BeginChange(); - if (e.Pointer.Type != PointerType.Mouse && !_isDoubleTapped) + if (e.Pointer.Type != PointerType.Mouse && !_isInTouchSelectionMode) { - var text = Text; - var clickInfo = e.GetCurrentPoint(this); - if (text != null && !(clickInfo.Pointer?.Captured is Border)) + if (!_isDoubleTapped && !_hasTouchSelection) { var point = e.GetPosition(_presenter); _presenter.MoveCaretToPoint(point); var caretIndex = _presenter.CaretIndex; - var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); - var selectionStart = SelectionStart; - var selectionEnd = SelectionEnd; - - if (clickToSelect) - { - if (_wordSelectionStart >= 0) - { - UpdateWordSelectionRange(caretIndex, ref selectionStart, ref selectionEnd); - - SetCurrentValue(SelectionStartProperty, selectionStart); - SetCurrentValue(SelectionEndProperty, selectionEnd); - } - else - { - SetCurrentValue(SelectionEndProperty, caretIndex); - } - } - else - { - SetCurrentValue(SelectionStartProperty, caretIndex); - SetCurrentValue(SelectionEndProperty, caretIndex); - _wordSelectionStart = -1; - } - - _presenter.TextSelectionHandleCanvas?.MoveHandlesToSelection(); + SetCurrentValue(CaretIndexProperty, caretIndex); + SetCurrentValue(SelectionEndProperty, caretIndex); + SetCurrentValue(SelectionStartProperty, caretIndex); } } - // Don't update selection if the pointer was held - if (_isHolding) - { - _isHolding = false; - } - else if (e.InitialPressMouseButton == MouseButton.Right) + if (e.InitialPressMouseButton == MouseButton.Right) { var point = e.GetPosition(_presenter); @@ -1917,26 +2032,10 @@ namespace Avalonia.Controls SetCurrentValue(SelectionStartProperty, caretIndex); } } - else if (e.Pointer.Type == PointerType.Touch) - { - if (_currentClickCount == 1) - { - var point = e.GetPosition(_presenter); - - _presenter.MoveCaretToPoint(point); - - var caretIndex = _presenter.CaretIndex; - SetCurrentValue(SelectionStartProperty, caretIndex); - SetCurrentValue(SelectionEndProperty, caretIndex); - } - - _presenter.TextSelectionHandleCanvas?.Show(); - - if (SelectionStart != SelectionEnd) - { - _presenter.TextSelectionHandleCanvas?.ShowContextMenu(); - } - } + _isInTouchMode = false; + _isInTouchSelectionMode = false; + _isInTouchCaretMode = false; + _hasTouchSelection = false; e.Pointer.Capture(null); } diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index 12e8e97640..84efc9c174 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -133,6 +133,7 @@ namespace Avalonia.Controls if (oldPresenter != null) { + oldPresenter.CurrentImClient = null; oldPresenter.ClearValue(TextPresenter.PreeditTextProperty); oldPresenter.CaretBoundsChanged -= (s, e) => RaiseCursorRectangleChanged(); @@ -142,6 +143,8 @@ namespace Avalonia.Controls if (_presenter != null) { + + _presenter.CurrentImClient = this; _presenter.CaretBoundsChanged += (s, e) => RaiseCursorRectangleChanged(); }