diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 2e1bfe100d..13e2a02fe5 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -212,14 +212,6 @@ namespace Avalonia.Controls /// bool IDataTemplateHost.IsDataTemplatesInitialized => _dataTemplates != null; - static Control() - { - Gestures.HoldingEvent.AddClassHandler((control, e) => - { - control.OnHoldEvent(control, e); - }); - } - /// void ISetterValue.Initialize(SetterBase setter) { @@ -382,6 +374,8 @@ namespace Avalonia.Controls InitializeIfNeeded(); ScheduleOnLoadedCore(); + + Holding += OnHoldEvent; } private void OnHoldEvent(object? sender, HoldingRoutedEventArgs e) @@ -399,6 +393,8 @@ namespace Avalonia.Controls base.OnDetachedFromVisualTreeCore(e); OnUnloadedCore(); + + Holding -= OnHoldEvent; } /// diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index a837f10eee..d7ecc2e90a 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -1,14 +1,15 @@ using System; using System.Collections.Generic; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Media.Immutable; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; using Avalonia.Threading; using Avalonia.Utilities; using Avalonia.VisualTree; -using Avalonia.Layout; -using Avalonia.Media.Immutable; -using Avalonia.Controls.Documents; namespace Avalonia.Controls.Presenters { @@ -94,6 +95,8 @@ namespace Avalonia.Controls.Presenters private CharacterHit _lastCharacterHit; private Rect _caretBounds; private Point _navigationPosition; + private Point? _previousOffset; + private TextSelectorLayer? _layer; static TextPresenter() { @@ -298,6 +301,8 @@ namespace Avalonia.Controls.Presenters protected override bool BypassFlowDirectionPolicies => true; + internal TextSelectionHandleCanvas? TextSelectionHandleCanvas { get; set; } + /// /// Creates the used to render the text. /// @@ -374,9 +379,19 @@ namespace Avalonia.Controls.Presenters } } + if(VisualRoot is Visual root) + { + var offset = this.TranslatePoint(Bounds.Position, root); + + if(_previousOffset != offset) + { + _previousOffset = offset; + } + } + RenderInternal(context); - if (selectionStart != selectionEnd || !_caretBlink) + if ((selectionStart != selectionEnd || !_caretBlink)) { return; } @@ -406,7 +421,7 @@ namespace Avalonia.Controls.Presenters context.DrawLine(new ImmutablePen(caretBrush), p1, p2); } - private (Point, Point) GetCaretPoints() + internal (Point, Point) GetCaretPoints() { var x = Math.Floor(_caretBounds.X) + 0.5; var y = Math.Floor(_caretBounds.Y) + 0.5; @@ -434,6 +449,10 @@ namespace Avalonia.Controls.Presenters public void HideCaret() { _caretBlink = false; + if (TextSelectionHandleCanvas != null) + { + TextSelectionHandleCanvas.ShowHandles = false; + } _caretTimer.Stop(); InvalidateVisual(); } @@ -830,11 +849,25 @@ namespace Avalonia.Controls.Presenters base.OnAttachedToVisualTree(e); _caretTimer.Tick += CaretTimerTick; + + if (TextSelectionHandleCanvas == null) + { + TextSelectionHandleCanvas = new TextSelectionHandleCanvas(); + } + + _layer = TextSelectorLayer.GetTextSelectorLayer(this); + _layer?.Add(TextSelectionHandleCanvas); + TextSelectionHandleCanvas.SetPresenter(this); } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); + if (TextSelectionHandleCanvas is { } c) + { + _layer?.Remove(c); + c.SetPresenter(null); + } _caretTimer.Stop(); diff --git a/src/Avalonia.Controls/Primitives/SelectionHandleType.cs b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs new file mode 100644 index 0000000000..2e1955de26 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/SelectionHandleType.cs @@ -0,0 +1,23 @@ +namespace Avalonia.Controls.Primitives +{ + /// + /// Represents which part of the selection the TextSelectionHandle controls. + /// + public enum SelectionHandleType + { + /// + /// The Handle controls the caret position. + /// + Caret, + + /// + /// The Handle controls the start of the text selection. + /// + Start, + + /// + /// The Handle controls the end of the text selection. + /// + End + } +} diff --git a/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs new file mode 100644 index 0000000000..c9a58aed0b --- /dev/null +++ b/src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs @@ -0,0 +1,387 @@ +using System; +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives +{ + internal class TextSelectionHandleCanvas : Canvas + { + private const int ContextMenuPadding = 16; + + private readonly TextSelectionHandle _caretHandle; + private readonly TextSelectionHandle _startHandle; + private readonly TextSelectionHandle _endHandle; + private TextPresenter? _presenter; + private TextBox? _textBox; + private bool _showHandle; + + internal bool ShowHandles + { + get => _showHandle; + set + { + _showHandle = value; + + if (!value) + { + _startHandle.IsVisible = false; + _endHandle.IsVisible = false; + _caretHandle.IsVisible = false; + } + + IsVisible = value; + } + } + + public TextSelectionHandleCanvas() + { + _caretHandle = new TextSelectionHandle() { SelectionHandleType = SelectionHandleType.Caret }; + _startHandle = new TextSelectionHandle(); + _endHandle = new TextSelectionHandle(); + + Children.Add(_caretHandle); + Children.Add(_startHandle); + Children.Add(_endHandle); + + _caretHandle.DragStarted += Handle_DragStarted; + _caretHandle.DragDelta += CaretHandle_DragDelta; + _caretHandle.DragCompleted += Handle_DragCompleted; + _startHandle.DragDelta += StartHandle_DragDelta; + _startHandle.DragCompleted += Handle_DragCompleted; + _startHandle.DragStarted += Handle_DragStarted; + _endHandle.DragDelta += EndHandle_DragDelta; + _endHandle.DragCompleted += Handle_DragCompleted; + _endHandle.DragStarted += Handle_DragStarted; + + _caretHandle.Classes.Add("caret"); + _startHandle.Classes.Add("start"); + _endHandle.Classes.Add("end"); + + _startHandle.SetTopLeft(default); + _caretHandle.SetTopLeft(default); + _endHandle.SetTopLeft(default); + + IsVisible = ShowHandles; + + ClipToBounds = false; + } + + private void Handle_DragStarted(object? sender, VectorEventArgs e) + { + if (_textBox?.ContextFlyout is { } flyout) + { + flyout.Hide(); + } + } + + private void EndHandle_DragDelta(object? sender, VectorEventArgs e) + { + if (sender is TextSelectionHandle handle) + DragSelectionHandle(handle); + } + + private void StartHandle_DragDelta(object? sender, VectorEventArgs e) + { + if (sender is TextSelectionHandle handle) + DragSelectionHandle(handle); + } + + private void CaretHandle_DragDelta(object? sender, VectorEventArgs e) + { + if (_presenter != null && _textBox != null) + { + var point = ToPresenter(_caretHandle.IndicatorPosition); + _presenter.MoveCaretToPoint(point); + _textBox.SelectionStart = _textBox.SelectionEnd = _presenter.CaretIndex; + var points = _presenter.GetCaretPoints(); + + _caretHandle?.SetTopLeft(ToLayer(points.Item2)); + } + } + + private void Handle_DragCompleted(object? sender, VectorEventArgs e) + { + MoveHandlesToSelection(); + + ShowContextMenu(); + } + + private void EnsureVisible() + { + if (_textBox is { } t && t.VisualRoot is Visual r) + { + 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; + } + } + + private void DragSelectionHandle(TextSelectionHandle handle) + { + if (_presenter != null && _textBox != null) + { + if (_textBox.ContextFlyout is { } flyout) + { + flyout.Hide(); + } + + var point = ToPresenter(handle.IndicatorPosition); + point = point.WithY(point.Y - _presenter.FontSize / 2); + var hit = _presenter.TextLayout.HitTestPoint(point); + var position = hit.CharacterHit.FirstCharacterIndex + hit.CharacterHit.TrailingLength; + + var otherHandle = handle == _startHandle ? _endHandle : _startHandle; + + if (handle.SelectionHandleType == SelectionHandleType.Start) + { + if (position >= _textBox.SelectionEnd) + position = _textBox.SelectionEnd - 1; + _textBox.SelectionStart = position; + } + else + { + if (position <= _textBox.SelectionStart) + position = _textBox.SelectionStart + 1; + _textBox.SelectionEnd = position; + } + + var selectionStart = _textBox.SelectionStart; + var selectionEnd = _textBox.SelectionEnd; + var start = Math.Min(selectionStart, selectionEnd); + var length = Math.Max(selectionStart, selectionEnd) - start; + + var rects = _presenter.TextLayout.HitTestTextRange(start, length); + + if (rects.Any()) + { + var first = rects.First(); + var last = rects.Last(); + + if (handle.SelectionHandleType == SelectionHandleType.Start) + handle?.SetTopLeft(ToLayer(first.BottomLeft)); + else + handle?.SetTopLeft(ToLayer(last.BottomRight)); + + if (otherHandle.SelectionHandleType == SelectionHandleType.Start) + otherHandle?.SetTopLeft(ToLayer(first.BottomLeft)); + else + otherHandle?.SetTopLeft(ToLayer(last.BottomRight)); + } + + _presenter?.MoveCaretToTextPosition(position); + } + + EnsureVisible(); + } + + private Point ToLayer(Point point) + { + return (_presenter?.VisualRoot is Visual v) ? _presenter?.TranslatePoint(point, v) ?? point : point; + } + + private Point ToPresenter(Point point) + { + 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) + { + return; + } + + var hasSelection = _textBox.SelectionStart != _textBox.SelectionEnd; + + var points = _presenter.GetCaretPoints(); + + _caretHandle.SetTopLeft(ToLayer(points.Item2)); + + if (hasSelection) + { + var selectionStart = _textBox.SelectionStart; + var selectionEnd = _textBox.SelectionEnd; + var start = Math.Min(selectionStart, selectionEnd); + var length = Math.Max(selectionStart, selectionEnd) - start; + + var rects = _presenter.TextLayout.HitTestTextRange(start, length); + + if (rects.Any()) + { + var first = rects.First(); + var last = rects.Last(); + + if (!_startHandle.IsDragging) + { + _startHandle.SetTopLeft(ToLayer(first.BottomLeft)); + _startHandle.SelectionHandleType = selectionStart < selectionEnd ? + SelectionHandleType.Start : + SelectionHandleType.End; + } + + if (!_endHandle.IsDragging) + { + _endHandle.SetTopLeft(ToLayer(last.BottomRight)); + _endHandle.SelectionHandleType = selectionStart > selectionEnd ? + SelectionHandleType.Start : + SelectionHandleType.End; + } + } + } + } + + internal void SetPresenter(TextPresenter? textPresenter) + { + _presenter = textPresenter; + if (_presenter != null) + { + _textBox = _presenter.FindAncestorOfType(); + + if (_textBox != null) + { + _textBox.AddHandler(TextBox.TextChangingEvent, TextChanged, handledEventsToo: true); + _textBox.AddHandler(KeyDownEvent, TextBoxKeyDown, handledEventsToo: true); + _textBox.AddHandler(LostFocusEvent, TextBoxLostFocus, handledEventsToo: true); + _textBox.AddHandler(PointerReleasedEvent, TextBoxPointerReleased, handledEventsToo: true); + _textBox.AddHandler(Gestures.HoldingEvent, TextBoxHolding, handledEventsToo: true); + + _textBox.PropertyChanged += TextBoxPropertyChanged; + _textBox.EffectiveViewportChanged += TextBoxEffectiveViewportChanged; + } + } + else + { + if (_textBox != null) + { + _textBox.RemoveHandler(TextBox.TextChangingEvent, TextChanged); + _textBox.RemoveHandler(KeyDownEvent, TextBoxKeyDown); + _textBox.RemoveHandler(PointerReleasedEvent, TextBoxPointerReleased); + _textBox.RemoveHandler(LostFocusEvent, TextBoxLostFocus); + _textBox.RemoveHandler(Gestures.HoldingEvent, TextBoxHolding); + + _textBox.PropertyChanged -= TextBoxPropertyChanged; + _textBox.EffectiveViewportChanged -= TextBoxEffectiveViewportChanged; + } + + _textBox = null; + } + } + + private void TextBoxEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) + { + if (ShowHandles) + { + MoveHandlesToSelection(); + EnsureVisible(); + } + } + + private void TextBoxHolding(object? sender, HoldingRoutedEventArgs e) + { + if (ShowContextMenu()) + e.Handled = true; + } + + internal bool ShowContextMenu() + { + if (_textBox != null) + { + if (_textBox.ContextFlyout is PopupFlyoutBase flyout) + { + var verticalOffset = (double.IsNaN(_textBox.LineHeight) ? _textBox.FontSize : _textBox.LineHeight) + + ContextMenuPadding; + + TextSelectionHandle? handle = null; + + if (_textBox.SelectionStart != _textBox.SelectionEnd) + { + if (_startHandle.IsEffectivelyVisible) + handle = _startHandle; + else if (_endHandle.IsEffectivelyVisible) + handle = _endHandle; + } + else + { + if (_caretHandle.IsEffectivelyVisible) + { + handle = _caretHandle; + } + } + + if (handle != null) + { + var topLeft = ToTextBox(handle.GetTopLeft()); + flyout.VerticalOffset = topLeft.Y - verticalOffset; + flyout.HorizontalOffset = topLeft.X; + flyout.Placement = PlacementMode.TopEdgeAlignedLeft; + _textBox.RaiseEvent(new ContextRequestedEventArgs()); + + return true; + } + } + else + { + _textBox.RaiseEvent(new ContextRequestedEventArgs()); + } + } + + return false; + } + + private void TextBoxPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (e.Pointer.Type != PointerType.Mouse) + { + ShowHandles = true; + + MoveHandlesToSelection(); + EnsureVisible(); + } + } + + private void TextBoxPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (ShowHandles && (e.Property == TextBox.SelectionStartProperty || + e.Property == TextBox.SelectionEndProperty)) + { + MoveHandlesToSelection(); + EnsureVisible(); + } + } + + private void TextBoxLostFocus(object? sender, RoutedEventArgs e) + { + ShowHandles = false; + } + + private void TextBoxKeyDown(object? sender, KeyEventArgs e) + { + ShowHandles = false; + } + + private void TextChanged(object? sender, TextChangingEventArgs e) + { + ShowHandles = false; + if (_textBox?.ContextFlyout is { } flyout && flyout.IsOpen) + flyout.Hide(); + } + } +} diff --git a/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs b/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs new file mode 100644 index 0000000000..d9bf8b5529 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/TextSelectionHandle.cs @@ -0,0 +1,184 @@ +using System; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace Avalonia.Controls.Primitives +{ + /// + /// A controls that enables easy control over text selection using touch based input + /// + public class TextSelectionHandle : Thumb + { + internal SelectionHandleType SelectionHandleType { get; set; } + + private Point _startPosition; + + internal Point IndicatorPosition => IsDragging ? _startPosition.WithX(_startPosition.X) + _delta : Bounds.Position.WithX(Bounds.Position.X).WithY(Bounds.Y); + + internal bool IsDragging { get; private set; } + + private Vector _delta; + private Point? _lastPoint; + private TranslateTransform? _transform; + + static TextSelectionHandle() + { + DragStartedEvent.AddClassHandler((x, e) => x.OnDragStarted(e), RoutingStrategies.Bubble); + DragDeltaEvent.AddClassHandler((x, e) => x.OnDragDelta(e), RoutingStrategies.Bubble); + DragCompletedEvent.AddClassHandler((x, e) => x.OnDragCompleted(e), RoutingStrategies.Bubble); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (_transform == null) + { + _transform = new TranslateTransform(); + } + + RenderTransform = _transform; + } + + protected override void OnLoaded(RoutedEventArgs args) + { + base.OnLoaded(args); + + InvalidateMeasure(); + } + protected override void OnDragStarted(VectorEventArgs e) + { + base.OnDragStarted(e); + + _startPosition = Bounds.Position; + _delta = default; + IsDragging = true; + } + + protected override void OnDragDelta(VectorEventArgs e) + { + base.OnDragDelta(e); + + _delta = e.Vector; + UpdateTextSelectionHandlePosition(); + } + + protected override void OnDragCompleted(VectorEventArgs e) + { + base.OnDragCompleted(e); + IsDragging = false; + + _startPosition = default; + } + + private void UpdateTextSelectionHandlePosition() + { + SetTopLeft(IndicatorPosition); + } + + protected override void ArrangeCore(Rect finalRect) + { + base.ArrangeCore(finalRect); + + if (_transform != null) + { + if (SelectionHandleType == SelectionHandleType.Caret) + { + HasMirrorTransform = true; + _transform.X = Bounds.Width / 2 * -1; + } + else if (SelectionHandleType == SelectionHandleType.Start) + { + HasMirrorTransform = true; + _transform.X = Bounds.Width * -1; + } + else + { + HasMirrorTransform = false; + _transform.X = 0; + } + } + } + + internal void SetTopLeft(Point point) + { + Canvas.SetTop(this, point.Y); + Canvas.SetLeft(this, point.X); + } + + internal Point GetTopLeft() + { + return new Point(Canvas.GetLeft(this), Canvas.GetTop(this)); + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + if (_lastPoint.HasValue) + { + var ev = new VectorEventArgs + { + RoutedEvent = DragCompletedEvent, + Vector = _lastPoint.Value, + }; + + _lastPoint = null; + + RaiseEvent(ev); + } + + PseudoClasses.Remove(":pressed"); + + base.OnPointerCaptureLost(e); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (_lastPoint.HasValue) + { + var ev = new VectorEventArgs + { + RoutedEvent = DragDeltaEvent, + Vector = e.GetPosition(VisualRoot as Visual) - _lastPoint.Value, + }; + + RaiseEvent(ev); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + e.Handled = true; + _lastPoint = e.GetPosition(VisualRoot as Visual); + + var ev = new VectorEventArgs + { + RoutedEvent = DragStartedEvent, + Vector = (Vector)_lastPoint, + }; + + PseudoClasses.Add(":pressed"); + + RaiseEvent(ev); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (_lastPoint.HasValue) + { + e.Handled = true; + _lastPoint = null; + + var ev = new VectorEventArgs + { + RoutedEvent = DragCompletedEvent, + Vector = (Vector)e.GetPosition(VisualRoot as Visual), + }; + + RaiseEvent(ev); + } + + PseudoClasses.Remove(":pressed"); + } + } +} diff --git a/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs b/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs new file mode 100644 index 0000000000..07eef005c3 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/TextSelectorLayer.cs @@ -0,0 +1,49 @@ +using System.Linq; +using Avalonia.Rendering; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives +{ + public class TextSelectorLayer : Canvas + { + protected override bool BypassFlowDirectionPolicies => true; + public Size AvailableSize { get; private set; } + public static TextSelectorLayer? GetTextSelectorLayer(Visual visual) + { + foreach (var v in visual.GetVisualAncestors()) + if (v is VisualLayerManager vlm) + if (vlm.TextSelectorLayer != null) + return vlm.TextSelectorLayer; + if (visual is TopLevel tl) + { + var layers = tl.GetVisualDescendants().OfType().FirstOrDefault(); + return layers?.TextSelectorLayer; + } + + return null; + } + + protected override Size MeasureOverride(Size availableSize) + { + foreach (Control child in Children) + child.Measure(availableSize); + return availableSize; + } + + protected override Size ArrangeOverride(Size finalSize) + { + AvailableSize = finalSize; + return base.ArrangeOverride(finalSize); + } + + public void Add(Control control) + { + Children.Add(control); + } + + public void Remove(Control control) + { + Children.Remove(control); + } + } +} diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs index bc47462b56..d188a32940 100644 --- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs +++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs @@ -9,6 +9,7 @@ namespace Avalonia.Controls.Primitives private const int ChromeZIndex = int.MaxValue - 99; private const int LightDismissOverlayZIndex = int.MaxValue - 98; private const int OverlayZIndex = int.MaxValue - 97; + private const int TextSelectorLayerZIndex = int.MaxValue - 96; private ILogicalRoot? _logicalRoot; private readonly List _layers = new(); @@ -65,6 +66,19 @@ namespace Avalonia.Controls.Primitives } } + public TextSelectorLayer? TextSelectorLayer + { + get + { + if (IsPopup) + return null; + var rv = FindLayer(); + if (rv == null) + AddLayer(rv = new TextSelectorLayer(), TextSelectorLayerZIndex); + return rv; + } + } + public LightDismissOverlayLayer LightDismissOverlayLayer { get diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 5d2b7bcb53..b8a141eb5b 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -316,13 +316,11 @@ namespace Avalonia.Controls private bool _canRedo; private int _wordSelectionStart = -1; - private bool _touchDragStarted; - private bool _inTouchDrag; - private Point _touchDragStartPoint; private int _selectedTextChangesMadeSinceLastUndoSnapshot; private bool _hasDoneSnapshotOnce; private static bool _isHolding; private int _currentClickCount; + private bool _isDoubleTapped; private const int _maxCharsBeforeUndoSnapshot = 7; static TextBox() @@ -803,8 +801,18 @@ 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) @@ -813,6 +821,11 @@ namespace Avalonia.Controls } } + private void ScrollViewer_ScrollChanged(object? sender, ScrollChangedEventArgs e) + { + _presenter?.TextSelectionHandleCanvas?.MoveHandlesToSelection(); + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -826,16 +839,6 @@ namespace Avalonia.Controls _presenter.PropertyChanged += PresenterPropertyChanged; } - - AddHandler(Gestures.HoldingEvent, OnHolding); - } - - private void OnHolding(object? sender, HoldingRoutedEventArgs e) - { - if (e.Handled || e.HoldingState != HoldingState.Started) - return; - - _isHolding = true; } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) @@ -850,8 +853,6 @@ namespace Avalonia.Controls } _imClient.SetPresenter(null, null); - - RemoveHandler(Gestures.HoldingEvent, OnHolding); } private void PresenterPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) @@ -1470,24 +1471,12 @@ namespace Avalonia.Controls var text = Text; var clickInfo = e.GetCurrentPoint(this); - if (text != null && clickInfo.Properties.IsLeftButtonPressed && + if (text != null && (e.Pointer.Type == PointerType.Mouse || e.ClickCount >= 2) && clickInfo.Properties.IsLeftButtonPressed && !(clickInfo.Pointer?.Captured is Border)) { - var isTouch = e.Pointer.Type == PointerType.Touch; - _currentClickCount = e.ClickCount; var point = e.GetPosition(_presenter); - if(isTouch && e.ClickCount == 1) - { - _wordSelectionStart = -1; - _touchDragStarted = true; - _touchDragStartPoint = point; - e.Pointer.Capture(_presenter); - e.Handled = true; - return; - } - _presenter.MoveCaretToPoint(point); var caretIndex = _presenter.CaretIndex; @@ -1548,6 +1537,7 @@ namespace Avalonia.Controls } } + _isDoubleTapped = e.ClickCount == 2; e.Pointer.Capture(_presenter); e.Handled = true; } @@ -1568,32 +1558,35 @@ namespace Avalonia.Controls MathUtilities.Clamp(point.X, 0, Math.Max(_presenter.Bounds.Width - 1, 0)), MathUtilities.Clamp(point.Y, 0, Math.Max(_presenter.Bounds.Height - 1, 0))); - if(_touchDragStarted) - { - _touchDragStarted = false; - _inTouchDrag = true; - - _presenter.MoveCaretToPoint(_touchDragStartPoint); - _touchDragStartPoint = default; - SetCurrentValue(SelectionStartProperty, _presenter.CaretIndex); - } + var previousIndex = _presenter.CaretIndex; _presenter.MoveCaretToPoint(point); var caretIndex = _presenter.CaretIndex; - var selectionStart = SelectionStart; - var selectionEnd = SelectionEnd; + if (Math.Abs(caretIndex - previousIndex) == 1) + e.PreventGestureRecognition(); - if (_wordSelectionStart >= 0) + if (e.Pointer.Type == PointerType.Mouse) { - UpdateWordSelectionRange(caretIndex, ref selectionStart, ref selectionEnd); + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; - SetCurrentValue(SelectionStartProperty, selectionStart); - SetCurrentValue(SelectionEndProperty, selectionEnd); + 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); } } @@ -1632,17 +1625,51 @@ namespace Avalonia.Controls return; } - _touchDragStarted = false; - _touchDragStartPoint = default; - - var isInTouchDrag = _inTouchDrag; - _inTouchDrag = false; - if (e.Pointer.Captured != _presenter) { return; } + if (e.Pointer.Type != PointerType.Mouse && !_isDoubleTapped) + { + var text = Text; + var clickInfo = e.GetCurrentPoint(this); + if (text != null && !(clickInfo.Pointer?.Captured is Border)) + { + 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(); + } + } + // Don't update selection if the pointer was held if (_isHolding) { @@ -1671,7 +1698,7 @@ namespace Avalonia.Controls } else if (e.Pointer.Type == PointerType.Touch) { - if (_currentClickCount == 1 && !isInTouchDrag) + if (_currentClickCount == 1) { var point = e.GetPosition(_presenter); @@ -1684,7 +1711,7 @@ namespace Avalonia.Controls if(SelectionStart != SelectionEnd) { - RaiseEvent(new ContextRequestedEventArgs(e)); + _presenter.TextSelectionHandleCanvas?.ShowContextMenu(); } } diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index 73e8595b11..c906827e50 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -207,6 +207,10 @@ 1 + + 32 + 2,0,2,0 + 8,4,8,7 @@ -996,6 +1000,10 @@ 1 + + 32 + 2,0,2,0 + 8,4,8,7 diff --git a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml index bcb5efe80a..34aecd03e0 100644 --- a/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml @@ -73,6 +73,7 @@ + diff --git a/src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml b/src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml index b5dff7b59d..c12d4fcc4e 100644 --- a/src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml @@ -32,4 +32,12 @@ + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml b/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml index 8a2d76b940..3cf6261683 100644 --- a/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml @@ -146,7 +146,6 @@ - + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml b/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml index 2c05ad6f64..e7704ee4e5 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TextBox.xaml @@ -20,12 +20,17 @@ M 11.416016,10 20,1.4160156 18.583984,0 10,8.5839846 1.4160156,0 0,1.4160156 8.5839844,10 0,18.583985 1.4160156,20 10,11.416015 18.583984,20 20,18.583985 Z m10.051 7.0032c2.215 0 4.0105 1.7901 4.0105 3.9984s-1.7956 3.9984-4.0105 3.9984c-2.215 0-4.0105-1.7901-4.0105-3.9984s1.7956-3.9984 4.0105-3.9984zm0 1.4994c-1.3844 0-2.5066 1.1188-2.5066 2.499s1.1222 2.499 2.5066 2.499 2.5066-1.1188 2.5066-2.499-1.1222-2.499-2.5066-2.499zm0-5.0026c4.6257 0 8.6188 3.1487 9.7267 7.5613 0.10085 0.40165-0.14399 0.80877-0.54686 0.90931-0.40288 0.10054-0.81122-0.14355-0.91208-0.54521-0.94136-3.7492-4.3361-6.4261-8.2678-6.4261-3.9334 0-7.3292 2.6792-8.2689 6.4306-0.10063 0.40171-0.50884 0.64603-0.91177 0.54571s-0.648-0.5073-0.54737-0.90901c1.106-4.4152 5.1003-7.5667 9.728-7.5667z m0.21967 0.21965c-0.26627 0.26627-0.29047 0.68293-0.07262 0.97654l0.07262 0.08412 4.0346 4.0346c-1.922 1.3495-3.3585 3.365-3.9554 5.7495-0.10058 0.4018 0.14362 0.8091 0.54543 0.9097 0.40182 0.1005 0.80909-0.1436 0.90968-0.5455 0.52947-2.1151 1.8371-3.8891 3.5802-5.0341l1.8096 1.8098c-0.70751 0.7215-1.1438 1.71-1.1438 2.8003 0 2.2092 1.7909 4 4 4 1.0904 0 2.0788-0.4363 2.8004-1.1438l5.9193 5.9195c0.2929 0.2929 0.7677 0.2929 1.0606 0 0.2663-0.2662 0.2905-0.6829 0.0726-0.9765l-0.0726-0.0841-6.1135-6.1142 0.0012-0.0015-1.2001-1.1979-2.8699-2.8693 2e-3 -8e-4 -2.8812-2.8782 0.0012-0.0018-1.1333-1.1305-4.3064-4.3058c-0.29289-0.29289-0.76777-0.29289-1.0607 0zm7.9844 9.0458 3.5351 3.5351c-0.45 0.4358-1.0633 0.704-1.7392 0.704-1.3807 0-2.5-1.1193-2.5-2.5 0-0.6759 0.26824-1.2892 0.7041-1.7391zm1.7959-5.7655c-1.0003 0-1.9709 0.14807-2.8889 0.425l1.237 1.2362c0.5358-0.10587 1.0883-0.16119 1.6519-0.16119 3.9231 0 7.3099 2.6803 8.2471 6.4332 0.1004 0.4018 0.5075 0.6462 0.9094 0.5459 0.4019-0.1004 0.6463-0.5075 0.5459-0.9094-1.103-4.417-5.0869-7.5697-9.7024-7.5697zm0.1947 3.5093 3.8013 3.8007c-0.1018-2.0569-1.7488-3.7024-3.8013-3.8007z - - + + + + + + + @@ -102,7 +107,7 @@ - + diff --git a/src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml b/src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml new file mode 100644 index 0000000000..84bc104036 --- /dev/null +++ b/src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml @@ -0,0 +1,49 @@ + + 32 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml index c5fc3f3cce..51a21720b9 100644 --- a/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml +++ b/src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml @@ -71,6 +71,7 @@ + diff --git a/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml b/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml new file mode 100644 index 0000000000..c9ca5ebc63 --- /dev/null +++ b/src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml @@ -0,0 +1,52 @@ + + 32 + + + + + + + + + + + + + + + + + + + + + +