diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml b/samples/ControlCatalog/Pages/TextBlockPage.xaml index fe9455bd29..cb49ba96c6 100644 --- a/samples/ControlCatalog/Pages/TextBlockPage.xaml +++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml @@ -118,7 +118,7 @@ - + This is a TextBlock with several @@ -126,7 +126,7 @@ using a variety of styles . - + diff --git a/src/Avalonia.Controls/Documents/InlineCollection.cs b/src/Avalonia.Controls/Documents/InlineCollection.cs index a76222385e..dc688fc359 100644 --- a/src/Avalonia.Controls/Documents/InlineCollection.cs +++ b/src/Avalonia.Controls/Documents/InlineCollection.cs @@ -12,39 +12,55 @@ namespace Avalonia.Controls.Documents [WhitespaceSignificantCollection] public class InlineCollection : AvaloniaList { - private readonly IInlineHost? _host; + private ILogical? _parent; + private IInlineHost? _inlineHost; private string? _text = string.Empty; /// /// Initializes a new instance of the class. /// - public InlineCollection(ILogical parent) : this(parent, null) { } - - /// - /// Initializes a new instance of the class. - /// - internal InlineCollection(ILogical parent, IInlineHost? host = null) : base(0) + public InlineCollection() { - _host = host; - ResetBehavior = ResetBehavior.Remove; this.ForEachItem( x => { - ((ISetLogicalParent)x).SetParent(parent); - x.InlineHost = host; - host?.Invalidate(); + ((ISetLogicalParent)x).SetParent(Parent); + x.InlineHost = InlineHost; + Invalidate(); }, x => { ((ISetLogicalParent)x).SetParent(null); - x.InlineHost = host; - host?.Invalidate(); + x.InlineHost = InlineHost; + Invalidate(); }, () => throw new NotSupportedException()); } + internal ILogical? Parent + { + get => _parent; + set + { + _parent = value; + + OnParentChanged(value); + } + } + + internal IInlineHost? InlineHost + { + get => _inlineHost; + set + { + _inlineHost = value; + + OnInlineHostChanged(value); + } + } + public bool HasComplexContent => Count > 0; /// @@ -61,10 +77,10 @@ namespace Avalonia.Controls.Documents { return _text; } - + var builder = new StringBuilder(); - foreach(var inline in this) + foreach (var inline in this) { inline.AppendText(builder); } @@ -100,7 +116,7 @@ namespace Avalonia.Controls.Documents } else { - _text += text; + _text = text; } } @@ -120,7 +136,7 @@ namespace Avalonia.Controls.Documents base.Add(new Run(_text)); } - _text = string.Empty; + _text = null; } base.Add(item); @@ -136,14 +152,28 @@ namespace Avalonia.Controls.Documents /// protected void Invalidate() { - if(_host != null) + if(InlineHost != null) { - _host.Invalidate(); + InlineHost.Invalidate(); } Invalidated?.Invoke(this, EventArgs.Empty); } - private void Invalidate(object? sender, EventArgs e) => Invalidate(); + private void OnParentChanged(ILogical? parent) + { + foreach(var child in this) + { + ((ISetLogicalParent)child).SetParent(parent); + } + } + + private void OnInlineHostChanged(IInlineHost? inlineHost) + { + foreach (var child in this) + { + child.InlineHost = inlineHost; + } + } } } diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index bd1b4fc5e1..c7289dbc3f 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -14,25 +14,27 @@ namespace Avalonia.Controls.Documents /// /// Defines the property. /// - public static readonly DirectProperty InlinesProperty = - AvaloniaProperty.RegisterDirect( - nameof(Inlines), - o => o.Inlines); + public static readonly StyledProperty InlinesProperty = + AvaloniaProperty.Register( + nameof(Inlines)); - /// - /// Initializes a new instance of a Span element. - /// public Span() { - Inlines = new InlineCollection(this); - Inlines.Invalidated += (s, e) => InlineHost?.Invalidate(); + Inlines = new InlineCollection + { + Parent = this + }; } /// /// Gets or sets the inlines. /// [Content] - public InlineCollection Inlines { get; } + public InlineCollection Inlines + { + get => GetValue(InlinesProperty); + set => SetValue(InlinesProperty, value); + } internal override void BuildTextRun(IList textRuns) { @@ -52,7 +54,7 @@ namespace Avalonia.Controls.Documents var textCharacters = new TextCharacters(text.AsMemory(), textRunProperties); textRuns.Add(textCharacters); - } + } } } @@ -65,10 +67,52 @@ namespace Avalonia.Controls.Documents inline.AppendText(stringBuilder); } } + else + { + if (Inlines.Text is string text) + { + stringBuilder.Append(text); + } + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + switch (change.Property.Name) + { + case nameof(InlinesProperty): + OnInlinesChanged(change.OldValue as InlineCollection, change.NewValue as InlineCollection); + InlineHost?.Invalidate(); + break; + } + } + + internal override void OnInlineHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + { + base.OnInlineHostChanged(oldValue, newValue); + + if(Inlines is not null) + { + Inlines.InlineHost = newValue; + } + } + + private void OnInlinesChanged(InlineCollection? oldValue, InlineCollection? newValue) + { + if (oldValue is not null) + { + oldValue.Parent = null; + oldValue.InlineHost = null; + oldValue.Invalidated -= (s, e) => InlineHost?.Invalidate(); + } - if (Inlines.Text is string text) + if (newValue is not null) { - stringBuilder.Append(text); + newValue.Parent = this; + newValue.InlineHost = InlineHost; + newValue.Invalidated += (s, e) => InlineHost?.Invalidate(); } } } diff --git a/src/Avalonia.Controls/Documents/TextElement.cs b/src/Avalonia.Controls/Documents/TextElement.cs index f228519e60..5bac3642ed 100644 --- a/src/Avalonia.Controls/Documents/TextElement.cs +++ b/src/Avalonia.Controls/Documents/TextElement.cs @@ -67,6 +67,8 @@ namespace Avalonia.Controls.Documents Brushes.Black, inherits: true); + private IInlineHost? _inlineHost; + /// /// Gets or sets a brush used to paint the control's background. /// @@ -250,7 +252,21 @@ namespace Avalonia.Controls.Documents control.SetValue(ForegroundProperty, value); } - internal IInlineHost? InlineHost { get; set; } + internal IInlineHost? InlineHost + { + get => _inlineHost; + set + { + var oldValue = _inlineHost; + _inlineHost = value; + OnInlineHostChanged(oldValue, value); + } + } + + internal virtual void OnInlineHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + { + + } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 3523cd5214..e463bc5731 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -543,9 +543,11 @@ namespace Avalonia.Controls.Presenters protected override Size ArrangeOverride(Size finalSize) { - if (finalSize.Width < TextLayout.Bounds.Width) + var textWidth = Math.Ceiling(TextLayout.Bounds.Width); + + if (finalSize.Width < textWidth) { - finalSize = finalSize.WithWidth(TextLayout.Bounds.Width); + finalSize = finalSize.WithWidth(textWidth); } if (MathUtilities.AreClose(_constraint.Width, finalSize.Width)) @@ -553,7 +555,7 @@ namespace Avalonia.Controls.Presenters return finalSize; } - _constraint = new Size(finalSize.Width, double.PositiveInfinity); + _constraint = new Size(Math.Ceiling(finalSize.Width), double.PositiveInfinity); _textLayout = null; diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index 87cf660cad..7e5b34acd9 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -79,9 +79,9 @@ namespace Avalonia.Controls.Primitives } /// - protected override TextLayout CreateTextLayout(Size constraint, string? text) + protected override TextLayout CreateTextLayout(string? text) { - return base.CreateTextLayout(constraint, RemoveAccessKeyMarker(text)); + return base.CreateTextLayout(RemoveAccessKeyMarker(text)); } /// diff --git a/src/Avalonia.Controls/RichTextBlock.cs b/src/Avalonia.Controls/RichTextBlock.cs new file mode 100644 index 0000000000..2b84113497 --- /dev/null +++ b/src/Avalonia.Controls/RichTextBlock.cs @@ -0,0 +1,574 @@ +using System; +using System.Collections.Generic; +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.Metadata; +using Avalonia.Utilities; + +namespace Avalonia.Controls +{ + /// + /// A control that displays a block of formatted text. + /// + public class RichTextBlock : TextBlock, IInlineHost + { + public static readonly StyledProperty IsTextSelectionEnabledProperty = + AvaloniaProperty.Register(nameof(IsTextSelectionEnabled), false); + + public static readonly DirectProperty SelectionStartProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectionStart), + o => o.SelectionStart, + (o, v) => o.SelectionStart = v); + + public static readonly DirectProperty SelectionEndProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectionEnd), + o => o.SelectionEnd, + (o, v) => o.SelectionEnd = v); + + public static readonly DirectProperty SelectedTextProperty = + AvaloniaProperty.RegisterDirect( + nameof(SelectedText), + o => o.SelectedText); + + public static readonly StyledProperty SelectionBrushProperty = + AvaloniaProperty.Register(nameof(SelectionBrush), Brushes.Blue); + + public static readonly StyledProperty SelectionForegroundBrushProperty = + AvaloniaProperty.Register(nameof(SelectionForegroundBrush)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty InlinesProperty = + AvaloniaProperty.Register( + nameof(Inlines)); + + public static readonly DirectProperty CanCopyProperty = + AvaloniaProperty.RegisterDirect( + nameof(CanCopy), + o => o.CanCopy); + + public static readonly RoutedEvent CopyingToClipboardEvent = + RoutedEvent.Register( + nameof(CopyingToClipboard), RoutingStrategies.Bubble); + + private bool _canCopy; + private int _selectionStart; + private int _selectionEnd; + + static RichTextBlock() + { + FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true); + + AffectsRender(SelectionStartProperty, SelectionEndProperty, SelectionForegroundBrushProperty, SelectionBrushProperty); + } + + public RichTextBlock() + { + Inlines = new InlineCollection + { + Parent = this, + InlineHost = this + }; + } + + /// + /// Gets or sets the brush that highlights selected text. + /// + public IBrush? SelectionBrush + { + get => GetValue(SelectionBrushProperty); + set => SetValue(SelectionBrushProperty, value); + } + + /// + /// Gets or sets a value that defines the brush used for 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 => _selectionStart; + set + { + if (SetAndRaise(SelectionStartProperty, ref _selectionStart, value)) + { + RaisePropertyChanged(SelectedTextProperty, "", ""); + } + } + } + + /// + /// Gets or sets a character index for the end of the current selection. + /// + public int SelectionEnd + { + get => _selectionEnd; + set + { + if (SetAndRaise(SelectionEndProperty, ref _selectionEnd, value)) + { + RaisePropertyChanged(SelectedTextProperty, "", ""); + } + } + } + + /// + /// Gets the content of the current selection. + /// + public string SelectedText + { + get => GetSelection(); + } + + /// + /// Gets or sets a value that indicates whether text selection is enabled, either through user action or calling selection-related API. + /// + public bool IsTextSelectionEnabled + { + get => GetValue(IsTextSelectionEnabledProperty); + set => SetValue(IsTextSelectionEnabledProperty, value); + } + + /// + /// Gets or sets the inlines. + /// + [Content] + public InlineCollection Inlines + { + get => GetValue(InlinesProperty); + set => SetValue(InlinesProperty, value); + } + + /// + /// Property for determining if the Copy command can be executed. + /// + public bool CanCopy + { + get => _canCopy; + private set => SetAndRaise(CanCopyProperty, ref _canCopy, value); + } + + public event EventHandler? CopyingToClipboard + { + add => AddHandler(CopyingToClipboardEvent, value); + remove => RemoveHandler(CopyingToClipboardEvent, value); + } + + /// + /// Copies the current selection to the Clipboard. + /// + public async void Copy() + { + if (_canCopy || !IsTextSelectionEnabled) + { + return; + } + + var text = GetSelection(); + + if (string.IsNullOrEmpty(text)) + { + return; + } + + var eventArgs = new RoutedEventArgs(CopyingToClipboardEvent); + + RaiseEvent(eventArgs); + + if (!eventArgs.Handled) + { + await ((IClipboard)AvaloniaLocator.Current.GetRequiredService(typeof(IClipboard))) + .SetTextAsync(text); + } + } + + public override void Render(DrawingContext context) + { + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; + var selectionBrush = SelectionBrush; + + var selectionEnabled = IsTextSelectionEnabled; + + if (selectionEnabled && selectionStart != selectionEnd && selectionBrush != null) + { + var start = Math.Min(selectionStart, selectionEnd); + var length = Math.Max(selectionStart, selectionEnd) - start; + + var rects = TextLayout.HitTestTextRange(start, length); + + foreach (var rect in rects) + { + context.FillRectangle(selectionBrush, PixelRect.FromRect(rect, 1).ToRect(1)); + } + } + + base.Render(context); + } + + /// + /// Select all text in the TextBox + /// + public void SelectAll() + { + if (!IsTextSelectionEnabled) + { + return; + } + + var text = Text; + + SelectionStart = 0; + SelectionEnd = text?.Length ?? 0; + } + + /// + /// Clears the current selection/> + /// + public void ClearSelection() + { + if (!IsTextSelectionEnabled) + { + return; + } + + SelectionEnd = SelectionStart; + } + + protected override string? GetText() + { + return _text ?? Inlines.Text; + } + + protected override void SetText(string? text) + { + var oldValue = _text ?? Inlines?.Text; + + if (Inlines is not null && Inlines.HasComplexContent) + { + Inlines.Text = text; + + _text = null; + } + else + { + _text = text; + } + + RaisePropertyChanged(TextProperty, oldValue, text); + } + + /// + /// Creates the used to render the text. + /// + /// A object. + protected override TextLayout CreateTextLayout(string? text) + { + var defaultProperties = new GenericTextRunProperties( + new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), + FontSize, + TextDecorations, + Foreground); + + var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, + defaultProperties, TextWrapping, LineHeight, 0); + + ITextSource textSource; + + var inlines = Inlines; + + if (inlines is not null && inlines.HasComplexContent) + { + var textRuns = new List(); + + foreach (var inline in inlines) + { + inline.BuildTextRun(textRuns); + } + + textSource = new InlinesTextSource(textRuns); + } + else + { + textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); + } + + return new TextLayout( + textSource, + paragraphProperties, + TextTrimming, + _constraint.Width, + _constraint.Height, + maxLines: MaxLines, + lineHeight: LineHeight); + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + + ClearSelection(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + var handled = false; + var modifiers = e.KeyModifiers; + var keymap = AvaloniaLocator.Current.GetRequiredService(); + + bool Match(List gestures) => gestures.Any(g => g.Matches(e)); + + if (Match(keymap.Copy)) + { + Copy(); + + handled = true; + } + + e.Handled = handled; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (!IsTextSelectionEnabled) + { + return; + } + + var text = Text; + var clickInfo = e.GetCurrentPoint(this); + + if (text != null && clickInfo.Properties.IsLeftButtonPressed) + { + var point = e.GetPosition(this); + + var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); + + var oldIndex = SelectionStart; + + var hit = TextLayout.HitTestPoint(point); + var index = hit.TextPosition; + + SelectionStart = SelectionEnd = index; + +#pragma warning disable CS0618 // Type or member is obsolete + switch (e.ClickCount) +#pragma warning restore CS0618 // Type or member is obsolete + { + case 1: + if (clickToSelect) + { + SelectionStart = Math.Min(oldIndex, index); + SelectionEnd = Math.Max(oldIndex, index); + } + else + { + SelectionStart = SelectionEnd = index; + } + + break; + case 2: + if (!StringUtils.IsStartOfWord(text, index)) + { + SelectionStart = StringUtils.PreviousWord(text, index); + } + + SelectionEnd = StringUtils.NextWord(text, index); + break; + case 3: + SelectAll(); + break; + } + } + + e.Pointer.Capture(this); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + if (!IsTextSelectionEnabled) + { + return; + } + + // selection should not change during pointer move if the user right clicks + if (e.Pointer.Captured == this && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + var point = e.GetPosition(this); + + point = new Point( + MathUtilities.Clamp(point.X, 0, Math.Max(Bounds.Width - 1, 0)), + MathUtilities.Clamp(point.Y, 0, Math.Max(Bounds.Height - 1, 0))); + + var hit = TextLayout.HitTestPoint(point); + + SelectionEnd = hit.TextPosition; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + if (!IsTextSelectionEnabled) + { + return; + } + + if (e.Pointer.Captured != this) + { + return; + } + + if (e.InitialPressMouseButton == MouseButton.Right) + { + var point = e.GetPosition(this); + + 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) + { + SelectionStart = SelectionEnd = caretIndex; + } + } + + e.Pointer.Capture(null); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + switch (change.Property.Name) + { + case nameof(InlinesProperty): + { + OnInlinesChanged(change.OldValue as InlineCollection, change.NewValue as InlineCollection); + InvalidateTextLayout(); + break; + } + case nameof(TextProperty): + { + InvalidateTextLayout(); + break; + } + } + } + + private string GetSelection() + { + if (!IsTextSelectionEnabled) + { + return ""; + } + + var text = Inlines.Text ?? Text; + + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; + var start = Math.Min(selectionStart, selectionEnd); + var end = Math.Max(selectionStart, selectionEnd); + + if (start == end || text.Length < end) + { + return ""; + } + + var length = Math.Max(0, end - start); + + var selectedText = text.Substring(start, length); + + return selectedText; + } + + private void OnInlinesChanged(InlineCollection? oldValue, InlineCollection? newValue) + { + if (oldValue is not null) + { + oldValue.Parent = null; + oldValue.InlineHost = null; + oldValue.Invalidated -= (s, e) => InvalidateTextLayout(); + } + + if (newValue is not null) + { + newValue.Parent = this; + newValue.InlineHost = this; + newValue.Invalidated += (s, e) => InvalidateTextLayout(); + } + } + + void IInlineHost.AddVisualChild(IControl child) + { + if (child.VisualParent == null) + { + VisualChildren.Add(child); + } + } + + void IInlineHost.Invalidate() + { + InvalidateTextLayout(); + } + + private readonly struct InlinesTextSource : ITextSource + { + private readonly IReadOnlyList _textRuns; + + public InlinesTextSource(IReadOnlyList textRuns) + { + _textRuns = textRuns; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + var currentPosition = 0; + + foreach (var textRun in _textRuns) + { + if (textRun.TextSourceLength == 0) + { + continue; + } + + if (currentPosition >= textSourceIndex) + { + return textRun; + } + + currentPosition += textRun.TextSourceLength; + } + + return null; + } + } + } +} diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView.cs index 532cb1d329..c344dd795d 100644 --- a/src/Avalonia.Controls/SplitView.cs +++ b/src/Avalonia.Controls/SplitView.cs @@ -431,23 +431,45 @@ namespace Avalonia.Controls } } + private string GetPseudoClass(SplitViewDisplayMode mode) + { + return mode switch + { + SplitViewDisplayMode.Inline => "inline", + SplitViewDisplayMode.CompactInline => "compactinline", + SplitViewDisplayMode.Overlay => "overlay", + SplitViewDisplayMode.CompactOverlay => "compactoverlay", + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; + } + + private string GetPseudoClass(SplitViewPanePlacement placement) + { + return placement switch + { + SplitViewPanePlacement.Left => "left", + SplitViewPanePlacement.Right => "right", + _ => throw new ArgumentOutOfRangeException(nameof(placement), placement, null) + }; + } + private void OnPanePlacementChanged(AvaloniaPropertyChangedEventArgs e) { - var oldState = e.OldValue!.ToString()!.ToLower(); - var newState = e.NewValue!.ToString()!.ToLower(); + var oldState = GetPseudoClass(e.GetOldValue()); + var newState = GetPseudoClass(e.GetNewValue()); PseudoClasses.Remove($":{oldState}"); PseudoClasses.Add($":{newState}"); } private void OnDisplayModeChanged(AvaloniaPropertyChangedEventArgs e) { - var oldState = e.OldValue!.ToString()!.ToLower(); - var newState = e.NewValue!.ToString()!.ToLower(); + var oldState = GetPseudoClass(e.GetOldValue()); + var newState = GetPseudoClass(e.GetNewValue()); PseudoClasses.Remove($":{oldState}"); PseudoClasses.Add($":{newState}"); - var (closedPaneWidth, paneColumnGridLength) = (SplitViewDisplayMode)e.NewValue switch + var (closedPaneWidth, paneColumnGridLength) = e.GetNewValue() switch { SplitViewDisplayMode.Overlay => (0, new GridLength(0, GridUnitType.Pixel)), SplitViewDisplayMode.CompactOverlay => (CompactPaneLength, new GridLength(CompactPaneLength, GridUnitType.Pixel)), diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index db315d3aaf..52261d1c76 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -1,12 +1,9 @@ using System; -using System.Collections.Generic; -using System.Text; using Avalonia.Automation.Peers; using Avalonia.Controls.Documents; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.TextFormatting; -using Avalonia.Metadata; using Avalonia.Utilities; namespace Avalonia.Controls @@ -14,7 +11,7 @@ namespace Avalonia.Controls /// /// A control that displays a block of text. /// - public class TextBlock : Control, IInlineHost + public class TextBlock : Control { /// /// Defines the property. @@ -101,14 +98,6 @@ namespace Avalonia.Controls o => o.Text, (o, v) => o.Text = v); - /// - /// Defines the property. - /// - public static readonly DirectProperty InlinesProperty = - AvaloniaProperty.RegisterDirect( - nameof(Inlines), - o => o.Inlines); - /// /// Defines the property. /// @@ -139,8 +128,9 @@ namespace Avalonia.Controls public static readonly StyledProperty TextDecorationsProperty = AvaloniaProperty.Register(nameof(TextDecorations)); - private TextLayout? _textLayout; - private Size _constraint; + protected string? _text; + protected TextLayout? _textLayout; + protected Size _constraint; /// /// Initializes static members of the class. @@ -152,14 +142,6 @@ namespace Avalonia.Controls AffectsRender(BackgroundProperty, ForegroundProperty); } - /// - /// Initializes a new instance of the class. - /// - public TextBlock() - { - Inlines = new InlineCollection(this, this); - } - /// /// Gets the used to render the text. /// @@ -167,7 +149,7 @@ namespace Avalonia.Controls { get { - return _textLayout ?? (_textLayout = CreateTextLayout(_constraint, Text)); + return _textLayout ??= CreateTextLayout(_text); } } @@ -194,28 +176,10 @@ namespace Avalonia.Controls /// public string? Text { - get => Inlines.Text; - set - { - var old = Text; - - if (value == old) - { - return; - } - - Inlines.Text = value; - - RaisePropertyChanged(TextProperty, old, value); - } + get => GetText(); + set => SetText(value); } - /// - /// Gets the inlines. - /// - [Content] - public InlineCollection Inlines { get; } - /// /// Gets or sets the font family used to draw the control's text. /// @@ -509,6 +473,10 @@ namespace Avalonia.Controls control.SetValue(MaxLinesProperty, maxLines); } + public void Add(string text) + { + _text = text; + } /// /// Renders the to a drawing context. @@ -544,13 +512,21 @@ namespace Avalonia.Controls TextLayout.Draw(context, new Point(padding.Left, top)); } + protected virtual string? GetText() + { + return _text; + } + + protected virtual void SetText(string? text) + { + SetAndRaise(TextProperty, ref _text, text); + } + /// /// Creates the used to render the text. /// - /// The constraint of the text. - /// The text to format. /// A object. - protected virtual TextLayout CreateTextLayout(Size constraint, string? text) + protected virtual TextLayout CreateTextLayout(string? text) { var defaultProperties = new GenericTextRunProperties( new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), @@ -561,30 +537,12 @@ namespace Avalonia.Controls var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, defaultProperties, TextWrapping, LineHeight, 0); - ITextSource textSource; - - if (Inlines.HasComplexContent) - { - var textRuns = new List(); - - foreach (var inline in Inlines) - { - inline.BuildTextRun(textRuns); - } - - textSource = new InlinesTextSource(textRuns); - } - else - { - textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); - } - return new TextLayout( - textSource, + new SimpleTextSource((text ?? "").AsMemory(), defaultProperties), paragraphProperties, TextTrimming, - constraint.Width, - constraint.Height, + _constraint.Width, + _constraint.Height, maxLines: MaxLines, lineHeight: LineHeight); } @@ -601,11 +559,6 @@ namespace Avalonia.Controls protected override Size MeasureOverride(Size availableSize) { - if (!Inlines.HasComplexContent && string.IsNullOrEmpty(Text)) - { - return new Size(); - } - var scale = LayoutHelper.GetLayoutScale(this); var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); @@ -623,9 +576,11 @@ namespace Avalonia.Controls protected override Size ArrangeOverride(Size finalSize) { - if(finalSize.Width < TextLayout.Bounds.Width) + var textWidth = Math.Ceiling(TextLayout.Bounds.Width); + + if(finalSize.Width < textWidth) { - finalSize = finalSize.WithWidth(TextLayout.Bounds.Width); + finalSize = finalSize.WithWidth(textWidth); } if (MathUtilities.AreClose(_constraint.Width, finalSize.Width)) @@ -637,7 +592,7 @@ namespace Avalonia.Controls var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale); - _constraint = new Size(finalSize.Deflate(padding).Width, double.PositiveInfinity); + _constraint = new Size(Math.Ceiling(finalSize.Deflate(padding).Width), double.PositiveInfinity); _textLayout = null; @@ -685,57 +640,7 @@ namespace Avalonia.Controls } } - private void InlinesChanged(object? sender, EventArgs e) - { - InvalidateTextLayout(); - } - - void IInlineHost.AddVisualChild(IControl child) - { - if (child.VisualParent == null) - { - VisualChildren.Add(child); - } - } - - void IInlineHost.Invalidate() - { - InvalidateTextLayout(); - } - - private readonly struct InlinesTextSource : ITextSource - { - private readonly IReadOnlyList _textRuns; - - public InlinesTextSource(IReadOnlyList textRuns) - { - _textRuns = textRuns; - } - - public TextRun? GetTextRun(int textSourceIndex) - { - var currentPosition = 0; - - foreach (var textRun in _textRuns) - { - if(textRun.TextSourceLength == 0) - { - continue; - } - - if(currentPosition >= textSourceIndex) - { - return textRun; - } - - currentPosition += textRun.TextSourceLength; - } - - return null; - } - } - - private readonly struct SimpleTextSource : ITextSource + protected readonly struct SimpleTextSource : ITextSource { private readonly ReadOnlySlice _text; private readonly TextRunProperties _defaultProperties; diff --git a/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs b/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs index c684466200..b57a024f41 100644 --- a/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs +++ b/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs @@ -49,7 +49,7 @@ namespace Avalonia.Base.UnitTests.Styling setter.Instance(control).Start(false); - Assert.Equal("", control.Text); + Assert.Equal(null, control.Text); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/RichTextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/RichTextBlockTests.cs new file mode 100644 index 0000000000..eb4b88956d --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/RichTextBlockTests.cs @@ -0,0 +1,52 @@ +using Avalonia.Controls.Documents; +using Avalonia.Media; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class RichTextBlockTests + { + [Fact] + public void Changing_InlinesCollection_Should_Invalidate_Measure() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var target = new RichTextBlock(); + + target.Measure(Size.Infinity); + + Assert.True(target.IsMeasureValid); + + target.Inlines.Add(new Run("Hello")); + + Assert.False(target.IsMeasureValid); + + target.Measure(Size.Infinity); + + Assert.True(target.IsMeasureValid); + } + } + + [Fact] + public void Changing_Inlines_Properties_Should_Invalidate_Measure() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var target = new RichTextBlock(); + + var inline = new Run("Hello"); + + target.Inlines.Add(inline); + + target.Measure(Size.Infinity); + + Assert.True(target.IsMeasureValid); + + inline.Foreground = Brushes.Green; + + Assert.False(target.IsMeasureValid); + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs index 0ed1f8d2d0..37dde9fbac 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -20,13 +20,11 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Default_Text_Value_Should_Be_EmptyString() + public void Default_Text_Value_Should_Be_Null() { var textBlock = new TextBlock(); - Assert.Equal( - "", - textBlock.Text); + Assert.Equal(null, textBlock.Text); } [Fact] @@ -62,47 +60,5 @@ namespace Avalonia.Controls.UnitTests renderer.Verify(x => x.AddDirty(target), Times.Once); } - - [Fact] - public void Changing_InlinesCollection_Should_Invalidate_Measure() - { - using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) - { - var target = new TextBlock(); - - target.Measure(Size.Infinity); - - Assert.True(target.IsMeasureValid); - - target.Inlines.Add(new Run("Hello")); - - Assert.False(target.IsMeasureValid); - - target.Measure(Size.Infinity); - - Assert.True(target.IsMeasureValid); - } - } - - [Fact] - public void Changing_Inlines_Properties_Should_Invalidate_Measure() - { - using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) - { - var target = new TextBlock(); - - var inline = new Run("Hello"); - - target.Inlines.Add(inline); - - target.Measure(Size.Infinity); - - Assert.True(target.IsMeasureValid); - - inline.Text = "1337"; - - Assert.False(target.IsMeasureValid); - } - } } }