using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Avalonia.Controls.Documents;
using Avalonia.Controls.Utils;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Platform;
using Avalonia.Utilities;
namespace Avalonia.Controls
{
///
/// A control that displays a block of formatted text.
///
public class SelectableTextBlock : TextBlock, IInlineHost
{
public static readonly StyledProperty SelectionStartProperty =
TextBox.SelectionStartProperty.AddOwner();
public static readonly StyledProperty SelectionEndProperty =
TextBox.SelectionEndProperty.AddOwner();
public static readonly DirectProperty SelectedTextProperty =
AvaloniaProperty.RegisterDirect(
nameof(SelectedText),
o => o.SelectedText);
public static readonly StyledProperty SelectionBrushProperty =
TextBox.SelectionBrushProperty.AddOwner();
public static readonly StyledProperty SelectionForegroundBrushProperty =
TextBox.SelectionForegroundBrushProperty.AddOwner();
public static readonly DirectProperty CanCopyProperty =
TextBox.CanCopyProperty.AddOwner(o => o.CanCopy);
public static readonly RoutedEvent CopyingToClipboardEvent =
RoutedEvent.Register(
nameof(CopyingToClipboard), RoutingStrategies.Bubble);
private bool _canCopy;
private int _wordSelectionStart = -1;
static SelectableTextBlock()
{
FocusableProperty.OverrideDefaultValue(typeof(SelectableTextBlock), true);
AffectsRender(SelectionStartProperty, SelectionEndProperty, SelectionBrushProperty);
}
public event EventHandler? CopyingToClipboard
{
add => AddHandler(CopyingToClipboardEvent, value);
remove => RemoveHandler(CopyingToClipboardEvent, value);
}
///
/// Gets or sets the brush that highlights selected text.
///
public IBrush? SelectionBrush
{
get => GetValue(SelectionBrushProperty);
set => SetValue(SelectionBrushProperty, value);
}
///
/// Gets or sets a brush that is used for the foreground of selected text
///
public IBrush? SelectionForegroundBrush
{
get => GetValue(SelectionForegroundBrushProperty);
set => SetValue(SelectionForegroundBrushProperty, value);
}
///
/// Gets or sets a character index for the beginning of the current selection.
///
public int SelectionStart
{
get => GetValue(SelectionStartProperty);
set => SetValue(SelectionStartProperty, value);
}
///
/// Gets or sets a character index for the end of the current selection.
///
public int SelectionEnd
{
get => GetValue(SelectionEndProperty);
set => SetValue(SelectionEndProperty, value);
}
///
/// Gets the content of the current selection.
///
public string SelectedText
{
get => GetSelection();
}
///
/// Property for determining if the Copy command can be executed.
///
public bool CanCopy
{
get => _canCopy;
private set => SetAndRaise(CanCopyProperty, ref _canCopy, value);
}
///
/// Copies the current selection to the Clipboard.
///
public async void Copy()
{
if (!_canCopy)
{
return;
}
var text = GetSelection();
if (string.IsNullOrEmpty(text))
{
return;
}
var eventArgs = new RoutedEventArgs(CopyingToClipboardEvent);
RaiseEvent(eventArgs);
if (!eventArgs.Handled)
{
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard != null)
await clipboard.SetTextAsync(text);
}
}
///
/// Select all text in the TextBox
///
public void SelectAll()
{
var text = HasComplexContent ? Inlines?.Text : Text;
SetCurrentValue(SelectionStartProperty, 0);
SetCurrentValue(SelectionEndProperty, text?.Length ?? 0);
}
///
/// Clears the current selection
///
public void ClearSelection()
{
SetCurrentValue(SelectionEndProperty, SelectionStart);
}
protected override void OnGotFocus(FocusChangedEventArgs e)
{
base.OnGotFocus(e);
UpdateCommandStates();
}
protected override void OnLostFocus(FocusChangedEventArgs e)
{
base.OnLostFocus(e);
if ((ContextFlyout == null || !ContextFlyout.IsOpen) &&
(ContextMenu == null || !ContextMenu.IsOpen))
{
ClearSelection();
}
UpdateCommandStates();
}
protected override TextLayout CreateTextLayout(string? text)
{
var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch);
var effectiveFontSize = TextScaling.GetScaledFontSize(this, FontSize);
var fontScaleFactor = effectiveFontSize / FontSize;
var defaultProperties = new GenericTextRunProperties(
typeface,
effectiveFontSize,
TextDecorations,
Foreground,
fontFeatures: FontFeatures);
var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false,
defaultProperties, TextWrapping, LineHeight * fontScaleFactor, 0, LetterSpacing * fontScaleFactor)
{
LineSpacing = LineSpacing * fontScaleFactor,
};
List>? textStyleOverrides = null;
var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd;
var start = Math.Min(selectionStart, selectionEnd);
var length = Math.Max(selectionStart, selectionEnd) - start;
if (length > 0 && SelectionForegroundBrush != null)
{
if (_textRuns != null)
{
// Apply selection foreground color without changing the original text formatting.
// The built-in SelectableTextBlock selection logic recreates TextRunProperties,
// which overwrites run-specific Typeface/FontFeatures/FontSize and breaks bold/italic.
// Here we reuse each run's existing properties and only override the foreground brush.
var accumulatedLength = 0;
foreach (var textRun in _textRuns)
{
var runLength = textRun.Text.Length;
if (accumulatedLength + runLength <= start ||
accumulatedLength >= start + length)
{
accumulatedLength += runLength;
continue;
}
var overlapStart = Math.Max(start, accumulatedLength);
var overlapEnd = Math.Min(start + length, accumulatedLength + runLength);
var overlapLength = overlapEnd - overlapStart;
textStyleOverrides ??= [];
textStyleOverrides.Add(
new ValueSpan(
overlapStart,
overlapLength,
new GenericTextRunProperties(
textRun.Properties?.Typeface ?? typeface,
effectiveFontSize,
foregroundBrush: SelectionForegroundBrush,
fontFeatures: textRun.Properties?.FontFeatures ?? FontFeatures)));
accumulatedLength += runLength;
}
}
else
{
textStyleOverrides =
[
new ValueSpan(start, length,
new GenericTextRunProperties(
typeface,
effectiveFontSize,
foregroundBrush: SelectionForegroundBrush,
fontFeatures: FontFeatures))
];
}
}
ITextSource textSource;
if (_textRuns != null)
{
textSource = new InlinesTextSource(_textRuns, textStyleOverrides);
}
else
{
textSource = new FormattedTextSource(text ?? "", defaultProperties, textStyleOverrides);
}
var maxSize = GetMaxSizeFromConstraint();
return new TextLayout(
textSource,
paragraphProperties,
TextTrimming,
maxSize.Width,
maxSize.Height,
MaxLines);
}
protected override void RenderTextLayout(DrawingContext context, Point origin)
{
var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd;
var selectionBrush = SelectionBrush;
if (selectionStart != selectionEnd && selectionBrush != null)
{
var start = Math.Min(selectionStart, selectionEnd);
var length = Math.Max(selectionStart, selectionEnd) - start;
var rects = TextLayout.HitTestTextRange(start, length);
using (context.PushTransform(Matrix.CreateTranslation(origin)))
{
foreach (var rect in rects)
{
context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1));
}
}
}
base.RenderTextLayout(context, origin);
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
var handled = false;
var modifiers = e.KeyModifiers;
var keymap = Application.Current!.PlatformSettings!.HotkeyConfiguration;
bool Match(List gestures) => gestures.Any(g => g.Matches(e));
if (Match(keymap.Copy))
{
Copy();
handled = true;
}
else if (Match(keymap.SelectAll))
{
SelectAll();
handled = true;
}
e.Handled = handled;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == SelectionStartProperty ||
change.Property == SelectionEndProperty)
{
RaisePropertyChanged(SelectedTextProperty, "", "");
UpdateCommandStates();
InvalidateTextLayout();
}
if(change.Property == SelectionForegroundBrushProperty)
{
InvalidateTextLayout();
}
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var text = HasComplexContent ? Inlines?.Text : Text;
var clickInfo = e.GetCurrentPoint(this);
if (text != null && clickInfo.Properties.IsLeftButtonPressed)
{
var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
var oldIndex = SelectionStart;
var hit = TextLayout.HitTestPoint(point);
var index = hit.TextPosition;
switch (e.ClickCount)
{
case 1:
if (clickToSelect)
{
if (_wordSelectionStart >= 0)
{
var previousWord = StringUtils.PreviousWord(text, index);
if (index > _wordSelectionStart)
{
SetCurrentValue(SelectionEndProperty, StringUtils.NextWord(text, index));
}
if (index < _wordSelectionStart || previousWord == _wordSelectionStart)
{
SetCurrentValue(SelectionStartProperty, previousWord);
}
}
else
{
SetCurrentValue(SelectionStartProperty, Math.Min(oldIndex, index));
SetCurrentValue(SelectionEndProperty, Math.Max(oldIndex, index));
}
}
else
{
SetCurrentValue(SelectionStartProperty, index);
SetCurrentValue(SelectionEndProperty, index);
_wordSelectionStart = -1;
}
break;
case 2:
if (!StringUtils.IsStartOfWord(text, index))
{
SetCurrentValue(SelectionStartProperty, StringUtils.PreviousWord(text, index));
}
_wordSelectionStart = SelectionStart;
if (!StringUtils.IsEndOfWord(text, index))
{
SetCurrentValue(SelectionEndProperty, StringUtils.NextWord(text, index));
}
break;
case 3:
_wordSelectionStart = -1;
SelectAll();
break;
}
}
e.Pointer.Capture(this);
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
// selection should not change during pointer move if the user right clicks
if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
var text = HasComplexContent ? Inlines?.Text : Text;
var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
point = new Point(
MathUtilities.Clamp(point.X, 0, Math.Max(TextLayout.WidthIncludingTrailingWhitespace, 0)),
MathUtilities.Clamp(point.Y, 0, Math.Max(TextLayout.Height, 0)));
var hit = TextLayout.HitTestPoint(point);
var textPosition = hit.TextPosition;
if (text != null && _wordSelectionStart >= 0)
{
var distance = textPosition - _wordSelectionStart;
if (distance <= 0)
{
SetCurrentValue(SelectionStartProperty, StringUtils.PreviousWord(text, textPosition));
}
if (distance >= 0)
{
if (SelectionStart != _wordSelectionStart)
{
SetCurrentValue(SelectionStartProperty, _wordSelectionStart);
}
SetCurrentValue(SelectionEndProperty, StringUtils.NextWord(text, textPosition));
}
}
else
{
SetCurrentValue(SelectionEndProperty, textPosition);
}
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (e.Pointer.Captured != this)
{
return;
}
if (e.InitialPressMouseButton == MouseButton.Right)
{
var padding = Padding;
var point = e.GetPosition(this) - new Point(padding.Left, padding.Top);
var hit = TextLayout.HitTestPoint(point);
var caretIndex = hit.TextPosition;
// see if mouse clicked inside current selection
// if it did not, we change the selection to where the user clicked
var firstSelection = Math.Min(SelectionStart, SelectionEnd);
var lastSelection = Math.Max(SelectionStart, SelectionEnd);
var didClickInSelection = SelectionStart != SelectionEnd &&
caretIndex >= firstSelection && caretIndex <= lastSelection;
if (!didClickInSelection)
{
SetCurrentValue(SelectionStartProperty, caretIndex);
SetCurrentValue(SelectionEndProperty, caretIndex);
}
}
e.Pointer.Capture(null);
}
private void UpdateCommandStates()
{
var text = GetSelection();
CanCopy = !string.IsNullOrEmpty(text);
}
private string GetSelection()
{
var text = HasComplexContent ? Inlines?.Text : Text;
var textLength = text?.Length ?? 0;
if (textLength == 0)
{
return "";
}
var selectionStart = SelectionStart;
var selectionEnd = SelectionEnd;
var start = Math.Min(selectionStart, selectionEnd);
var end = Math.Max(selectionStart, selectionEnd);
if (start == end || textLength < end)
{
return "";
}
var length = Math.Max(0, end - start);
var selectedText = text!.Substring(start, length);
return selectedText;
}
}
}