diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 04129cb82d..dc0eaf0a51 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -32,6 +32,7 @@ diff --git a/samples/ControlCatalog/Pages/TabControlPage.xaml b/samples/ControlCatalog/Pages/TabControlPage.xaml index a830ce69ac..e289de90f9 100644 --- a/samples/ControlCatalog/Pages/TabControlPage.xaml +++ b/samples/ControlCatalog/Pages/TabControlPage.xaml @@ -53,14 +53,8 @@ - - - - - - diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs index 59489ebcc0..f89d9d1e20 100644 --- a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reactive; using Avalonia.Controls; using Avalonia.Controls.Selection; +using ControlCatalog.Pages; using MiniMvvm; namespace ControlCatalog.ViewModels @@ -20,9 +21,9 @@ namespace ControlCatalog.ViewModels public ListBoxPageViewModel() { - Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); + Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); - Selection = new SelectionModel(); + Selection = new SelectionModel(); Selection.Select(1); _selectionMode = this.WhenAnyValue( @@ -58,8 +59,8 @@ namespace ControlCatalog.ViewModels }); } - public ObservableCollection Items { get; } - public SelectionModel Selection { get; } + public ObservableCollection Items { get; } + public SelectionModel Selection { get; } public IObservable SelectionMode => _selectionMode; public bool Multiple @@ -96,6 +97,31 @@ namespace ControlCatalog.ViewModels public MiniCommand RemoveItemCommand { get; } public MiniCommand SelectRandomItemCommand { get; } - private string GenerateItem() => $"Item {_counter++.ToString()}"; + private ItemModel GenerateItem() => new ItemModel(_counter ++); + } + + /// + /// An Item model for the + /// + public class ItemModel + { + /// + /// Creates a new ItemModel with the given ID + /// + /// The ID to display + public ItemModel(int id) + { + ID = id; + } + + /// + /// The ID of this Item + /// + public int ID { get; } + + public override string ToString() + { + return $"Item {ID}"; + } } } diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs index e20a4a9ad8..570c6a6d07 100644 --- a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs +++ b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs @@ -128,8 +128,11 @@ namespace Avalonia.Rendering.Composition.Animations left = kf; right = _keyFrames[c + 1]; - break; } + else if (c == 0) + return ExpressionVariant.Create(GetKeyFrame(ref ctx, kf)); + else + break; } var keyProgress = Math.Max(0, Math.Min(1, (iterationProgress - left.Key) / (right.Key - left.Key))); diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs index 98a6a3600e..38d9b34937 100644 --- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs +++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs @@ -146,8 +146,18 @@ public class CompositingRenderer : IRendererWithCompositor return result == 0 ? lhs.index.CompareTo(rhs.index) : result; }); } - - if (compositionChildren.Count == visualChildren.Count) + + var childVisual = v.ChildCompositionVisual; + + // Check if the current visual somehow got migrated to another compositor + if (childVisual != null && childVisual.Compositor != v.CompositionVisual.Compositor) + childVisual = null; + + var expectedCount = visualChildren.Count; + if (childVisual != null) + expectedCount++; + + if (compositionChildren.Count == expectedCount) { bool mismatch = false; if (sortedChildren != null) @@ -167,6 +177,9 @@ public class CompositingRenderer : IRendererWithCompositor break; } + if (childVisual != null && + !ReferenceEquals(compositionChildren[compositionChildren.Count - 1], childVisual)) + mismatch = true; if (!mismatch) { @@ -193,6 +206,9 @@ public class CompositingRenderer : IRendererWithCompositor if (compositionChild != null) compositionChildren.Add(compositionChild); } + + if (childVisual != null) + compositionChildren.Add(childVisual); } private void UpdateCore() diff --git a/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs b/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs index 1a13d23acd..00fa7b3315 100644 --- a/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs +++ b/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs @@ -29,4 +29,7 @@ public partial class Compositor public ImplicitAnimationCollection CreateImplicitAnimationCollection() => new ImplicitAnimationCollection(this); public CompositionAnimationGroup CreateAnimationGroup() => new CompositionAnimationGroup(this); + + public CompositionSolidColorVisual CreateSolidColorVisual() => + new(this, new ServerCompositionSolidColorVisual(Server)); } \ No newline at end of file diff --git a/src/Avalonia.Base/Rendering/Composition/ElementCompositionPreview.cs b/src/Avalonia.Base/Rendering/Composition/ElementCompositionPreview.cs index 5bd8e4a4d3..b01321edd8 100644 --- a/src/Avalonia.Base/Rendering/Composition/ElementCompositionPreview.cs +++ b/src/Avalonia.Base/Rendering/Composition/ElementCompositionPreview.cs @@ -1,5 +1,8 @@ // Special license applies License.md +using System; +using Avalonia.VisualTree; + namespace Avalonia.Rendering.Composition; /// @@ -13,4 +16,22 @@ public static class ElementComposition /// /// public static CompositionVisual? GetElementVisual(Visual visual) => visual.CompositionVisual; + + /// + /// Sets a custom as the last child of the element’s visual tree. + /// + public static void SetElementChildVisual(Visual visual, CompositionVisual? compositionVisual) + { + if (compositionVisual != null && visual.CompositionVisual != null && + compositionVisual.Compositor != visual.CompositionVisual.Compositor) + throw new InvalidOperationException("Composition visuals belong to different compositor instances"); + + visual.ChildCompositionVisual = compositionVisual; + visual.GetVisualRoot()?.Renderer.RecalculateChildren(visual); + } + + /// + /// Retrieves a object previously set by a call to . + /// + public static CompositionVisual? GetElementChildVisual(Visual visual) => visual.ChildCompositionVisual; } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSolidColorVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSolidColorVisual.cs new file mode 100644 index 0000000000..79abd7ee17 --- /dev/null +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionSolidColorVisual.cs @@ -0,0 +1,11 @@ +using Avalonia.Media.Immutable; + +namespace Avalonia.Rendering.Composition.Server; + +internal partial class ServerCompositionSolidColorVisual +{ + protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip) + { + canvas.DrawRectangle(new ImmutableSolidColorBrush(Color), null, new Rect(0, 0, Size.X, Size.Y)); + } +} \ No newline at end of file diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs index b742f1d44a..d724e14298 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -48,7 +48,7 @@ namespace Avalonia.Rendering.Composition.Server if (Opacity != 1) canvas.PushOpacity(Opacity); var boundsRect = new Rect(new Size(Size.X, Size.Y)); - if(ClipToBounds) + if (ClipToBounds && !HandlesClipToBounds) canvas.PushClip(Root!.SnapToDevicePixels(boundsRect)); if (Clip != null) canvas.PushGeometryClip(Clip); @@ -65,11 +65,13 @@ namespace Avalonia.Rendering.Composition.Server canvas.PopOpacityMask(); if (Clip != null) canvas.PopGeometryClip(); - if (ClipToBounds) + if (ClipToBounds && !HandlesClipToBounds) canvas.PopClip(); if(Opacity != 1) canvas.PopOpacity(); } + + protected virtual bool HandlesClipToBounds => false; private ReadbackData _readback0, _readback1, _readback2; diff --git a/src/Avalonia.Base/Threading/DispatcherPriority.cs b/src/Avalonia.Base/Threading/DispatcherPriority.cs index f041590fc0..b3194e249b 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriority.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriority.cs @@ -45,32 +45,37 @@ namespace Avalonia.Threading /// /// The job will be processed after other non-idle operations have completed. /// - public static readonly DispatcherPriority Background = new(1); + public static readonly DispatcherPriority Background = new(MinValue + 1); /// /// The job will be processed with the same priority as input. /// - public static readonly DispatcherPriority Input = new(2); + public static readonly DispatcherPriority Input = new(Background + 1); /// /// The job will be processed after layout and render but before input. /// - public static readonly DispatcherPriority Loaded = new(3); - + public static readonly DispatcherPriority Loaded = new(Input + 1); + /// /// The job will be processed with the same priority as render. /// - public static readonly DispatcherPriority Render = new(5); - + public static readonly DispatcherPriority Render = new(Loaded + 1); + /// /// The job will be processed with the same priority as composition updates. /// - public static readonly DispatcherPriority Composition = new(6); - + public static readonly DispatcherPriority Composition = new(Render + 1); + /// - /// The job will be processed with the same priority as render. + /// The job will be processed with before composition updates. + /// + public static readonly DispatcherPriority PreComposition = new(Composition + 1); + + /// + /// The job will be processed with the same priority as layout. /// - public static readonly DispatcherPriority Layout = new(7); + public static readonly DispatcherPriority Layout = new(PreComposition + 1); /// /// The job will be processed with the same priority as data binding. @@ -80,7 +85,7 @@ namespace Avalonia.Threading /// /// The job will be processed before other asynchronous operations. /// - public static readonly DispatcherPriority Send = new(8); + public static readonly DispatcherPriority Send = new(Layout + 1); /// /// Maximum possible priority @@ -123,4 +128,4 @@ namespace Avalonia.Threading /// public int CompareTo(DispatcherPriority other) => Value.CompareTo(other.Value); } -} \ No newline at end of file +} diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 29559a8618..2bb4a29b3c 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -292,6 +292,7 @@ namespace Avalonia protected IRenderRoot? VisualRoot => _visualRoot ?? (this as IRenderRoot); internal CompositionDrawListVisual? CompositionVisual { get; private set; } + internal CompositionVisual? ChildCompositionVisual { get; set; } public bool HasNonUniformZIndexChildren { get; private set; } @@ -452,12 +453,15 @@ namespace Avalonia } } + private protected virtual CompositionDrawListVisual CreateCompositionVisual(Compositor compositor) + => new CompositionDrawListVisual(compositor, + new ServerCompositionDrawListVisual(compositor.Server, this), this); + internal CompositionVisual AttachToCompositor(Compositor compositor) { if (CompositionVisual == null || CompositionVisual.Compositor != compositor) { - CompositionVisual = new CompositionDrawListVisual(compositor, - new ServerCompositionDrawListVisual(compositor.Server, this), this); + CompositionVisual = CreateCompositionVisual(compositor); } return CompositionVisual; diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index e0e177da44..6dfcb2e74d 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -27,6 +27,9 @@ + + + diff --git a/src/Avalonia.Controls/Border.cs b/src/Avalonia.Controls/Border.cs index bc740c133a..1bb574acd2 100644 --- a/src/Avalonia.Controls/Border.cs +++ b/src/Avalonia.Controls/Border.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Shapes; using Avalonia.Controls.Utils; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Rendering.Composition; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -73,6 +74,7 @@ namespace Avalonia.Controls private readonly BorderRenderHelper _borderRenderHelper = new BorderRenderHelper(); private Thickness? _layoutThickness; private double _scale; + private CompositionBorderVisual? _borderVisual; /// /// Initializes static members of the class. @@ -101,6 +103,10 @@ namespace Avalonia.Controls case nameof(BorderThickness): _layoutThickness = null; break; + case nameof(CornerRadius): + if (_borderVisual != null) + _borderVisual.CornerRadius = CornerRadius; + break; } } @@ -245,6 +251,14 @@ namespace Avalonia.Controls return LayoutHelper.ArrangeChild(Child, finalSize, Padding, BorderThickness); } + private protected override CompositionDrawListVisual CreateCompositionVisual(Compositor compositor) + { + return _borderVisual = new CompositionBorderVisual(compositor, this) + { + CornerRadius = CornerRadius + }; + } + public CornerRadius ClipToBoundsRadius => CornerRadius; } } diff --git a/src/Avalonia.Controls/BorderVisual.cs b/src/Avalonia.Controls/BorderVisual.cs new file mode 100644 index 0000000000..7afbf9edcf --- /dev/null +++ b/src/Avalonia.Controls/BorderVisual.cs @@ -0,0 +1,76 @@ +using System; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Server; +using Avalonia.Rendering.Composition.Transport; +using Avalonia.Rendering.SceneGraph; + +namespace Avalonia.Controls; + +class CompositionBorderVisual : CompositionDrawListVisual +{ + private CornerRadius _cornerRadius; + private bool _cornerRadiusChanged; + + public CompositionBorderVisual(Compositor compositor, Visual visual) : base(compositor, + new ServerBorderVisual(compositor.Server, visual), visual) + { + } + + public CornerRadius CornerRadius + { + get => _cornerRadius; + set + { + if (_cornerRadius != value) + { + _cornerRadiusChanged = true; + _cornerRadius = value; + RegisterForSerialization(); + } + } + } + + private protected override void SerializeChangesCore(BatchStreamWriter writer) + { + base.SerializeChangesCore(writer); + writer.Write(_cornerRadiusChanged); + if (_cornerRadiusChanged) + writer.Write(_cornerRadius); + } + + class ServerBorderVisual : ServerCompositionDrawListVisual + { + private CornerRadius _cornerRadius; + public ServerBorderVisual(ServerCompositor compositor, Visual v) : base(compositor, v) + { + } + + protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip) + { + if (ClipToBounds) + { + var clipRect = Root!.SnapToDevicePixels(new Rect(new Size(Size.X, Size.Y))); + if (_cornerRadius.IsEmpty) + canvas.PushClip(clipRect); + else + canvas.PushClip(new RoundedRect(clipRect, _cornerRadius)); + } + + base.RenderCore(canvas, currentTransformedClip); + + if(ClipToBounds) + canvas.PopClip(); + + } + + protected override void DeserializeChangesCore(BatchStreamReader reader, TimeSpan commitedAt) + { + base.DeserializeChangesCore(reader, commitedAt); + if (reader.Read()) + _cornerRadius = reader.Read(); + } + + protected override bool HandlesClipToBounds => true; + } + +} \ No newline at end of file diff --git a/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs index f9772cb399..79c77f2519 100644 --- a/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Styling; namespace Avalonia.Controls.Generators @@ -24,6 +25,11 @@ namespace Avalonia.Controls.Generators /// Gets or sets the data template used to display the items in the control. /// IDataTemplate? ItemTemplate { get; set; } + + /// + /// Gets or sets the binding to use to bind to the member of an item used for displaying + /// + IBinding? DisplayMemberBinding { get; set; } /// /// Gets the ContainerType, or null if its an untyped ContainerGenerator. diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index 7fed6cb82c..4ce48d2a06 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -45,6 +45,9 @@ namespace Avalonia.Controls.Generators /// Gets or sets the data template used to display the items in the control. /// public IDataTemplate? ItemTemplate { get; set; } + + /// + public IBinding? DisplayMemberBinding { get; set; } /// /// Gets the owner control. @@ -189,7 +192,15 @@ namespace Avalonia.Controls.Generators if (result == null) { result = new ContentPresenter(); - result.SetValue(ContentPresenter.ContentProperty, item, BindingPriority.Style); + if (DisplayMemberBinding is not null) + { + result.SetValue(StyledElement.DataContextProperty, item, BindingPriority.Style); + result.Bind(ContentPresenter.ContentProperty, DisplayMemberBinding, BindingPriority.Style); + } + else + { + result.SetValue(ContentPresenter.ContentProperty, item, BindingPriority.Style); + } if (ItemTemplate != null) { diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs index 3ff1b0702d..5e965a9d04 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs @@ -53,8 +53,16 @@ namespace Avalonia.Controls.Generators container.SetValue(ContentTemplateProperty, ItemTemplate, BindingPriority.Style); } - container.SetValue(ContentProperty, item, BindingPriority.Style); - + if (DisplayMemberBinding is not null) + { + container.SetValue(StyledElement.DataContextProperty, item, BindingPriority.Style); + container.Bind(ContentProperty, DisplayMemberBinding, BindingPriority.Style); + } + else + { + container.SetValue(ContentProperty, item, BindingPriority.Style); + } + if (!(item is IControl)) { container.DataContext = item; diff --git a/src/Avalonia.Controls/Generators/TabItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/TabItemContainerGenerator.cs index c6b0bda9af..4021b8436a 100644 --- a/src/Avalonia.Controls/Generators/TabItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/TabItemContainerGenerator.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.LogicalTree; using Avalonia.Reactive; using Avalonia.VisualTree; @@ -33,6 +34,12 @@ namespace Avalonia.Controls.Generators TabControl.ItemTemplateProperty)); } + if (Owner.HeaderDisplayMemberBinding is not null) + { + tabItem.Bind(HeaderedContentControl.HeaderProperty, Owner.HeaderDisplayMemberBinding, + BindingPriority.Style); + } + if (tabItem.Header == null) { if (item is IHeadered headered) diff --git a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs index 4e3deb5552..2d8cb05e03 100644 --- a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs @@ -76,7 +76,15 @@ namespace Avalonia.Controls.Generators result.SetValue(Control.ThemeProperty, ItemContainerTheme, BindingPriority.Style); } - result.SetValue(ContentProperty, template.Build(item), BindingPriority.Style); + if (DisplayMemberBinding is not null) + { + result.SetValue(StyledElement.DataContextProperty, item, BindingPriority.Style); + result.Bind(ContentProperty, DisplayMemberBinding, BindingPriority.Style); + } + else + { + result.SetValue(ContentProperty, template.Build(item), BindingPriority.Style); + } var itemsSelector = template.ItemsSelector(item); diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 345e7fcac8..e9ce7912a7 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -11,6 +11,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Metadata; @@ -61,6 +62,23 @@ namespace Avalonia.Controls public static readonly StyledProperty ItemTemplateProperty = AvaloniaProperty.Register(nameof(ItemTemplate)); + + /// + /// Defines the property + /// + public static readonly StyledProperty DisplayMemberBindingProperty = + AvaloniaProperty.Register(nameof(DisplayMemberBinding)); + + /// + /// Gets or sets the to use for binding to the display member of each item. + /// + [AssignBinding] + public IBinding? DisplayMemberBinding + { + get { return GetValue(DisplayMemberBindingProperty); } + set { SetValue(DisplayMemberBindingProperty, value); } + } + private IEnumerable? _items = new AvaloniaList(); private int _itemCount; private IItemContainerGenerator? _itemContainerGenerator; @@ -97,6 +115,7 @@ namespace Avalonia.Controls _itemContainerGenerator.ItemContainerTheme = ItemContainerTheme; _itemContainerGenerator.ItemTemplate = ItemTemplate; + _itemContainerGenerator.DisplayMemberBinding = DisplayMemberBinding; _itemContainerGenerator.Materialized += (_, e) => OnContainersMaterialized(e); _itemContainerGenerator.Dematerialized += (_, e) => OnContainersDematerialized(e); _itemContainerGenerator.Recycled += (_, e) => OnContainersRecycled(e); diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index 2821fa8cf0..836433cdf8 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -5,6 +5,7 @@ using Avalonia.Collections; using Avalonia.Controls.Generators; using Avalonia.Controls.Templates; using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.LogicalTree; using Avalonia.Styling; @@ -33,6 +34,12 @@ namespace Avalonia.Controls.Presenters public static readonly StyledProperty ItemTemplateProperty = ItemsControl.ItemTemplateProperty.AddOwner(); + /// + /// Defines the property + /// + public static readonly StyledProperty DisplayMemberBindingProperty = + ItemsControl.DisplayMemberBindingProperty.AddOwner(); + private IEnumerable? _items; private IDisposable? _itemsSubscription; private bool _createdPanel; @@ -120,6 +127,15 @@ namespace Avalonia.Controls.Presenters set { SetValue(ItemTemplateProperty, value); } } + /// + /// Gets or sets the to use for binding to the display member of each item. + /// + public IBinding? DisplayMemberBinding + { + get { return GetValue(DisplayMemberBindingProperty); } + set { SetValue(DisplayMemberBindingProperty, value); } + } + /// /// Gets the panel used to display the items. /// @@ -177,6 +193,7 @@ namespace Avalonia.Controls.Presenters { result = new ItemContainerGenerator(this); result.ItemTemplate = ItemTemplate; + result.DisplayMemberBinding = DisplayMemberBinding; } result.Materialized += ContainerActionHandler; diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index 70fecc7ce1..63738716c0 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -12,6 +12,7 @@ using Avalonia.LogicalTree; using Avalonia.VisualTree; using Avalonia.Automation; using Avalonia.Controls.Metadata; +using Avalonia.Data; namespace Avalonia.Controls { @@ -57,6 +58,12 @@ namespace Avalonia.Controls public static readonly StyledProperty SelectedContentTemplateProperty = AvaloniaProperty.Register(nameof(SelectedContentTemplate)); + /// + /// Defines the property + /// + public static readonly StyledProperty HeaderDisplayMemberBindingProperty = + AvaloniaProperty.Register(nameof(HeaderDisplayMemberBinding)); + /// /// The default value for the property. /// @@ -134,6 +141,16 @@ namespace Avalonia.Controls get { return GetValue(SelectedContentTemplateProperty); } internal set { SetValue(SelectedContentTemplateProperty, value); } } + + /// + /// Gets or sets the to use for binding to the display member of each tab-items header. + /// + [AssignBinding] + public IBinding? HeaderDisplayMemberBinding + { + get { return GetValue(HeaderDisplayMemberBindingProperty); } + set { SetValue(HeaderDisplayMemberBindingProperty, value); } + } internal ItemsPresenter? ItemsPresenterPart { get; private set; } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index d5b45398e7..358bcfdad7 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -17,7 +17,6 @@ using Avalonia.Controls.Metadata; using Avalonia.Media.TextFormatting; using Avalonia.Media.TextFormatting.Unicode; using Avalonia.Automation.Peers; -using System.Diagnostics; using Avalonia.Threading; namespace Avalonia.Controls @@ -29,60 +28,108 @@ namespace Avalonia.Controls [PseudoClasses(":empty")] public class TextBox : TemplatedControl, UndoRedoHelper.IUndoRedoHost { + /// + /// Gets a platform-specific for the Cut action + /// public static KeyGesture? CutGesture { get; } = AvaloniaLocator.Current .GetService()?.Cut.FirstOrDefault(); + /// + /// Gets a platform-specific for the Copy action + /// public static KeyGesture? CopyGesture { get; } = AvaloniaLocator.Current .GetService()?.Copy.FirstOrDefault(); + /// + /// Gets a platform-specific for the Paste action + /// public static KeyGesture? PasteGesture { get; } = AvaloniaLocator.Current .GetService()?.Paste.FirstOrDefault(); + /// + /// Defines the property + /// public static readonly StyledProperty AcceptsReturnProperty = AvaloniaProperty.Register(nameof(AcceptsReturn)); + /// + /// Defines the property + /// public static readonly StyledProperty AcceptsTabProperty = AvaloniaProperty.Register(nameof(AcceptsTab)); + /// + /// Defines the property + /// public static readonly DirectProperty CaretIndexProperty = AvaloniaProperty.RegisterDirect( nameof(CaretIndex), o => o.CaretIndex, (o, v) => o.CaretIndex = v); + /// + /// Defines the property + /// public static readonly StyledProperty IsReadOnlyProperty = AvaloniaProperty.Register(nameof(IsReadOnly)); + /// + /// Defines the property + /// public static readonly StyledProperty PasswordCharProperty = AvaloniaProperty.Register(nameof(PasswordChar)); + /// + /// Defines the property + /// public static readonly StyledProperty SelectionBrushProperty = AvaloniaProperty.Register(nameof(SelectionBrush)); + /// + /// Defines the property + /// public static readonly StyledProperty SelectionForegroundBrushProperty = AvaloniaProperty.Register(nameof(SelectionForegroundBrush)); + /// + /// Defines the property + /// public static readonly StyledProperty CaretBrushProperty = AvaloniaProperty.Register(nameof(CaretBrush)); + /// + /// Defines the property + /// public static readonly DirectProperty SelectionStartProperty = AvaloniaProperty.RegisterDirect( nameof(SelectionStart), o => o.SelectionStart, (o, v) => o.SelectionStart = v); + /// + /// Defines the property + /// public static readonly DirectProperty SelectionEndProperty = AvaloniaProperty.RegisterDirect( nameof(SelectionEnd), o => o.SelectionEnd, (o, v) => o.SelectionEnd = v); + /// + /// Defines the property + /// public static readonly StyledProperty MaxLengthProperty = AvaloniaProperty.Register(nameof(MaxLength), defaultValue: 0); + /// + /// Defines the property + /// public static readonly StyledProperty MaxLinesProperty = AvaloniaProperty.Register(nameof(MaxLines), defaultValue: 0); + /// + /// Defines the property + /// public static readonly DirectProperty TextProperty = TextBlock.TextProperty.AddOwnerWithDataValidation( o => o.Text, @@ -90,6 +137,9 @@ namespace Avalonia.Controls defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); + /// + /// Defines the property + /// public static readonly StyledProperty TextAlignmentProperty = TextBlock.TextAlignmentProperty.AddOwner(); @@ -120,45 +170,78 @@ namespace Avalonia.Controls public static readonly StyledProperty LetterSpacingProperty = TextBlock.LetterSpacingProperty.AddOwner(); + /// + /// Defines the property + /// public static readonly StyledProperty WatermarkProperty = AvaloniaProperty.Register(nameof(Watermark)); + /// + /// Defines the property + /// public static readonly StyledProperty UseFloatingWatermarkProperty = AvaloniaProperty.Register(nameof(UseFloatingWatermark)); + /// + /// Defines the property + /// public static readonly DirectProperty NewLineProperty = AvaloniaProperty.RegisterDirect(nameof(NewLine), textbox => textbox.NewLine, (textbox, newline) => textbox.NewLine = newline); + /// + /// Defines the property + /// public static readonly StyledProperty InnerLeftContentProperty = AvaloniaProperty.Register(nameof(InnerLeftContent)); + /// + /// Defines the property + /// public static readonly StyledProperty InnerRightContentProperty = AvaloniaProperty.Register(nameof(InnerRightContent)); + /// + /// Defines the property + /// public static readonly StyledProperty RevealPasswordProperty = AvaloniaProperty.Register(nameof(RevealPassword)); + /// + /// Defines the property + /// public static readonly DirectProperty CanCutProperty = AvaloniaProperty.RegisterDirect( nameof(CanCut), o => o.CanCut); + /// + /// Defines the property + /// public static readonly DirectProperty CanCopyProperty = AvaloniaProperty.RegisterDirect( nameof(CanCopy), o => o.CanCopy); + /// + /// Defines the property + /// public static readonly DirectProperty CanPasteProperty = AvaloniaProperty.RegisterDirect( nameof(CanPaste), o => o.CanPaste); + /// + /// Defines the property + /// public static readonly StyledProperty IsUndoEnabledProperty = AvaloniaProperty.Register( nameof(IsUndoEnabled), defaultValue: true); + /// + /// Defines the property + /// public static readonly DirectProperty UndoLimitProperty = AvaloniaProperty.RegisterDirect( nameof(UndoLimit), @@ -166,6 +249,18 @@ namespace Avalonia.Controls (o, v) => o.UndoLimit = v, unsetValue: -1); + /// + /// Defines the property + /// + public static readonly DirectProperty CanUndoProperty = + AvaloniaProperty.RegisterDirect(nameof(CanUndo), x => x.CanUndo); + + /// + /// Defines the property + /// + public static readonly DirectProperty CanRedoProperty = + AvaloniaProperty.RegisterDirect(nameof(CanRedo), x => x.CanRedo); + /// /// Defines the event. /// @@ -201,9 +296,13 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(TextChanging), RoutingStrategies.Bubble); + /// + /// Stores the state information for available actions in the UndoRedoHelper + /// readonly struct UndoRedoState : IEquatable { public string? Text { get; } + public int CaretPosition { get; } public UndoRedoState(string? text, int caretPosition) @@ -232,6 +331,8 @@ namespace Avalonia.Controls private bool _canPaste; private string _newLine = Environment.NewLine; private static readonly string[] invalidCharacters = new String[1] { "\u007f" }; + private bool _canUndo; + private bool _canRedo; private int _wordSelectionStart = -1; private int _selectedTextChangesMadeSinceLastUndoSnapshot; @@ -268,24 +369,34 @@ namespace Avalonia.Controls ScrollViewer.HorizontalScrollBarVisibilityProperty, horizontalScrollBarVisibility, BindingPriority.Style); + _undoRedoHelper = new UndoRedoHelper(this); _selectedTextChangesMadeSinceLastUndoSnapshot = 0; _hasDoneSnapshotOnce = false; UpdatePseudoclasses(); } + /// + /// Gets or sets a value that determines whether the TextBox allows and displays newline or return characters + /// public bool AcceptsReturn { get => GetValue(AcceptsReturnProperty); set => SetValue(AcceptsReturnProperty, value); } + /// + /// Gets or sets a value that determins whether the TextBox allows and displays tabs + /// public bool AcceptsTab { get => GetValue(AcceptsTabProperty); set => SetValue(AcceptsTabProperty, value); } + /// + /// Gets or sets the index of the text caret + /// public int CaretIndex { get => _caretIndex; @@ -302,36 +413,54 @@ namespace Avalonia.Controls } } + /// + /// Gets or sets a value whether this TextBox is read-only + /// public bool IsReadOnly { get => GetValue(IsReadOnlyProperty); set => SetValue(IsReadOnlyProperty, value); } + /// + /// Gets or sets the that should be used for password masking + /// public char PasswordChar { get => GetValue(PasswordCharProperty); set => SetValue(PasswordCharProperty, value); } + /// + /// Gets or sets a brush that is used to highlight selected text + /// public IBrush? SelectionBrush { get => GetValue(SelectionBrushProperty); set => SetValue(SelectionBrushProperty, value); } + /// + /// Gets or sets a brush that is used for the foreground of selected text + /// public IBrush? SelectionForegroundBrush { get => GetValue(SelectionForegroundBrushProperty); set => SetValue(SelectionForegroundBrushProperty, value); } + /// + /// Gets or sets a brush that is used for the text caret + /// public IBrush? CaretBrush { get => GetValue(CaretBrushProperty); set => SetValue(CaretBrushProperty, value); } + /// + /// Gets or sets the starting position of the text selected in the TextBox + /// public int SelectionStart { get => _selectionStart; @@ -352,6 +481,13 @@ namespace Avalonia.Controls } } + /// + /// Gets or sets the end position of the text selected in the TextBox + /// + /// + /// When the SelectionEnd is equal to , there is no + /// selected text and it marks the caret position + /// public int SelectionEnd { get => _selectionEnd; @@ -371,19 +507,28 @@ namespace Avalonia.Controls } } } - + + /// + /// Gets or sets the maximum character length of the TextBox + /// public int MaxLength { get => GetValue(MaxLengthProperty); set => SetValue(MaxLengthProperty, value); } + /// + /// Gets or sets the maximum number of lines the TextBox can contain + /// public int MaxLines { get => GetValue(MaxLinesProperty); set => SetValue(MaxLinesProperty, value); } + /// + /// Gets or sets the spacing between characters + /// public double LetterSpacing { get => GetValue(LetterSpacingProperty); @@ -399,6 +544,9 @@ namespace Avalonia.Controls set => SetValue(LineHeightProperty, value); } + /// + /// Gets or sets the Text content of the TextBox + /// [Content] public string? Text { @@ -413,14 +561,20 @@ namespace Avalonia.Controls SelectionStart = CoerceCaretIndex(selectionStart, value); SelectionEnd = CoerceCaretIndex(selectionEnd, value); - var textChanged = SetAndRaise(TextProperty, ref _text, value); - - if (textChanged && IsUndoEnabled && !_isUndoingRedoing) + // Before #9490, snapshot here was done AFTER text change - this doesn't make sense + // since intial state would never be no text and you'd always have to make a text + // change before undo would be available + // The undo/redo stacks were also cleared at this point, which also doesn't make sense + // as it is still valid to want to undo a programmatic text set + // So we snapshot text now BEFORE the change so we can always revert + // Also don't need to check IsUndoEnabled here, that's done in SnapshotUndoRedo + if (!_isUndoingRedoing) { - _undoRedoHelper.Clear(); - SnapshotUndoRedo(); // so we always have an initial state + SnapshotUndoRedo(); } + var textChanged = SetAndRaise(TextProperty, ref _text, value); + if (textChanged) { RaiseTextChangeEvents(); @@ -428,6 +582,9 @@ namespace Avalonia.Controls } } + /// + /// Gets or sets the text selected in the TextBox + /// public string SelectedText { get => GetSelection(); @@ -464,6 +621,9 @@ namespace Avalonia.Controls set => SetValue(VerticalContentAlignmentProperty, value); } + /// + /// Gets or sets the of the TextBox + /// public TextAlignment TextAlignment { get => GetValue(TextAlignmentProperty); @@ -490,24 +650,36 @@ namespace Avalonia.Controls set => SetValue(UseFloatingWatermarkProperty, value); } + /// + /// Gets or sets custom content that is positioned on the left side of the text layout box + /// public object InnerLeftContent { get => GetValue(InnerLeftContentProperty); set => SetValue(InnerLeftContentProperty, value); } + /// + /// Gets or sets custom content that is positioned on the right side of the text layout box + /// public object InnerRightContent { get => GetValue(InnerRightContentProperty); set => SetValue(InnerRightContentProperty, value); } + /// + /// Gets or sets whether text masked by should be revealed + /// public bool RevealPassword { get => GetValue(RevealPasswordProperty); set => SetValue(RevealPasswordProperty, value); } + /// + /// Gets or sets the of the TextBox + /// public TextWrapping TextWrapping { get => GetValue(TextWrappingProperty); @@ -567,6 +739,9 @@ namespace Avalonia.Controls set => SetValue(IsUndoEnabledProperty, value); } + /// + /// Gets or sets the maximum number of items that can reside in the Undo stack + /// public int UndoLimit { get => _undoRedoHelper.Limit; @@ -590,18 +765,45 @@ namespace Avalonia.Controls } } + /// + /// Gets a value that indicates whether the undo stack has an action that can be undone + /// + public bool CanUndo + { + get => _canUndo; + private set => SetAndRaise(CanUndoProperty, ref _canUndo, value); + } + + /// + /// Gets a value that indicates whether the redo stack has an action that can be redone + /// + public bool CanRedo + { + get => _canRedo; + private set => SetAndRaise(CanRedoProperty, ref _canRedo, value); + } + + /// + /// Raised when content is being copied to the clipboard + /// public event EventHandler? CopyingToClipboard { add => AddHandler(CopyingToClipboardEvent, value); remove => RemoveHandler(CopyingToClipboardEvent, value); } + /// + /// Raised when content is being cut to the clipboard + /// public event EventHandler? CuttingToClipboard { add => AddHandler(CuttingToClipboardEvent, value); remove => RemoveHandler(CuttingToClipboardEvent, value); } + /// + /// Raised when content is being pasted from the clipboard + /// public event EventHandler? PastingFromClipboard { add => AddHandler(PastingFromClipboardEvent, value); @@ -831,6 +1033,9 @@ namespace Avalonia.Controls return text; } + /// + /// Cuts the current text onto the clipboard + /// public async void Cut() { var text = GetSelection(); @@ -851,6 +1056,9 @@ namespace Avalonia.Controls } } + /// + /// Copies the current text onto the clipboard + /// public async void Copy() { var text = GetSelection(); @@ -869,6 +1077,9 @@ namespace Avalonia.Controls } } + /// + /// Pastes the current clipboard text content into the TextBox + /// public async void Paste() { var eventArgs = new RoutedEventArgs(PastingFromClipboardEvent); @@ -943,30 +1154,13 @@ namespace Avalonia.Controls } else if (Match(keymap.Undo) && IsUndoEnabled) { - try - { - SnapshotUndoRedo(); - _isUndoingRedoing = true; - _undoRedoHelper.Undo(); - } - finally - { - _isUndoingRedoing = false; - } + Undo(); handled = true; } else if (Match(keymap.Redo) && IsUndoEnabled) { - try - { - _isUndoingRedoing = true; - _undoRedoHelper.Redo(); - } - finally - { - _isUndoingRedoing = false; - } + Redo(); handled = true; } @@ -1420,6 +1614,9 @@ namespace Avalonia.Controls } } + /// + /// Clears the text in the TextBox + /// public void Clear() { Text = string.Empty; @@ -1703,5 +1900,62 @@ namespace Avalonia.Controls } } } + + /// + /// Undoes the first action in the undo stack + /// + public void Undo() + { + if (IsUndoEnabled && CanUndo) + { + try + { + // Snapshot the current Text state - this will get popped on to the redo stack + // when we call undo below + SnapshotUndoRedo(); + _isUndoingRedoing = true; + _undoRedoHelper.Undo(); + } + finally + { + _isUndoingRedoing = false; + } + } + } + + /// + /// Reapplies the first item on the redo stack + /// + public void Redo() + { + if (IsUndoEnabled && CanRedo) + { + try + { + _isUndoingRedoing = true; + _undoRedoHelper.Redo(); + } + finally + { + _isUndoingRedoing = false; + } + } + } + + /// + /// Called from the UndoRedoHelper when the undo stack is modified + /// + void UndoRedoHelper.IUndoRedoHost.OnUndoStackChanged() + { + CanUndo = _undoRedoHelper.CanUndo; + } + + /// + /// Called from the UndoRedoHelper when the redo stack is modified + /// + void UndoRedoHelper.IUndoRedoHost.OnRedoStackChanged() + { + CanRedo = _undoRedoHelper.CanRedo; + } } } diff --git a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs index 0d5048c080..6ff72751a6 100644 --- a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs +++ b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs @@ -1,9 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Avalonia.Utilities; namespace Avalonia.Controls.Utils { @@ -14,9 +9,11 @@ namespace Avalonia.Controls.Utils public interface IUndoRedoHost { TState UndoRedoState { get; set; } - } + void OnUndoStackChanged(); + void OnRedoStackChanged(); + } private readonly LinkedList _states = new LinkedList(); @@ -28,6 +25,10 @@ namespace Avalonia.Controls.Utils /// public int Limit { get; set; } = 10; + public bool CanUndo => _currentNode?.Previous != null; + + public bool CanRedo => _currentNode?.Next != null; + public UndoRedoHelper(IUndoRedoHost host) { _host = host; @@ -39,6 +40,8 @@ namespace Avalonia.Controls.Utils { _currentNode = _currentNode.Previous; _host.UndoRedoState = _currentNode.Value; + _host.OnUndoStackChanged(); + _host.OnRedoStackChanged(); } } @@ -55,6 +58,7 @@ namespace Avalonia.Controls.Utils } public bool HasState => _currentNode != null; + public void UpdateLastState(TState state) { if (_states.Last != null) @@ -72,6 +76,8 @@ namespace Avalonia.Controls.Utils { while (_currentNode?.Next != null) _states.Remove(_currentNode.Next); + + _host.OnRedoStackChanged(); } public void Redo() @@ -80,6 +86,8 @@ namespace Avalonia.Controls.Utils { _currentNode = _currentNode.Next; _host.UndoRedoState = _currentNode.Value; + _host.OnRedoStackChanged(); + _host.OnUndoStackChanged(); } } @@ -94,6 +102,9 @@ namespace Avalonia.Controls.Utils _currentNode = _states.Last; if (Limit != -1 && _states.Count > Limit) _states.RemoveFirst(); + + _host.OnUndoStackChanged(); + _host.OnRedoStackChanged(); } } @@ -101,6 +112,9 @@ namespace Avalonia.Controls.Utils { _states.Clear(); _currentNode = null; + + _host.OnUndoStackChanged(); + _host.OnRedoStackChanged(); } } } diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index f08653a4f8..944f974cb0 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -4,6 +4,7 @@ using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; +using Avalonia.Data; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Styling; @@ -736,6 +737,25 @@ namespace Avalonia.Controls.UnitTests root.Child = null; root.Child = target; } + + [Fact] + public void Should_Use_DisplayMemberBinding() + { + var target = new ItemsControl + { + Template = GetTemplate(), + DisplayMemberBinding = new Binding("Length") + }; + + target.Items = new[] { "Foo" }; + target.ApplyTemplate(); + target.Presenter.ApplyTemplate(); + + var container = (ContentPresenter)target.Presenter.Panel.Children[0]; + container.UpdateChild(); + + Assert.Equal(container.Child!.GetValue(TextBlock.TextProperty), "3"); + } private class Item { diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 4680f38d94..09d183c115 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -866,6 +866,176 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void CanUndo_CanRedo_Is_False_When_Initialized() + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + Text = "New Text" + }; + + tb.Measure(Size.Infinity); + + Assert.False(tb.CanUndo); + Assert.False(tb.CanRedo); + } + } + + [Fact] + public void CanUndo_CanRedo_and_Programmatic_Undo_Redo_Works() + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + }; + + tb.Measure(Size.Infinity); + + // See GH #6024 for a bit more insight on when Undo/Redo snapshots are taken: + // - Every 'Space', but only when space is handled in OnKeyDown - Spaces in TextInput event won't work + // - Every 7 chars in a long word + RaiseTextEvent(tb, "ABC"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "DEF"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "123"); + + // NOTE: the spaces won't actually add spaces b/c they're sent only as key events and not Text events + // so our final text is without spaces + Assert.Equal("ABCDEF123", tb.Text); + + Assert.True(tb.CanUndo); + + tb.Undo(); + + // Undo will take us back one step + Assert.Equal("ABCDEF", tb.Text); + + Assert.True(tb.CanRedo); + + tb.Redo(); + + // Redo should restore us + Assert.Equal("ABCDEF123", tb.Text); + } + } + + [Fact] + public void Setting_UndoLimit_Clears_Undo_Redo() + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + }; + + tb.Measure(Size.Infinity); + + // This is all the same as the above test (CanUndo_CanRedo_and_Programmatic_Undo_Redo_Works) + // We do this to get the undo/redo stacks in a state where both are active + RaiseTextEvent(tb, "ABC"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "DEF"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "123"); + + Assert.Equal("ABCDEF123", tb.Text); + Assert.True(tb.CanUndo); + tb.Undo(); + // Undo will take us back one step + Assert.Equal("ABCDEF", tb.Text); + Assert.True(tb.CanRedo); + tb.Redo(); + // Redo should restore us + Assert.Equal("ABCDEF123", tb.Text); + + // Change the undo limit, this should clear both stacks setting CanUndo and CanRedo to false + tb.UndoLimit = 1; + + Assert.False(tb.CanUndo); + Assert.False(tb.CanRedo); + } + } + + [Fact] + public void Setting_IsUndoEnabled_To_False_Clears_Undo_Redo() + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + }; + + tb.Measure(Size.Infinity); + + // This is all the same as the above test (CanUndo_CanRedo_and_Programmatic_Undo_Redo_Works) + // We do this to get the undo/redo stacks in a state where both are active + RaiseTextEvent(tb, "ABC"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "DEF"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "123"); + + Assert.Equal("ABCDEF123", tb.Text); + Assert.True(tb.CanUndo); + tb.Undo(); + // Undo will take us back one step + Assert.Equal("ABCDEF", tb.Text); + Assert.True(tb.CanRedo); + tb.Redo(); + // Redo should restore us + Assert.Equal("ABCDEF123", tb.Text); + + // Disable Undo/Redo, this should clear both stacks setting CanUndo and CanRedo to false + tb.IsUndoEnabled = false; + + Assert.False(tb.CanUndo); + Assert.False(tb.CanRedo); + } + } + + [Fact] + public void UndoLimit_Count_Is_Respected() + { + using (UnitTestApplication.Start(Services)) + { + var tb = new TextBox + { + Template = CreateTemplate(), + UndoLimit = 3 // Something small for this test + }; + + tb.Measure(Size.Infinity); + + // Push 3 undoable actions, we should only be able to recover 2 + RaiseTextEvent(tb, "ABC"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "DEF"); + RaiseKeyEvent(tb, Key.Space, KeyModifiers.None); + RaiseTextEvent(tb, "123"); + + Assert.Equal("ABCDEF123", tb.Text); + + // Undo will take us back one step + tb.Undo(); + Assert.Equal("ABCDEF", tb.Text); + + // Undo again + tb.Undo(); + Assert.Equal("ABC", tb.Text); + + // We now should not be able to undo again + Assert.False(tb.CanUndo); + } + } + private static TestServices FocusServices => TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(),