Browse Source

Implement Text Selection handles in TextBox for touch input (#13107)

* add text selection handles

* only show selectors on pen and touch input

* show mobile themed context menu on mobile

* cleanup and track textbox layout changes with EffectiveViewportChanged

* make SelectionHandleType as clr property

* fix custom flyouts not showing with selection handles

* simplify text context menu for mobile.

* cleanup

* address review, simplify styles, add docs

* remove unused resource

* remove unused event handler, reformat selection canvas class

* prevent thumb positions from swapping

* adjust caret position when touch pointer moves

* cleanup, disable scrolling in textbox when intent is selection
pull/13445/head
Emmanuel Hansen 2 years ago
committed by GitHub
parent
commit
eb5fdd2a3d
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      src/Avalonia.Controls/Control.cs
  2. 43
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  3. 23
      src/Avalonia.Controls/Primitives/SelectionHandleType.cs
  4. 387
      src/Avalonia.Controls/Primitives/TextSelectionCanvas.cs
  5. 184
      src/Avalonia.Controls/Primitives/TextSelectionHandle.cs
  6. 49
      src/Avalonia.Controls/Primitives/TextSelectorLayer.cs
  7. 14
      src/Avalonia.Controls/Primitives/VisualLayerManager.cs
  8. 129
      src/Avalonia.Controls/TextBox.cs
  9. 8
      src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml
  10. 1
      src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
  11. 8
      src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml
  12. 5
      src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml
  13. 11
      src/Avalonia.Themes.Fluent/Controls/TextBox.xaml
  14. 49
      src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml
  15. 1
      src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
  16. 52
      src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml

12
src/Avalonia.Controls/Control.cs

@ -212,14 +212,6 @@ namespace Avalonia.Controls
/// <inheritdoc/>
bool IDataTemplateHost.IsDataTemplatesInitialized => _dataTemplates != null;
static Control()
{
Gestures.HoldingEvent.AddClassHandler<Control>((control, e) =>
{
control.OnHoldEvent(control, e);
});
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>

43
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; }
/// <summary>
/// Creates the <see cref="TextLayout"/> used to render the text.
/// </summary>
@ -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();

23
src/Avalonia.Controls/Primitives/SelectionHandleType.cs

@ -0,0 +1,23 @@
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Represents which part of the selection the TextSelectionHandle controls.
/// </summary>
public enum SelectionHandleType
{
/// <summary>
/// The Handle controls the caret position.
/// </summary>
Caret,
/// <summary>
/// The Handle controls the start of the text selection.
/// </summary>
Start,
/// <summary>
/// The Handle controls the end of the text selection.
/// </summary>
End
}
}

387
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<TextBox>();
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();
}
}
}

184
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
{
/// <summary>
/// A controls that enables easy control over text selection using touch based input
/// </summary>
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<TextSelectionHandle>((x, e) => x.OnDragStarted(e), RoutingStrategies.Bubble);
DragDeltaEvent.AddClassHandler<TextSelectionHandle>((x, e) => x.OnDragDelta(e), RoutingStrategies.Bubble);
DragCompletedEvent.AddClassHandler<TextSelectionHandle>((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");
}
}
}

49
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<VisualLayerManager>().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);
}
}
}

14
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<Control> _layers = new();
@ -65,6 +66,19 @@ namespace Avalonia.Controls.Primitives
}
}
public TextSelectorLayer? TextSelectorLayer
{
get
{
if (IsPopup)
return null;
var rv = FindLayer<TextSelectorLayer>();
if (rv == null)
AddLayer(rv = new TextSelectorLayer(), TextSelectorLayerZIndex);
return rv;
}
}
public LightDismissOverlayLayer LightDismissOverlayLayer
{
get

129
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<TextPresenter>("PART_TextPresenter");
if (_scrollViewer != null)
{
_scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
}
_scrollViewer = e.NameScope.Find<ScrollViewer>("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();
}
}

8
src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml

