From 0c8f54fe009761088f91bb394e136e4d9bb113d6 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Tue, 21 Jun 2022 15:34:05 +0200 Subject: [PATCH 01/58] Introduce RichTextBlock --- .../ControlCatalog/Pages/TextBlockPage.xaml | 4 +- .../Documents/InlineCollection.cs | 72 +++++-- src/Avalonia.Controls/Documents/Span.cs | 84 ++++++-- .../Documents/TextElement.cs | 18 +- src/Avalonia.Controls/RichTextBlock.cs | 199 ++++++++++++++++++ src/Avalonia.Controls/TextBlock.cs | 123 ++--------- 6 files changed, 353 insertions(+), 147 deletions(-) create mode 100644 src/Avalonia.Controls/RichTextBlock.cs 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..0cbf272297 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; /// @@ -57,14 +73,16 @@ namespace Avalonia.Controls.Documents { get { + return _text; + if (!HasComplexContent) { return _text; } - + var builder = new StringBuilder(); - foreach(var inline in this) + foreach (var inline in this) { inline.AppendText(builder); } @@ -100,7 +118,7 @@ namespace Avalonia.Controls.Documents } else { - _text += text; + _text = text; } } @@ -136,14 +154,30 @@ 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..c2576ec231 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Text; using Avalonia.Media.TextFormatting; -using Avalonia.Metadata; namespace Avalonia.Controls.Documents { @@ -14,25 +13,42 @@ 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); + } + + public void Add(Inline inline) + { + if (Inlines is not null) + { + Inlines.Add(inline); + } + } + + public void Add(string text) + { + if (Inlines is not null) + { + Inlines.Add(text); + } + } internal override void BuildTextRun(IList textRuns) { @@ -52,7 +68,7 @@ namespace Avalonia.Controls.Documents var textCharacters = new TextCharacters(text.AsMemory(), textRunProperties); textRuns.Add(textCharacters); - } + } } } @@ -71,5 +87,45 @@ namespace Avalonia.Controls.Documents 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 OnInlinesHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + { + base.OnInlinesHostChanged(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 (newValue is not null) + { + 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..e75fd87615 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; + OnInlinesHostChanged(oldValue, value); + } + } + + internal virtual void OnInlinesHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + { + + } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { diff --git a/src/Avalonia.Controls/RichTextBlock.cs b/src/Avalonia.Controls/RichTextBlock.cs new file mode 100644 index 0000000000..16d0254f4a --- /dev/null +++ b/src/Avalonia.Controls/RichTextBlock.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls.Documents; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace Avalonia.Controls +{ + /// + /// A control that displays a block of text. + /// + public class RichTextBlock : TextBlock, IInlineHost + { + /// + /// Defines the property. + /// + public static readonly StyledProperty InlinesProperty = + AvaloniaProperty.Register( + nameof(Inlines)); + + public RichTextBlock() + { + Inlines = new InlineCollection + { + Parent = this, + InlineHost = this + }; + } + + /// + /// Gets or sets the inlines. + /// + public InlineCollection Inlines + { + get => GetValue(InlinesProperty); + set => SetValue(InlinesProperty, value); + } + + public void Add(Inline inline) + { + if (Inlines is not null) + { + Inlines.Add(inline); + } + } + + public new void Add(string text) + { + if (Inlines is not null) + { + Inlines.Add(text); + } + } + + /// + /// Creates the used to render the text. + /// + /// The constraint of the text. + /// The text to format. + /// A object. + protected override TextLayout CreateTextLayout(Size constraint, 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 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): + { + OnTextChanged(change.OldValue as string, change.NewValue as string); + break; + } + } + } + + private void OnTextChanged(string? oldValue, string? newValue) + { + if (oldValue == newValue) + { + return; + } + + if (Inlines is null) + { + return; + } + + Inlines.Text = newValue; + } + + 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/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 1a69d1218c..87966e9a6f 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text; using Avalonia.Automation.Peers; using Avalonia.Controls.Documents; using Avalonia.Layout; @@ -14,7 +13,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 +100,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. /// @@ -137,6 +128,7 @@ namespace Avalonia.Controls public static readonly StyledProperty TextDecorationsProperty = AvaloniaProperty.Register(nameof(TextDecorations)); + private string? _text; private TextLayout? _textLayout; private Size _constraint; @@ -150,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. /// @@ -165,7 +149,7 @@ namespace Avalonia.Controls { get { - return _textLayout ?? (_textLayout = CreateTextLayout(_constraint, Text)); + return _textLayout ??= CreateTextLayout(_constraint, Text); } } @@ -192,28 +176,13 @@ namespace Avalonia.Controls /// public string? Text { - get => Inlines.Text; + get => _text; set { - var old = Text; - - if (value == old) - { - return; - } - - Inlines.Text = value; - - RaisePropertyChanged(TextProperty, old, value); + SetAndRaise(TextProperty, ref _text, value); } } - /// - /// Gets the inlines. - /// - [Content] - public InlineCollection Inlines { get; } - /// /// Gets or sets the font family used to draw the control's text. /// @@ -333,6 +302,11 @@ namespace Avalonia.Controls set { SetValue(BaselineOffsetProperty, value); } } + public void Add(string text) + { + Text = text; + } + /// /// Reads the attached property from the given element /// @@ -559,26 +533,8 @@ 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, @@ -599,11 +555,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); @@ -683,57 +634,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; From a408ea10d79ac357d968a1ff96a3664506a6058b Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 22 Jun 2022 16:45:38 +0200 Subject: [PATCH 02/58] Some Progress --- samples/Sandbox/MainWindow.axaml | 14 + .../Media/TextFormatting/TextFormatterImpl.cs | 2 +- .../TextFormatting/Unicode/BiDiAlgorithm.cs | 6 - .../Documents/InlineCollection.cs | 2 - src/Avalonia.Controls/Documents/Span.cs | 20 +- .../Presenters/TextPresenter.cs | 8 +- src/Avalonia.Controls/RichTextBlock.cs | 326 +++++++++++++++++- src/Avalonia.Controls/TextBlock.cs | 12 +- .../TextFormatting/BiDiAlgorithmTests.cs | 2 +- .../RichTextBlockTests.cs | 52 +++ .../TextBlockTests.cs | 42 --- 11 files changed, 397 insertions(+), 89 deletions(-) create mode 100644 tests/Avalonia.Controls.UnitTests/RichTextBlockTests.cs diff --git a/samples/Sandbox/MainWindow.axaml b/samples/Sandbox/MainWindow.axaml index 6929f192c7..806f6d37da 100644 --- a/samples/Sandbox/MainWindow.axaml +++ b/samples/Sandbox/MainWindow.axaml @@ -1,4 +1,18 @@ + + + + This is a + TextBlock + with several + Span elements, + + using a variety of styles + . + + + + diff --git a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs index 4205268bc6..cd764be43f 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs @@ -177,7 +177,7 @@ namespace Avalonia.Media.TextFormatting } - var biDi = BidiAlgorithm.Instance.Value!; + var biDi = new BidiAlgorithm(); biDi.Process(biDiData); diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs index 2511807d9c..3c510ff484 100644 --- a/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs +++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/BiDiAlgorithm.cs @@ -188,12 +188,6 @@ namespace Avalonia.Media.TextFormatting.Unicode { } - /// - /// Gets a per-thread instance that can be re-used as often - /// as necessary. - /// - public static ThreadLocal Instance { get; } = new ThreadLocal(() => new BidiAlgorithm()); - /// /// Gets the resolved levels. /// diff --git a/src/Avalonia.Controls/Documents/InlineCollection.cs b/src/Avalonia.Controls/Documents/InlineCollection.cs index 0cbf272297..2f27ca72d0 100644 --- a/src/Avalonia.Controls/Documents/InlineCollection.cs +++ b/src/Avalonia.Controls/Documents/InlineCollection.cs @@ -73,8 +73,6 @@ namespace Avalonia.Controls.Documents { get { - return _text; - if (!HasComplexContent) { return _text; diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index c2576ec231..98851726da 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; using Avalonia.Media.TextFormatting; +using Avalonia.Metadata; namespace Avalonia.Controls.Documents { @@ -27,29 +28,14 @@ namespace Avalonia.Controls.Documents /// /// Gets or sets the inlines. - /// + /// + [Content] public InlineCollection Inlines { get => GetValue(InlinesProperty); set => SetValue(InlinesProperty, value); } - public void Add(Inline inline) - { - if (Inlines is not null) - { - Inlines.Add(inline); - } - } - - public void Add(string text) - { - if (Inlines is not null) - { - Inlines.Add(text); - } - } - internal override void BuildTextRun(IList textRuns) { if (Inlines.HasComplexContent) 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/RichTextBlock.cs b/src/Avalonia.Controls/RichTextBlock.cs index 16d0254f4a..859503e693 100644 --- a/src/Avalonia.Controls/RichTextBlock.cs +++ b/src/Avalonia.Controls/RichTextBlock.cs @@ -1,8 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using Avalonia.Controls.Documents; +using Avalonia.Controls.Utils; +using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Media.TextFormatting; +using Avalonia.Metadata; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -11,6 +17,38 @@ namespace Avalonia.Controls /// public class RichTextBlock : TextBlock, IInlineHost { + public static readonly StyledProperty IsTextSelectionEnabledProperty = + AvaloniaProperty.Register(nameof(IsTextSelectionEnabled), false); + + public static readonly DirectProperty CaretIndexProperty = + AvaloniaProperty.RegisterDirect( + nameof(CaretIndex), + o => o.CaretIndex, + (o, v) => o.CaretIndex = v); + + 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. /// @@ -18,6 +56,17 @@ namespace Avalonia.Controls AvaloniaProperty.Register( nameof(Inlines)); + private int _caretIndex; + private int _selectionStart; + private int _selectionEnd; + + static RichTextBlock() + { + FocusableProperty.OverrideDefaultValue(typeof(RichTextBlock), true); + + AffectsRender(SelectionStartProperty, SelectionEndProperty, SelectionForegroundBrushProperty, SelectionBrushProperty); + } + public RichTextBlock() { Inlines = new InlineCollection @@ -27,31 +76,85 @@ namespace Avalonia.Controls }; } - /// - /// Gets or sets the inlines. - /// - public InlineCollection Inlines + public IBrush? SelectionBrush { - get => GetValue(InlinesProperty); - set => SetValue(InlinesProperty, value); + get => GetValue(SelectionBrushProperty); + set => SetValue(SelectionBrushProperty, value); } - public void Add(Inline inline) + public IBrush? SelectionForegroundBrush { - if (Inlines is not null) + get => GetValue(SelectionForegroundBrushProperty); + set => SetValue(SelectionForegroundBrushProperty, value); + } + + public int CaretIndex + { + get => _caretIndex; + set { - Inlines.Add(inline); + if(SetAndRaise(CaretIndexProperty, ref _caretIndex, value)) + { + SelectionStart = SelectionEnd = value; + } } } - public new void Add(string text) + public int SelectionStart { - if (Inlines is not null) + get => _selectionStart; + set { - Inlines.Add(text); + if (SetAndRaise(SelectionStartProperty, ref _selectionStart, value)) + { + RaisePropertyChanged(SelectedTextProperty, "", ""); + + if (SelectionEnd == value && CaretIndex != value) + { + CaretIndex = value; + } + } } } + public int SelectionEnd + { + get => _selectionEnd; + set + { + if(SetAndRaise(SelectionEndProperty, ref _selectionEnd, value)) + { + RaisePropertyChanged(SelectedTextProperty, "", ""); + + if (SelectionStart == value && CaretIndex != value) + { + CaretIndex = value; + } + } + } + } + + public string SelectedText + { + get => GetSelection(); + } + + 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); + } + /// /// Creates the used to render the text. /// @@ -99,6 +202,179 @@ namespace Avalonia.Controls lineHeight: LineHeight); } + 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 = Inlines.Text ?? Text; + + SelectionStart = 0; + SelectionEnd = text?.Length ?? 0; + } + + /// + /// Clears the current selection/> + /// + public void ClearSelection() + { + if (!IsTextSelectionEnabled) + { + return; + } + + SelectionEnd = SelectionStart; + } + + protected override void OnLostFocus(RoutedEventArgs e) + { + base.OnLostFocus(e); + + ClearSelection(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (!IsTextSelectionEnabled) + { + return; + } + + var text = Inlines.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 hit = TextLayout.HitTestPoint(point); + + var oldIndex = CaretIndex; + var index = hit.TextPosition; + CaretIndex = 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) + { + _caretIndex = SelectionEnd = SelectionStart = caretIndex; + } + } + + e.Pointer.Capture(null); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); @@ -134,6 +410,32 @@ namespace Avalonia.Controls Inlines.Text = newValue; } + private string GetSelection() + { + 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) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 87966e9a6f..1f891b092f 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -128,8 +128,8 @@ namespace Avalonia.Controls public static readonly StyledProperty TextDecorationsProperty = AvaloniaProperty.Register(nameof(TextDecorations)); - private string? _text; - private TextLayout? _textLayout; + protected string? _text; + protected TextLayout? _textLayout; private Size _constraint; /// @@ -572,9 +572,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)) @@ -586,7 +588,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; diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs index f8a2abc716..5ff2c0e07b 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiAlgorithmTests.cs @@ -27,7 +27,7 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting private bool Run(BiDiTestData testData) { - var bidi = BidiAlgorithm.Instance.Value; + var bidi = new BidiAlgorithm(); // Run the algorithm... ArraySlice resultLevels; 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..6da011f062 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs @@ -62,47 +62,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); - } - } } } From 90e0dcc9e3161cb9659ca7381c6ff98ee7e84f10 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Thu, 23 Jun 2022 15:18:36 +0200 Subject: [PATCH 03/58] Add Copy geasture --- samples/Sandbox/MainWindow.axaml | 1 + .../Documents/InlineCollection.cs | 4 +- src/Avalonia.Controls/Documents/Span.cs | 12 +- .../Documents/TextElement.cs | 4 +- .../Primitives/AccessText.cs | 4 +- src/Avalonia.Controls/RichTextBlock.cs | 194 +++++++++++++----- src/Avalonia.Controls/TextBlock.cs | 38 ++-- .../Media/TextFormatting/BiDiClassTests.cs | 2 +- .../Styling/SetterTests.cs | 2 +- 9 files changed, 174 insertions(+), 87 deletions(-) diff --git a/samples/Sandbox/MainWindow.axaml b/samples/Sandbox/MainWindow.axaml index 806f6d37da..a834e3fef3 100644 --- a/samples/Sandbox/MainWindow.axaml +++ b/samples/Sandbox/MainWindow.axaml @@ -13,6 +13,7 @@ . + diff --git a/src/Avalonia.Controls/Documents/InlineCollection.cs b/src/Avalonia.Controls/Documents/InlineCollection.cs index 2f27ca72d0..dc688fc359 100644 --- a/src/Avalonia.Controls/Documents/InlineCollection.cs +++ b/src/Avalonia.Controls/Documents/InlineCollection.cs @@ -136,7 +136,7 @@ namespace Avalonia.Controls.Documents base.Add(new Run(_text)); } - _text = string.Empty; + _text = null; } base.Add(item); @@ -160,8 +160,6 @@ namespace Avalonia.Controls.Documents Invalidated?.Invoke(this, EventArgs.Empty); } - private void Invalidate(object? sender, EventArgs e) => Invalidate(); - private void OnParentChanged(ILogical? parent) { foreach(var child in this) diff --git a/src/Avalonia.Controls/Documents/Span.cs b/src/Avalonia.Controls/Documents/Span.cs index 98851726da..c7289dbc3f 100644 --- a/src/Avalonia.Controls/Documents/Span.cs +++ b/src/Avalonia.Controls/Documents/Span.cs @@ -67,10 +67,12 @@ namespace Avalonia.Controls.Documents inline.AppendText(stringBuilder); } } - - if (Inlines.Text is string text) + else { - stringBuilder.Append(text); + if (Inlines.Text is string text) + { + stringBuilder.Append(text); + } } } @@ -87,9 +89,9 @@ namespace Avalonia.Controls.Documents } } - internal override void OnInlinesHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + internal override void OnInlineHostChanged(IInlineHost? oldValue, IInlineHost? newValue) { - base.OnInlinesHostChanged(oldValue, newValue); + base.OnInlineHostChanged(oldValue, newValue); if(Inlines is not null) { diff --git a/src/Avalonia.Controls/Documents/TextElement.cs b/src/Avalonia.Controls/Documents/TextElement.cs index e75fd87615..5bac3642ed 100644 --- a/src/Avalonia.Controls/Documents/TextElement.cs +++ b/src/Avalonia.Controls/Documents/TextElement.cs @@ -259,11 +259,11 @@ namespace Avalonia.Controls.Documents { var oldValue = _inlineHost; _inlineHost = value; - OnInlinesHostChanged(oldValue, value); + OnInlineHostChanged(oldValue, value); } } - internal virtual void OnInlinesHostChanged(IInlineHost? oldValue, IInlineHost? newValue) + internal virtual void OnInlineHostChanged(IInlineHost? oldValue, IInlineHost? newValue) { } 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 index 859503e693..1411d715ec 100644 --- a/src/Avalonia.Controls/RichTextBlock.cs +++ b/src/Avalonia.Controls/RichTextBlock.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; +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; @@ -56,6 +57,16 @@ namespace Avalonia.Controls 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 _caretIndex; private int _selectionStart; private int _selectionEnd; @@ -75,7 +86,7 @@ namespace Avalonia.Controls InlineHost = this }; } - + public IBrush? SelectionBrush { get => GetValue(SelectionBrushProperty); @@ -156,50 +167,43 @@ namespace Avalonia.Controls } /// - /// Creates the used to render the text. + /// Property for determining if the Copy command can be executed. /// - /// The constraint of the text. - /// The text to format. - /// A object. - protected override TextLayout CreateTextLayout(Size constraint, string? text) + public bool CanCopy { - var defaultProperties = new GenericTextRunProperties( - new Typeface(FontFamily, FontStyle, FontWeight, FontStretch), - FontSize, - TextDecorations, - Foreground); + get => _canCopy; + private set => SetAndRaise(CanCopyProperty, ref _canCopy, value); + } - var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, - defaultProperties, TextWrapping, LineHeight, 0); + public event EventHandler? CopyingToClipboard + { + add => AddHandler(CopyingToClipboardEvent, value); + remove => RemoveHandler(CopyingToClipboardEvent, value); + } - ITextSource textSource; + public async void Copy() + { + if (_canCopy || !IsTextSelectionEnabled) + { + return; + } - var inlines = Inlines; + var text = GetSelection(); - if (inlines is not null && inlines.HasComplexContent) + if (string.IsNullOrEmpty(text)) { - var textRuns = new List(); + return; + } - foreach (var inline in inlines) - { - inline.BuildTextRun(textRuns); - } + var eventArgs = new RoutedEventArgs(CopyingToClipboardEvent); - textSource = new InlinesTextSource(textRuns); - } - else + RaiseEvent(eventArgs); + + if (!eventArgs.Handled) { - textSource = new SimpleTextSource((text ?? "").AsMemory(), defaultProperties); + await ((IClipboard)AvaloniaLocator.Current.GetRequiredService(typeof(IClipboard))) + .SetTextAsync(text); } - - return new TextLayout( - textSource, - paragraphProperties, - TextTrimming, - constraint.Width, - constraint.Height, - maxLines: MaxLines, - lineHeight: LineHeight); } public override void Render(DrawingContext context) @@ -236,7 +240,7 @@ namespace Avalonia.Controls return; } - var text = Inlines.Text ?? Text; + var text = Text; SelectionStart = 0; SelectionEnd = text?.Length ?? 0; @@ -255,6 +259,75 @@ namespace Avalonia.Controls 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); @@ -262,6 +335,24 @@ namespace Avalonia.Controls 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) @@ -269,20 +360,21 @@ namespace Avalonia.Controls return; } - var text = Inlines.Text; + 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 hit = TextLayout.HitTestPoint(point); + var clickToSelect = e.KeyModifiers.HasFlag(KeyModifiers.Shift); var oldIndex = CaretIndex; + + var hit = TextLayout.HitTestPoint(point); var index = hit.TextPosition; - CaretIndex = index; + + SetAndRaise(CaretIndexProperty, ref _caretIndex, index); #pragma warning disable CS0618 // Type or member is obsolete switch (e.ClickCount) @@ -368,7 +460,7 @@ namespace Avalonia.Controls caretIndex >= firstSelection && caretIndex <= lastSelection; if (!didClickInSelection) { - _caretIndex = SelectionEnd = SelectionStart = caretIndex; + CaretIndex = SelectionEnd = SelectionStart = caretIndex; } } @@ -389,29 +481,19 @@ namespace Avalonia.Controls } case nameof(TextProperty): { - OnTextChanged(change.OldValue as string, change.NewValue as string); + InvalidateTextLayout(); break; } } } - private void OnTextChanged(string? oldValue, string? newValue) + private string GetSelection() { - if (oldValue == newValue) - { - return; - } - - if (Inlines is null) + if (!IsTextSelectionEnabled) { - return; + return ""; } - Inlines.Text = newValue; - } - - private string GetSelection() - { var text = Inlines.Text ?? Text; if (string.IsNullOrEmpty(text)) diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 1f891b092f..2f83ee1002 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -130,7 +130,7 @@ namespace Avalonia.Controls protected string? _text; protected TextLayout? _textLayout; - private Size _constraint; + protected Size _constraint; /// /// Initializes static members of the class. @@ -149,7 +149,7 @@ namespace Avalonia.Controls { get { - return _textLayout ??= CreateTextLayout(_constraint, Text); + return _textLayout ??= CreateTextLayout(_text); } } @@ -176,11 +176,8 @@ namespace Avalonia.Controls /// public string? Text { - get => _text; - set - { - SetAndRaise(TextProperty, ref _text, value); - } + get => GetText(); + set => SetText(value); } /// @@ -302,11 +299,6 @@ namespace Avalonia.Controls set { SetValue(BaselineOffsetProperty, value); } } - public void Add(string text) - { - Text = text; - } - /// /// Reads the attached property from the given element /// @@ -481,6 +473,10 @@ namespace Avalonia.Controls control.SetValue(MaxLinesProperty, maxLines); } + public void Add(string text) + { + _text = text; + } /// /// Renders the to a drawing context. @@ -516,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), @@ -537,8 +541,8 @@ namespace Avalonia.Controls new SimpleTextSource((text ?? "").AsMemory(), defaultProperties), paragraphProperties, TextTrimming, - constraint.Width, - constraint.Height, + _constraint.Width, + _constraint.Height, maxLines: MaxLines, lineHeight: LineHeight); } diff --git a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs index 1ed33e6132..f29420ff87 100644 --- a/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/TextFormatting/BiDiClassTests.cs @@ -30,7 +30,7 @@ namespace Avalonia.Visuals.UnitTests.Media.TextFormatting private bool Run(BiDiClassData t) { - var bidi = BidiAlgorithm.Instance.Value; + var bidi = new BidiAlgorithm(); var bidiData = new BidiData(t.ParagraphLevel); var text = Encoding.UTF32.GetString(MemoryMarshal.Cast(t.CodePoints).ToArray()); diff --git a/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs b/tests/Avalonia.Base.UnitTests/Styling/SetterTests.cs index ed4c78aa3e..99dfc93a68 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] From 0289a515b38984601d36186a260c44edf5acd8be Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Jun 2022 00:59:16 -0400 Subject: [PATCH 04/58] Add file picker interface definitions --- src/Avalonia.Base/Logging/LogArea.cs | 10 ++ .../Platform/Storage/FileIO/BclStorageFile.cs | 107 ++++++++++++++++++ .../Storage/FileIO/BclStorageFolder.cs | 88 ++++++++++++++ .../Storage/FileIO/BclStorageProvider.cs | 35 ++++++ .../Storage/FileIO/StorageProviderHelpers.cs | 40 +++++++ .../Platform/Storage/FilePickerFileType.cs | 44 +++++++ .../Platform/Storage/FilePickerFileTypes.cs | 48 ++++++++ .../Platform/Storage/FilePickerOpenOptions.cs | 29 +++++ .../Platform/Storage/FilePickerSaveOptions.cs | 39 +++++++ .../Storage/FolderPickerOpenOptions.cs | 22 ++++ .../Platform/Storage/IStorageBookmarkItem.cs | 21 ++++ .../Platform/Storage/IStorageFile.cs | 32 ++++++ .../Platform/Storage/IStorageFolder.cs | 12 ++ .../Platform/Storage/IStorageItem.cs | 53 +++++++++ .../Platform/Storage/IStorageProvider.cs | 56 +++++++++ .../Platform/Storage/StorageItemProperties.cs | 43 +++++++ .../Dialogs/IStorageProviderFactory.cs | 12 ++ .../{ => Dialogs}/ISystemDialogImpl.cs | 2 + .../Platform/Dialogs/SystemDialogImpl.cs | 74 ++++++++++++ .../ITopLevelImplWithStorageProvider.cs | 11 ++ src/Avalonia.Controls/SystemDialog.cs | 57 ++++++++++ src/Avalonia.Controls/TopLevel.cs | 9 +- 22 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs create mode 100644 src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs create mode 100644 src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs create mode 100644 src/Avalonia.Base/Platform/Storage/IStorageFile.cs create mode 100644 src/Avalonia.Base/Platform/Storage/IStorageFolder.cs create mode 100644 src/Avalonia.Base/Platform/Storage/IStorageItem.cs create mode 100644 src/Avalonia.Base/Platform/Storage/IStorageProvider.cs create mode 100644 src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs create mode 100644 src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs rename src/Avalonia.Controls/Platform/{ => Dialogs}/ISystemDialogImpl.cs (88%) create mode 100644 src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs create mode 100644 src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs diff --git a/src/Avalonia.Base/Logging/LogArea.cs b/src/Avalonia.Base/Logging/LogArea.cs index c049f9e763..98ef6d2530 100644 --- a/src/Avalonia.Base/Logging/LogArea.cs +++ b/src/Avalonia.Base/Logging/LogArea.cs @@ -44,5 +44,15 @@ namespace Avalonia.Logging /// The log event comes from X11Platform. /// public const string X11Platform = nameof(X11Platform); + + /// + /// The log event comes from AndroidPlatform. + /// + public const string AndroidPlatform = nameof(AndroidPlatform); + + /// + /// The log event comes from IOSPlatform. + /// + public const string IOSPlatform = nameof(IOSPlatform); } } diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs new file mode 100644 index 0000000000..5af02219ce --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs @@ -0,0 +1,107 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage.FileIO; + +[Unstable] +public class BclStorageFile : IStorageBookmarkFile +{ + private readonly FileInfo _fileInfo; + + public BclStorageFile(FileInfo fileInfo) + { + _fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo)); + } + + public bool CanOpenRead => true; + + public bool CanOpenWrite => true; + + public string Name => _fileInfo.Name; + + public virtual bool CanBookmark => true; + + public Task GetBasicPropertiesAsync() + { + var props = new StorageItemProperties(); + if (_fileInfo.Exists) + { + props = new StorageItemProperties( + (ulong)_fileInfo.Length, + _fileInfo.CreationTimeUtc, + _fileInfo.LastAccessTimeUtc); + } + return Task.FromResult(props); + } + + public Task GetParentAsync() + { + if (_fileInfo.Directory is { } directory) + { + return Task.FromResult(new BclStorageFolder(directory)); + } + return Task.FromResult(null); + } + + public Task OpenRead() + { + return Task.FromResult(_fileInfo.OpenRead()); + } + + public Task OpenWrite() + { + return Task.FromResult(_fileInfo.OpenWrite()); + } + + public virtual Task SaveBookmark() + { + return Task.FromResult(_fileInfo.FullName); + } + + public Task ReleaseBookmark() + { + // No-op + return Task.CompletedTask; + } + + public bool TryGetUri([NotNullWhen(true)] out Uri? uri) + { + try + { + if (_fileInfo.Directory is not null) + { + uri = Path.IsPathRooted(_fileInfo.FullName) ? + new Uri(new Uri("file://"), _fileInfo.FullName) : + new Uri(_fileInfo.FullName, UriKind.Relative); + return true; + } + + uri = null; + return false; + } + catch (SecurityException) + { + uri = null; + return false; + } + } + + protected virtual void Dispose(bool disposing) + { + } + + ~BclStorageFile() + { + Dispose(disposing: false); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs new file mode 100644 index 0000000000..7267017eaf --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs @@ -0,0 +1,88 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage.FileIO; + +[Unstable] +public class BclStorageFolder : IStorageBookmarkFolder +{ + private readonly DirectoryInfo _directoryInfo; + + public BclStorageFolder(DirectoryInfo directoryInfo) + { + _directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); + if (!_directoryInfo.Exists) + { + throw new ArgumentException("Directory must exist", nameof(directoryInfo)); + } + } + + public string Name => _directoryInfo.Name; + + public bool CanBookmark => true; + + public Task GetBasicPropertiesAsync() + { + var props = new StorageItemProperties( + null, + _directoryInfo.CreationTimeUtc, + _directoryInfo.LastAccessTimeUtc); + return Task.FromResult(props); + } + + public Task GetParentAsync() + { + if (_directoryInfo.Parent is { } directory) + { + return Task.FromResult(new BclStorageFolder(directory)); + } + return Task.FromResult(null); + } + + public virtual Task SaveBookmark() + { + return Task.FromResult(_directoryInfo.FullName); + } + + public Task ReleaseBookmark() + { + // No-op + return Task.CompletedTask; + } + + public bool TryGetUri([NotNullWhen(true)] out Uri? uri) + { + try + { + uri = Path.IsPathRooted(_directoryInfo.FullName) ? + new Uri(new Uri("file://"), _directoryInfo.FullName) : + new Uri(_directoryInfo.FullName, UriKind.Relative); + + return true; + } + catch (SecurityException) + { + uri = null; + return false; + } + } + + protected virtual void Dispose(bool disposing) + { + } + + ~BclStorageFolder() + { + Dispose(disposing: false); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs new file mode 100644 index 0000000000..469388021e --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage.FileIO; + +[Unstable] +public abstract class BclStorageProvider : IStorageProvider +{ + public abstract bool CanOpen { get; } + public abstract Task> OpenFilePickerAsync(FilePickerOpenOptions options); + + public abstract bool CanSave { get; } + public abstract Task SaveFilePickerAsync(FilePickerSaveOptions options); + + public abstract bool CanPickFolder { get; } + public abstract Task> OpenFolderPickerAsync(FolderPickerOpenOptions options); + + public virtual Task OpenFileBookmarkAsync(string bookmark) + { + var file = new FileInfo(bookmark); + return file.Exists + ? Task.FromResult(new BclStorageFile(file)) + : Task.FromResult(null); + } + + public virtual Task OpenFolderBookmarkAsync(string bookmark) + { + var folder = new DirectoryInfo(bookmark); + return folder.Exists + ? Task.FromResult(new BclStorageFolder(folder)) + : Task.FromResult(null); + } +} diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs new file mode 100644 index 0000000000..f90d0a5a2f --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Linq; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage.FileIO; + +[Unstable] +public static class StorageProviderHelpers +{ + public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter) + { + var name = Path.GetFileName(path); + if (name != null && !Path.HasExtension(name)) + { + if (filter?.Patterns?.Count > 0) + { + if (defaultExtension != null + && filter.Patterns.Contains(defaultExtension)) + { + return Path.ChangeExtension(path, defaultExtension.TrimStart('.')); + } + + var ext = filter.Patterns.FirstOrDefault(x => x != "*.*"); + ext = ext?.Split(new[] { "*." }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + if (ext != null) + { + return Path.ChangeExtension(path, ext); + } + } + + if (defaultExtension != null) + { + return Path.ChangeExtension(path, defaultExtension); + } + } + + return path; + } +} diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs new file mode 100644 index 0000000000..98848ac9f7 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Avalonia.Platform.Storage; + +/// +/// Represents a name mapped to the associated file types (extensions). +/// +public class FilePickerFileType +{ + public FilePickerFileType(string name) + { + Name = name; + } + + /// + /// File type name. + /// + public string Name { get; } + + /// + /// List of extensions in GLOB format. I.e. "*.png" or "*.*". + /// + /// + /// Used on Windows and Linux systems. + /// + public IReadOnlyList? Patterns { get; set; } + + /// + /// List of extensions in MIME format. + /// + /// + /// Used on Android, Browser and Linux systems. + /// + public IReadOnlyList? MimeTypes { get; set; } + + /// + /// List of extensions in Apple uniform format. + /// + /// + /// Used only on Apple devices. + /// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers. + /// + public IReadOnlyList? AppleUniformTypeIdentifiers { get; set; } +} diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs b/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs new file mode 100644 index 0000000000..5da037999a --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs @@ -0,0 +1,48 @@ +namespace Avalonia.Platform.Storage; + +/// +/// Dictionary of well known file types. +/// +public static class FilePickerFileTypes +{ + public static FilePickerFileType All { get; } = new("All") + { + Patterns = new[] { "*.*" }, + MimeTypes = new[] { "*/*" } + }; + + public static FilePickerFileType TextPlain { get; } = new("Plain Text") + { + Patterns = new[] { "*.txt" }, + AppleUniformTypeIdentifiers = new[] { "public.plain-text" }, + MimeTypes = new[] { "text/plain" } + }; + + public static FilePickerFileType ImageAll { get; } = new("All Images") + { + Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" }, + AppleUniformTypeIdentifiers = new[] { "public.image" }, + MimeTypes = new[] { "image/*" } + }; + + public static FilePickerFileType ImageJpg { get; } = new("JPEG image") + { + Patterns = new[] { "*.jpg", "*.jpeg" }, + AppleUniformTypeIdentifiers = new[] { "public.jpeg" }, + MimeTypes = new[] { "image/jpeg" } + }; + + public static FilePickerFileType ImagePng { get; } = new("PNG image") + { + Patterns = new[] { "*.png" }, + AppleUniformTypeIdentifiers = new[] { "public.png" }, + MimeTypes = new[] { "image/png" } + }; + + public static FilePickerFileType Pdf { get; } = new("PDF document") + { + Patterns = new[] { "*.pdf" }, + AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" }, + MimeTypes = new[] { "application/pdf" } + }; +} diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs b/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs new file mode 100644 index 0000000000..1f9202b0e7 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Avalonia.Platform.Storage; + +/// +/// Options class for method. +/// +public class FilePickerOpenOptions +{ + /// + /// Gets or sets the text that appears in the title bar of a file dialog. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the initial location where the file open picker looks for files to present to the user. + /// + public IStorageFolder? SuggestedStartLocation { get; set; } + + /// + /// Gets or sets an option indicating whether open picker allows users to select multiple files. + /// + public bool AllowMultiple { get; set; } + + /// + /// Gets or sets the collection of file types that the file open picker displays. + /// + public IReadOnlyList? FileTypeFilter { get; set; } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs b/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs new file mode 100644 index 0000000000..0f4d690f7a --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace Avalonia.Platform.Storage; + +/// +/// Options class for method. +/// +public class FilePickerSaveOptions +{ + /// + /// Gets or sets the text that appears in the title bar of a file dialog. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the file name that the file save picker suggests to the user. + /// + public string? SuggestedFileName { get; set; } + + /// + /// Gets or sets the default extension to be used to save the file. + /// + public string? DefaultExtension { get; set; } + + /// + /// Gets or sets the initial location where the file open picker looks for files to present to the user. + /// + public IStorageFolder? SuggestedStartLocation { get; set; } + + /// + /// Gets or sets the collection of valid file types that the user can choose to assign to a file. + /// + public IReadOnlyList? FileTypeChoices { get; set; } + + /// + /// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists. + /// + public bool? ShowOverwritePrompt { get; set; } +} diff --git a/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs b/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs new file mode 100644 index 0000000000..de90da30b2 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs @@ -0,0 +1,22 @@ +namespace Avalonia.Platform.Storage; + +/// +/// Options class for method. +/// +public class FolderPickerOpenOptions +{ + /// + /// Gets or sets the text that appears in the title bar of a folder dialog. + /// + public string? Title { get; set; } + + /// + /// Gets or sets an option indicating whether open picker allows users to select multiple folders. + /// + public bool AllowMultiple { get; set; } + + /// + /// Gets or sets the initial location where the file open picker looks for files to present to the user. + /// + public IStorageFolder? SuggestedStartLocation { get; set; } +} diff --git a/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs new file mode 100644 index 0000000000..65811b7fbd --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage; + +[NotClientImplementable] +public interface IStorageBookmarkItem : IStorageItem +{ + Task ReleaseBookmark(); +} + +[NotClientImplementable] +public interface IStorageBookmarkFile : IStorageFile, IStorageBookmarkItem +{ +} + +[NotClientImplementable] +public interface IStorageBookmarkFolder : IStorageFolder, IStorageBookmarkItem +{ + +} diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs new file mode 100644 index 0000000000..2f12514e50 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs @@ -0,0 +1,32 @@ +using System.IO; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage; + +/// +/// Represents a file. Provides information about the file and its contents, and ways to manipulate them. +/// +[NotClientImplementable] +public interface IStorageFile : IStorageItem +{ + /// + /// Returns true, if file is readable. + /// + bool CanOpenRead { get; } + + /// + /// Opens a stream for read access. + /// + Task OpenRead(); + + /// + /// Returns true, if file is writeable. + /// + bool CanOpenWrite { get; } + + /// + /// Opens stream for writing to the file. + /// + Task OpenWrite(); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs new file mode 100644 index 0000000000..83b316bc3b --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs @@ -0,0 +1,12 @@ +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage; + +/// +/// Manipulates folders and their contents, and provides information about them. +/// +[NotClientImplementable] +public interface IStorageFolder : IStorageItem +{ + +} \ No newline at end of file diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs new file mode 100644 index 0000000000..078311a286 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs @@ -0,0 +1,53 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage; + +/// +/// Manipulates storage items (files and folders) and their contents, and provides information about them +/// +/// +/// This interface inherits . It's recommended to dispose when it's not used anymore. +/// +[NotClientImplementable] +public interface IStorageItem : IDisposable +{ + /// + /// Gets the name of the item including the file name extension if there is one. + /// + string Name { get; } + + /// + /// Gets the full file-system path of the item, if the item has a path. + /// + /// + /// Android backend might return file path with "content:" scheme. + /// Browser and iOS backends might return relative uris. + /// + bool TryGetUri([NotNullWhen(true)] out Uri? uri); + + /// + /// Gets the basic properties of the current item. + /// + Task GetBasicPropertiesAsync(); + + /// + /// Returns true is item can be bookmarked and reused later. + /// + bool CanBookmark { get; } + + /// + /// Saves items to a bookmark. + /// + /// + /// Returns identifier of a bookmark. Can be null if OS denied request. + /// + Task SaveBookmark(); + + /// + /// Gets the parent folder of the current storage item. + /// + Task GetParentAsync(); +} diff --git a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs new file mode 100644 index 0000000000..32fb148790 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Metadata; + +namespace Avalonia.Platform.Storage; + +[NotClientImplementable] +public interface IStorageProvider +{ + /// + /// Returns true if it's possible to open file picker on the current platform. + /// + bool CanOpen { get; } + + /// + /// Opens file picker dialog. + /// + /// Array of selected or empty collection if user canceled the dialog. + Task> OpenFilePickerAsync(FilePickerOpenOptions options); + + /// + /// Returns true if it's possible to open save file picker on the current platform. + /// + bool CanSave { get; } + + /// + /// Opens save file picker dialog. + /// + /// Saved or null if user canceled the dialog. + Task SaveFilePickerAsync(FilePickerSaveOptions options); + + /// + /// Returns true if it's possible to open folder picker on the current platform. + /// + bool CanPickFolder { get; } + + /// + /// Opens folder picker dialog. + /// + /// Array of selected or empty collection if user canceled the dialog. + Task> OpenFolderPickerAsync(FolderPickerOpenOptions options); + + /// + /// Open from the bookmark ID. + /// + /// Bookmark ID. + /// Bookmarked file or null if OS denied request. + Task OpenFileBookmarkAsync(string bookmark); + + /// + /// Open from the bookmark ID. + /// + /// Bookmark ID. + /// Bookmarked folder or null if OS denied request. + Task OpenFolderBookmarkAsync(string bookmark); +} diff --git a/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs b/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs new file mode 100644 index 0000000000..41b9bfa941 --- /dev/null +++ b/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs @@ -0,0 +1,43 @@ +using System; + +namespace Avalonia.Platform.Storage; + +/// +/// Provides access to the content-related properties of an item (like a file or folder). +/// +public class StorageItemProperties +{ + public StorageItemProperties( + ulong? size = null, + DateTimeOffset? dateCreated = null, + DateTimeOffset? dateModified = null) + { + Size = size; + DateCreated = dateCreated; + DateModified = dateModified; + } + + /// + /// Gets the size of the file in bytes. + /// + /// + /// Can be null if property is not available. + /// + public ulong? Size { get; } + + /// + /// Gets the date and time that the current folder was created. + /// + /// + /// Can be null if property is not available. + /// + public DateTimeOffset? DateCreated { get; } + + /// + /// Gets the date and time of the last time the file was modified. + /// + /// + /// Can be null if property is not available. + /// + public DateTimeOffset? DateModified { get; } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs b/src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs new file mode 100644 index 0000000000..3eee8e848e --- /dev/null +++ b/src/Avalonia.Controls/Platform/Dialogs/IStorageProviderFactory.cs @@ -0,0 +1,12 @@ +#nullable enable +using Avalonia.Platform.Storage; + +namespace Avalonia.Controls.Platform; + +/// +/// Factory allows to register custom storage provider instead of native implementation. +/// +public interface IStorageProviderFactory +{ + IStorageProvider CreateProvider(TopLevel topLevel); +} diff --git a/src/Avalonia.Controls/Platform/ISystemDialogImpl.cs b/src/Avalonia.Controls/Platform/Dialogs/ISystemDialogImpl.cs similarity index 88% rename from src/Avalonia.Controls/Platform/ISystemDialogImpl.cs rename to src/Avalonia.Controls/Platform/Dialogs/ISystemDialogImpl.cs index 715eda5cfa..996fff6775 100644 --- a/src/Avalonia.Controls/Platform/ISystemDialogImpl.cs +++ b/src/Avalonia.Controls/Platform/Dialogs/ISystemDialogImpl.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Avalonia.Metadata; @@ -6,6 +7,7 @@ namespace Avalonia.Controls.Platform /// /// Defines a platform-specific system dialog implementation. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] [Unstable] public interface ISystemDialogImpl { diff --git a/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs b/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs new file mode 100644 index 0000000000..2775c53803 --- /dev/null +++ b/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +#nullable enable + +namespace Avalonia.Controls.Platform +{ + /// + /// Defines a platform-specific system dialog implementation. + /// + [Obsolete] + internal class SystemDialogImpl : ISystemDialogImpl + { + public async Task ShowFileDialogAsync(FileDialog dialog, Window parent) + { + if (dialog is OpenFileDialog openDialog) + { + var filePicker = parent.StorageProvider; + if (!filePicker.CanOpen) + { + return null; + } + + var options = openDialog.ToFilePickerOpenOptions(); + + var files = await filePicker.OpenFilePickerAsync(options); + return files + .Select(file => file.TryGetUri(out var fullPath) + ? fullPath.LocalPath + : file.Name) + .ToArray(); + } + else if (dialog is SaveFileDialog saveDialog) + { + var filePicker = parent.StorageProvider; + if (!filePicker.CanSave) + { + return null; + } + + var options = saveDialog.ToFilePickerSaveOptions(); + + var file = await filePicker.SaveFilePickerAsync(options); + if (file is null) + { + return null; + } + + var filePath = file.TryGetUri(out var fullPath) + ? fullPath.LocalPath + : file.Name; + return new[] { filePath }; + } + return null; + } + + public async Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent) + { + var filePicker = parent.StorageProvider; + if (!filePicker.CanPickFolder) + { + return null; + } + + var options = dialog.ToFolderPickerOpenOptions(); + + var folders = await filePicker.OpenFolderPickerAsync(options); + return folders + .Select(f => f.TryGetUri(out var uri) ? uri.LocalPath : null) + .FirstOrDefault(u => u is not null); + } + } +} diff --git a/src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs b/src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs new file mode 100644 index 0000000000..b42040f3c3 --- /dev/null +++ b/src/Avalonia.Controls/Platform/ITopLevelImplWithStorageProvider.cs @@ -0,0 +1,11 @@ +using Avalonia.Metadata; +using Avalonia.Platform; +using Avalonia.Platform.Storage; + +namespace Avalonia.Controls.Platform; + +[Unstable] +public interface ITopLevelImplWithStorageProvider : ITopLevelImpl +{ + public IStorageProvider StorageProvider { get; } +} diff --git a/src/Avalonia.Controls/SystemDialog.cs b/src/Avalonia.Controls/SystemDialog.cs index 093f10be51..f3fb4d9a6d 100644 --- a/src/Avalonia.Controls/SystemDialog.cs +++ b/src/Avalonia.Controls/SystemDialog.cs @@ -3,12 +3,15 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls.Platform; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; namespace Avalonia.Controls { /// /// Base class for system file dialogs. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public abstract class FileDialog : FileSystemDialog { /// @@ -26,6 +29,7 @@ namespace Avalonia.Controls /// /// Base class for system file and directory dialogs. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public abstract class FileSystemDialog : SystemDialog { [Obsolete("Use Directory")] @@ -45,6 +49,7 @@ namespace Avalonia.Controls /// /// Represents a system dialog that prompts the user to select a location for saving a file. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public class SaveFileDialog : FileDialog { /// @@ -73,11 +78,27 @@ namespace Avalonia.Controls return (await service.ShowFileDialogAsync(this, parent) ?? Array.Empty()).FirstOrDefault(); } + + public FilePickerSaveOptions ToFilePickerSaveOptions() + { + return new FilePickerSaveOptions + { + SuggestedFileName = InitialFileName, + DefaultExtension = DefaultExtension, + FileTypeChoices = Filters?.Select(f => new FilePickerFileType(f.Name!) { Patterns = f.Extensions.Select(e => $"*.{e}").ToArray() }).ToArray(), + Title = Title, + SuggestedStartLocation = InitialDirectory is { } directory + ? new BclStorageFolder(new System.IO.DirectoryInfo(directory)) + : null, + ShowOverwritePrompt = ShowOverwritePrompt + }; + } } /// /// Represents a system dialog that allows the user to select one or more files to open. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public class OpenFileDialog : FileDialog { /// @@ -100,11 +121,25 @@ namespace Avalonia.Controls var service = AvaloniaLocator.Current.GetRequiredService(); return service.ShowFileDialogAsync(this, parent); } + + public FilePickerOpenOptions ToFilePickerOpenOptions() + { + return new FilePickerOpenOptions + { + AllowMultiple = AllowMultiple, + FileTypeFilter = Filters?.Select(f => new FilePickerFileType(f.Name!) { Patterns = f.Extensions.Select(e => $"*.{e}").ToArray() }).ToArray(), + Title = Title, + SuggestedStartLocation = InitialDirectory is { } directory + ? new BclStorageFolder(new System.IO.DirectoryInfo(directory)) + : null + }; + } } /// /// Represents a system dialog that allows the user to select a directory. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public class OpenFolderDialog : FileSystemDialog { [Obsolete("Use Directory")] @@ -129,14 +164,35 @@ namespace Avalonia.Controls var service = AvaloniaLocator.Current.GetRequiredService(); return service.ShowFolderDialogAsync(this, parent); } + + public FolderPickerOpenOptions ToFolderPickerOpenOptions() + { + return new FolderPickerOpenOptions + { + Title = Title, + SuggestedStartLocation = InitialDirectory is { } directory + ? new BclStorageFolder(new System.IO.DirectoryInfo(directory)) + : null + }; + } } /// /// Base class for system dialogs. /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public abstract class SystemDialog { + static SystemDialog() + { + if (AvaloniaLocator.Current.GetService() is null) + { + // Register default implementation. + AvaloniaLocator.CurrentMutable.Bind().ToSingleton(); + } + } + /// /// Gets or sets the dialog title. /// @@ -146,6 +202,7 @@ namespace Avalonia.Controls /// /// Represents a filter in an or an . /// + [Obsolete("Use Window.StorageProvider API or TopLevel.StorageProvider API")] public class FileDialogFilter { /// diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 57fb82485c..d09b824958 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -11,6 +11,7 @@ using Avalonia.Logging; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Rendering; using Avalonia.Styling; using Avalonia.Utilities; @@ -93,7 +94,8 @@ namespace Avalonia.Controls private ILayoutManager? _layoutManager; private Border? _transparencyFallbackBorder; private TargetWeakEventSubscriber? _resourcesChangesSubscriber; - + private IStorageProvider? _storageProvider; + /// /// Initializes static members of the class. /// @@ -319,6 +321,11 @@ namespace Avalonia.Controls double IRenderRoot.RenderScaling => PlatformImpl?.RenderScaling ?? 1; IStyleHost IStyleHost.StylingParent => _globalStyles!; + + public IStorageProvider StorageProvider => _storageProvider + ??= AvaloniaLocator.Current.GetService()?.CreateProvider(this) + ?? (PlatformImpl as ITopLevelImplWithStorageProvider)?.StorageProvider + ?? throw new InvalidOperationException("StorageProvider platform implementation is not available."); IRenderTarget IRenderRoot.CreateRenderTarget() => CreateRenderTarget(); From e717cce7e822f25cc4290150597410489fcbdc26 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 24 Jun 2022 01:00:07 -0400 Subject: [PATCH 05/58] Update headless implementations, managed and samples --- samples/ControlCatalog/ControlCatalog.csproj | 2 +- samples/ControlCatalog/MainView.xaml | 3 + samples/ControlCatalog/MainView.xaml.cs | 5 - samples/ControlCatalog/Pages/DialogsPage.xaml | 76 ++++-- .../ControlCatalog/Pages/DialogsPage.xaml.cs | 234 ++++++++++++++++-- .../Pages/NumericUpDownPage.xaml | 6 +- .../Pages/NumericUpDownPage.xaml.cs | 13 +- .../Remote/PreviewerWindowImpl.cs | 6 +- .../Remote/PreviewerWindowingPlatform.cs | 1 - src/Avalonia.DesignerSupport/Remote/Stubs.cs | 32 ++- src/Avalonia.Dialogs/Avalonia.Dialogs.csproj | 4 - .../ManagedFileChooserFilterViewModel.cs | 35 +-- .../ManagedFileChooserViewModel.cs | 97 +++++--- .../ManagedFileDialogExtensions.cs | 142 ++--------- .../ManagedStorageProvider.cs | 147 +++++++++++ .../AvaloniaHeadlessPlatform.cs | 1 - .../HeadlessPlatformStubs.cs | 36 ++- src/Avalonia.Headless/HeadlessWindowImpl.cs | 6 +- 18 files changed, 576 insertions(+), 270 deletions(-) create mode 100644 src/Avalonia.Dialogs/ManagedStorageProvider.cs diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index 903c849834..8358fb3cd4 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -1,6 +1,6 @@  - netstandard2.0 + netstandard2.0;net6.0 true enable diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index d8dc3bad2d..b6ce59f15b 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -69,6 +69,9 @@ + + + diff --git a/samples/ControlCatalog/MainView.xaml.cs b/samples/ControlCatalog/MainView.xaml.cs index d675324d9f..47d11738bc 100644 --- a/samples/ControlCatalog/MainView.xaml.cs +++ b/samples/ControlCatalog/MainView.xaml.cs @@ -24,11 +24,6 @@ namespace ControlCatalog { IList tabItems = ((IList)sideBar.Items); tabItems.Add(new TabItem() - { - Header = "Dialogs", - Content = new DialogsPage() - }); - tabItems.Add(new TabItem() { Header = "Screens", Content = new ScreenPage() diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml b/samples/ControlCatalog/Pages/DialogsPage.xaml index 8a835867b3..cc23ef796a 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml @@ -1,29 +1,57 @@ - - - Use filters - - - - - + + - - + - - - - - - - + + + + + + + + + + + + + + Use filters + + + Force managed dialog + Open multiple + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs index efa30c2741..f7b6db1255 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs @@ -1,13 +1,21 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Dialogs; using Avalonia.Layout; using Avalonia.Markup.Xaml; -#pragma warning disable 4014 +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; + +#pragma warning disable CS0618 // Type or member is obsolete +#nullable enable + namespace ControlCatalog.Pages { public class DialogsPage : UserControl @@ -18,13 +26,16 @@ namespace ControlCatalog.Pages var results = this.Get("PickerLastResults"); var resultsVisible = this.Get("PickerLastResultsVisible"); + var bookmarkContainer = this.Get("BookmarkContainer"); + var openedFileContent = this.Get("OpenedFileContent"); + var openMultiple = this.Get("OpenMultiple"); - string? lastSelectedDirectory = null; + IStorageFolder? lastSelectedDirectory = null; - List? GetFilters() + List GetFilters() { if (this.Get("UseFilters").IsChecked != true) - return null; + return new List(); return new List { new FileDialogFilter @@ -39,12 +50,23 @@ namespace ControlCatalog.Pages }; } + List? GetFileTypes() + { + if (this.Get("UseFilters").IsChecked != true) + return null; + return new List + { + FilePickerFileTypes.All, + FilePickerFileTypes.TextPlain + }; + } + this.Get