@ -207,6 +207,10 @@
<StaticResource x:Key="MenuFlyoutPresenterBackground" ResourceKey="SystemControlTransientBackgroundBrush" />
<StaticResource x:Key="MenuFlyoutPresenterBorderBrush" ResourceKey="SystemControlTransientBorderBrush" />
<Thickness x:Key="MenuFlyoutPresenterBorderThemeThickness">1</Thickness>
<!-- Mobile Resources for MenuFlyout.xaml -->
<x:Double x:Key="HorizontalMenuFlyoutThemeMinWidth">32</x:Double>
<Thickness x:Key="HorizontalMenuFlyoutItemMargin">2,0,2,0</Thickness>
<Thickness x:Key="HorizontalMenuFlyoutItemThemePaddingNarrow">8,4,8,7</Thickness>
<!-- Resources for TextBox.xaml -->
<StaticResource x:Key="TextControlForeground" ResourceKey="SystemControlForegroundBaseHighBrush" />
@ -996,6 +1000,10 @@
<StaticResource x:Key="MenuFlyoutPresenterBackground" ResourceKey="SystemControlTransientBackgroundBrush" />
<StaticResource x:Key="MenuFlyoutPresenterBorderBrush" ResourceKey="SystemControlTransientBorderBrush" />
<Thickness x:Key="MenuFlyoutPresenterBorderThemeThickness">1</Thickness>
<!-- Mobile Resources for MenuFlyout.xaml -->
<x:Double x:Key="HorizontalMenuFlyoutThemeMinWidth">32</x:Double>
<Thickness x:Key="HorizontalMenuFlyoutItemMargin">2,0,2,0</Thickness>
<Thickness x:Key="HorizontalMenuFlyoutItemThemePaddingNarrow">8,4,8,7</Thickness>
<!-- Resources for TextBox.xaml -->
<!-- Copied from FluentBaseDark.xaml -->

1
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@ -73,6 +73,7 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/RefreshVisualizer.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ThemeVariantScope.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/HeaderedContentControl.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Styles.Resources>

8
src/Avalonia.Themes.Fluent/Controls/MenuFlyoutPresenter.xaml

@ -32,4 +32,12 @@
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="HorizontalMenuFlyoutPresenter" TargetType="MenuFlyoutPresenter" BasedOn="{StaticResource {x:Type MenuFlyoutPresenter}}">
<Setter Property="MinWidth" Value="{DynamicResource HorizontalMenuFlyoutThemeMinWidth}" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>

5
src/Avalonia.Themes.Fluent/Controls/MenuItem.xaml

@ -146,7 +146,6 @@
<Style Selector="^:icon /template/ Viewbox#PART_IconPresenter">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:selected">
<Style Selector="^ /template/ Border#PART_LayoutRoot">
<Setter Property="Background" Value="{DynamicResource MenuFlyoutItemBackgroundPointerOver}" />
@ -207,4 +206,8 @@
</Setter>
</Style>
</ControlTheme>
<ControlTheme x:Key="HorizontalMenuItem" TargetType="MenuItem" BasedOn="{StaticResource FluentTopLevelMenuItem}">
<Setter Property="Padding" Value="{DynamicResource HorizontalMenuFlyoutItemThemePaddingNarrow}" />
<Setter Property="Margin" Value="{DynamicResource HorizontalMenuFlyoutItemMargin}" />
</ControlTheme>
</ResourceDictionary>

11
src/Avalonia.Themes.Fluent/Controls/TextBox.xaml

@ -20,12 +20,17 @@
<StreamGeometry x:Key="TextBoxClearButtonData">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</StreamGeometry>
<StreamGeometry x:Key="PasswordBoxRevealButtonData">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</StreamGeometry>
<StreamGeometry x:Key="PasswordBoxHideButtonData">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</StreamGeometry>
<MenuFlyout x:Key="DefaultTextBoxContextFlyout" Placement="Bottom">
<MenuFlyout x:Key="DefaultTextBoxContextFlyout">
<MenuItem x:Name="TextBoxContextFlyoutCutItem" Header="Cut" Command="{Binding $parent[TextBox].Cut}" IsEnabled="{Binding $parent[TextBox].CanCut}" InputGesture="{x:Static TextBox.CutGesture}" />
<MenuItem x:Name="TextBoxContextFlyoutCopyItem" Header="Copy" Command="{Binding $parent[TextBox].Copy}" IsEnabled="{Binding $parent[TextBox].CanCopy}" InputGesture="{x:Static TextBox.CopyGesture}"/>
<MenuItem x:Name="TextBoxContextFlyoutPasteItem" Header="Paste" Command="{Binding $parent[TextBox].Paste}" IsEnabled="{Binding $parent[TextBox].CanPaste}" InputGesture="{x:Static TextBox.PasteGesture}"/>
</MenuFlyout>
<MenuFlyout x:Key="HorizontalTextBoxContextFlyout" FlyoutPresenterTheme="{StaticResource HorizontalMenuFlyoutPresenter}" ItemContainerTheme="{StaticResource HorizontalMenuItem}">
<MenuItem x:Name="HorizontalTextBoxContextFlyoutCutItem" Header="Cut" Command="{Binding $parent[TextBox].Cut}" IsEnabled="{Binding $parent[TextBox].CanCut}" IsVisible="{Binding $parent[TextBox].CanCut}" />
<MenuItem x:Name="HorizontalTextBoxContextFlyoutCopyItem" Header="Copy" Command="{Binding $parent[TextBox].Copy}" IsEnabled="{Binding $parent[TextBox].CanCopy}" IsVisible="{Binding $parent[TextBox].CanCopy}" />
<MenuItem x:Name="HorizontalTextBoxContextFlyoutPasteItem" Header="Paste" Command="{Binding $parent[TextBox].Paste}" IsEnabled="{Binding $parent[TextBox].CanPaste}" />
</MenuFlyout>
<ControlTheme x:Key="FluentTextBoxButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource TextControlButtonBackground}" />
@ -102,7 +107,7 @@
<Setter Property="Padding" Value="{DynamicResource TextControlThemePadding}" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="ScrollViewer.IsScrollChainingEnabled" Value="True" />
<Setter Property="ContextFlyout" Value="{StaticResource DefaultTextBoxContextFlyout}" />
<Setter Property="ContextFlyout" Value="{OnFormFactor Desktop={StaticResource DefaultTextBoxContextFlyout}, Mobile={StaticResource HorizontalTextBoxContextFlyout}}" />
<Setter Property="Template">
<ControlTemplate>
<DataValidationErrors>

49
src/Avalonia.Themes.Fluent/Controls/TextSelectionHandle.xaml

@ -0,0 +1,49 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<x:Double x:Key="TextSelectHandleSize">32</x:Double>
<GeometryGroup x:Key="TextSelectionHandlePath" FillRule="NonZero">
<RectangleGeometry Rect="0,0,16,16"/>
<EllipseGeometry Center="16,16"
RadiusX="16"
RadiusY="16"/>
</GeometryGroup>
<GeometryGroup x:Key="TextCaretHandlePath" FillRule="NonZero">
<RectangleGeometry Rect="0,0,16,16">
<RectangleGeometry.Transform>
<TransformGroup >
<RotateTransform CenterX="16"
Angle="-45"/>
</TransformGroup>
</RectangleGeometry.Transform>
</RectangleGeometry>
<EllipseGeometry Center="16,22.7"
RadiusX="16"
RadiusY="16"/>
</GeometryGroup>
<ControlTheme x:Key="{x:Type TextSelectionHandle}"
TargetType="TextSelectionHandle">
<Setter Property="Background"
Value="{DynamicResource TextControlSelectionHighlightColor}" />
<Setter Property="HorizontalAlignment"
Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Grid>
<PathIcon
Cursor="Arrow"
Name="PART_HandlePathIcon"
HorizontalAlignment="Stretch"
Height="{DynamicResource TextSelectHandleSize}"
Foreground="{TemplateBinding Background}"
Data="{DynamicResource TextSelectionHandlePath}"
/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style Selector="^.caret /template/ PathIcon#PART_HandlePathIcon">
<Setter Property="Data" Value="{DynamicResource TextCaretHandlePath}" />
</Style>
</ControlTheme>
</ResourceDictionary>

1
src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml

@ -71,6 +71,7 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/RefreshVisualizer.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/ThemeVariantScope.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/HeaderedContentControl.xaml"/>
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Styles.Resources>

52
src/Avalonia.Themes.Simple/Controls/TextSelectionHandle.xaml

@ -0,0 +1,52 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<x:Double x:Key="TextSelectHandleSize">32</x:Double>
<ControlTheme x:Key="{x:Type TextSelectionHandle}"
TargetType="TextSelectionHandle">
<Setter Property="Background"
Value="{DynamicResource HighlightBrush}" />
<Setter Property="HorizontalAlignment"
Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate>
<Grid>
<PathIcon
Cursor="Arrow"
Name="PART_HandlePathIcon"
HorizontalAlignment="Stretch"
Height="{DynamicResource TextSelectHandleSize}"
Foreground="{TemplateBinding Background}"
>
<PathIcon.Data>
<GeometryGroup FillRule="NonZero">
<RectangleGeometry Rect="0,0,16,16"/>
<EllipseGeometry Center="16,16"
RadiusX="16"
RadiusY="16"/>
</GeometryGroup>
</PathIcon.Data>
</PathIcon>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style Selector="^.caret /template/ PathIcon#PART_HandlePathIcon">
<Setter Property="Data">
<GeometryGroup FillRule="NonZero">
<RectangleGeometry Rect="0,0,16,16">
<RectangleGeometry.Transform>
<TransformGroup >
<RotateTransform CenterX="16"
Angle="-45"/>
</TransformGroup>
</RectangleGeometry.Transform>
</RectangleGeometry>
<EllipseGeometry Center="16,22.7"
RadiusX="16"
RadiusY="16"/>
</GeometryGroup>
</Setter>
</Style>
</ControlTheme>
</ResourceDictionary>
Loading…
Cancel
Save