浏览代码

Merge pull request #9490 from amwx/TextBoxImprovements

TextBox updates
Max Katz 2 年之前
父节点
当前提交
aa5eaef67d

+ 280 - 26
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<TextBox.UndoRedoState>.IUndoRedoHost
     {
+        /// <summary>
+        /// Gets a platform-specific <see cref="KeyGesture"/> for the Cut action
+        /// </summary>
         public static KeyGesture? CutGesture { get; } = AvaloniaLocator.Current
             .GetService<PlatformHotkeyConfiguration>()?.Cut.FirstOrDefault();
 
+        /// <summary>
+        /// Gets a platform-specific <see cref="KeyGesture"/> for the Copy action
+        /// </summary>
         public static KeyGesture? CopyGesture { get; } = AvaloniaLocator.Current
             .GetService<PlatformHotkeyConfiguration>()?.Copy.FirstOrDefault();
 
+        /// <summary>
+        /// Gets a platform-specific <see cref="KeyGesture"/> for the Paste action
+        /// </summary>
         public static KeyGesture? PasteGesture { get; } = AvaloniaLocator.Current
             .GetService<PlatformHotkeyConfiguration>()?.Paste.FirstOrDefault();
 
+        /// <summary>
+        /// Defines the <see cref="AcceptsReturn"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> AcceptsReturnProperty =
             AvaloniaProperty.Register<TextBox, bool>(nameof(AcceptsReturn));
 
+        /// <summary>
+        /// Defines the <see cref="AcceptsTab"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> AcceptsTabProperty =
             AvaloniaProperty.Register<TextBox, bool>(nameof(AcceptsTab));
 
+        /// <summary>
+        /// Defines the <see cref="CaretIndex"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, int> CaretIndexProperty =
             AvaloniaProperty.RegisterDirect<TextBox, int>(
                 nameof(CaretIndex),
                 o => o.CaretIndex,
                 (o, v) => o.CaretIndex = v);
 
+        /// <summary>
+        /// Defines the <see cref="IsReadOnly"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> IsReadOnlyProperty =
             AvaloniaProperty.Register<TextBox, bool>(nameof(IsReadOnly));
 
+        /// <summary>
+        /// Defines the <see cref="PasswordChar"/> property
+        /// </summary>
         public static readonly StyledProperty<char> PasswordCharProperty =
             AvaloniaProperty.Register<TextBox, char>(nameof(PasswordChar));
 
+        /// <summary>
+        /// Defines the <see cref="SelectionBrush"/> property
+        /// </summary>
         public static readonly StyledProperty<IBrush?> SelectionBrushProperty =
             AvaloniaProperty.Register<TextBox, IBrush?>(nameof(SelectionBrush));
 
+        /// <summary>
+        /// Defines the <see cref="SelectionForegroundBrush"/> property
+        /// </summary>
         public static readonly StyledProperty<IBrush?> SelectionForegroundBrushProperty =
             AvaloniaProperty.Register<TextBox, IBrush?>(nameof(SelectionForegroundBrush));
 
+        /// <summary>
+        /// Defines the <see cref="CaretBrush"/> property
+        /// </summary>
         public static readonly StyledProperty<IBrush?> CaretBrushProperty =
             AvaloniaProperty.Register<TextBox, IBrush?>(nameof(CaretBrush));
 
+        /// <summary>
+        /// Defines the <see cref="SelectionStart"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, int> SelectionStartProperty =
             AvaloniaProperty.RegisterDirect<TextBox, int>(
                 nameof(SelectionStart),
                 o => o.SelectionStart,
                 (o, v) => o.SelectionStart = v);
 
+        /// <summary>
+        /// Defines the <see cref="SelectionEnd"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, int> SelectionEndProperty =
             AvaloniaProperty.RegisterDirect<TextBox, int>(
                 nameof(SelectionEnd),
                 o => o.SelectionEnd,
                 (o, v) => o.SelectionEnd = v);
 
+        /// <summary>
+        /// Defines the <see cref="MaxLength"/> property
+        /// </summary>
         public static readonly StyledProperty<int> MaxLengthProperty =
             AvaloniaProperty.Register<TextBox, int>(nameof(MaxLength), defaultValue: 0);
 
+        /// <summary>
+        /// Defines the <see cref="MaxLines"/> property
+        /// </summary>
         public static readonly StyledProperty<int> MaxLinesProperty =
             AvaloniaProperty.Register<TextBox, int>(nameof(MaxLines), defaultValue: 0);
 
+        /// <summary>
+        /// Defines the <see cref="Text"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, string?> TextProperty =
             TextBlock.TextProperty.AddOwnerWithDataValidation<TextBox>(
                 o => o.Text,
@@ -90,6 +137,9 @@ namespace Avalonia.Controls
                 defaultBindingMode: BindingMode.TwoWay,
                 enableDataValidation: true);
 
+        /// <summary>
+        /// Defines the <see cref="TextAlignment"/> property
+        /// </summary>
         public static readonly StyledProperty<TextAlignment> TextAlignmentProperty =
             TextBlock.TextAlignmentProperty.AddOwner<TextBox>();
 
@@ -120,45 +170,78 @@ namespace Avalonia.Controls
         public static readonly StyledProperty<double> LetterSpacingProperty =
             TextBlock.LetterSpacingProperty.AddOwner<TextBox>();
 
+        /// <summary>
+        /// Defines the <see cref="Watermark"/> property
+        /// </summary>
         public static readonly StyledProperty<string?> WatermarkProperty =
             AvaloniaProperty.Register<TextBox, string?>(nameof(Watermark));
 
+        /// <summary>
+        /// Defines the <see cref="UseFloatingWatermark"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> UseFloatingWatermarkProperty =
             AvaloniaProperty.Register<TextBox, bool>(nameof(UseFloatingWatermark));
 
+        /// <summary>
+        /// Defines the <see cref="NewLine"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, string> NewLineProperty =
             AvaloniaProperty.RegisterDirect<TextBox, string>(nameof(NewLine),
                 textbox => textbox.NewLine, (textbox, newline) => textbox.NewLine = newline);
 
+        /// <summary>
+        /// Defines the <see cref="InnerLeftContent"/> property
+        /// </summary>
         public static readonly StyledProperty<object> InnerLeftContentProperty =
             AvaloniaProperty.Register<TextBox, object>(nameof(InnerLeftContent));
 
+        /// <summary>
+        /// Defines the <see cref="InnerRightContent"/> property
+        /// </summary>
         public static readonly StyledProperty<object> InnerRightContentProperty =
             AvaloniaProperty.Register<TextBox, object>(nameof(InnerRightContent));
 
+        /// <summary>
+        /// Defines the <see cref="RevealPassword"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> RevealPasswordProperty =
             AvaloniaProperty.Register<TextBox, bool>(nameof(RevealPassword));
 
+        /// <summary>
+        /// Defines the <see cref="CanCut"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, bool> CanCutProperty =
             AvaloniaProperty.RegisterDirect<TextBox, bool>(
                 nameof(CanCut),
                 o => o.CanCut);
 
+        /// <summary>
+        /// Defines the <see cref="CanCopy"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, bool> CanCopyProperty =
             AvaloniaProperty.RegisterDirect<TextBox, bool>(
                 nameof(CanCopy),
                 o => o.CanCopy);
 
+        /// <summary>
+        /// Defines the <see cref="CanPaste"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, bool> CanPasteProperty =
             AvaloniaProperty.RegisterDirect<TextBox, bool>(
                 nameof(CanPaste),
                 o => o.CanPaste);
 
+        /// <summary>
+        /// Defines the <see cref="IsUndoEnabled"/> property
+        /// </summary>
         public static readonly StyledProperty<bool> IsUndoEnabledProperty =
             AvaloniaProperty.Register<TextBox, bool>(
                 nameof(IsUndoEnabled),
                 defaultValue: true);
 
+        /// <summary>
+        /// Defines the <see cref="UndoLimit"/> property
+        /// </summary>
         public static readonly DirectProperty<TextBox, int> UndoLimitProperty =
             AvaloniaProperty.RegisterDirect<TextBox, int>(
                 nameof(UndoLimit),
@@ -166,6 +249,18 @@ namespace Avalonia.Controls
                 (o, v) => o.UndoLimit = v,
                 unsetValue: -1);
 
+        /// <summary>
+        /// Defines the <see cref="CanUndo"/> property
+        /// </summary>
+        public static readonly DirectProperty<TextBox, bool> CanUndoProperty =
+            AvaloniaProperty.RegisterDirect<TextBox, bool>(nameof(CanUndo), x => x.CanUndo);
+
+        /// <summary>
+        /// Defines the <see cref="CanRedo"/> property
+        /// </summary>
+        public static readonly DirectProperty<TextBox, bool> CanRedoProperty =
+            AvaloniaProperty.RegisterDirect<TextBox, bool>(nameof(CanRedo), x => x.CanRedo);
+
         /// <summary>
         /// Defines the <see cref="CopyingToClipboard"/> event.
         /// </summary>
@@ -201,9 +296,13 @@ namespace Avalonia.Controls
             RoutedEvent.Register<TextBox, TextChangingEventArgs>(
                 nameof(TextChanging), RoutingStrategies.Bubble);
 
+        /// <summary>
+        /// Stores the state information for available actions in the UndoRedoHelper
+        /// </summary>
         readonly struct UndoRedoState : IEquatable<UndoRedoState>
         {
             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<UndoRedoState>(this);
             _selectedTextChangesMadeSinceLastUndoSnapshot = 0;
             _hasDoneSnapshotOnce = false;
             UpdatePseudoclasses();
         }
 
+        /// <summary>
+        /// Gets or sets a value that determines whether the TextBox allows and displays newline or return characters
+        /// </summary>
         public bool AcceptsReturn
         {
             get => GetValue(AcceptsReturnProperty);
             set => SetValue(AcceptsReturnProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a value that determins whether the TextBox allows and displays tabs
+        /// </summary>
         public bool AcceptsTab
         {
             get => GetValue(AcceptsTabProperty);
             set => SetValue(AcceptsTabProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the index of the text caret
+        /// </summary>
         public int CaretIndex
         {
             get => _caretIndex;
@@ -302,36 +413,54 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Gets or sets a value whether this TextBox is read-only
+        /// </summary>
         public bool IsReadOnly
         {
             get => GetValue(IsReadOnlyProperty);
             set => SetValue(IsReadOnlyProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the <see cref="char"/> that should be used for password masking
+        /// </summary>
         public char PasswordChar
         {
             get => GetValue(PasswordCharProperty);
             set => SetValue(PasswordCharProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a brush that is used to highlight selected text
+        /// </summary>
         public IBrush? SelectionBrush
         {
             get => GetValue(SelectionBrushProperty);
             set => SetValue(SelectionBrushProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a brush that is used for the foreground of selected text
+        /// </summary>
         public IBrush? SelectionForegroundBrush
         {
             get => GetValue(SelectionForegroundBrushProperty);
             set => SetValue(SelectionForegroundBrushProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets a brush that is used for the text caret
+        /// </summary>
         public IBrush? CaretBrush
         {
             get => GetValue(CaretBrushProperty);
             set => SetValue(CaretBrushProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the starting position of the text selected in the TextBox
+        /// </summary>
         public int SelectionStart
         {
             get => _selectionStart;
@@ -352,6 +481,13 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Gets or sets the end position of the text selected in the TextBox
+        /// </summary>
+        /// <remarks>
+        /// When the SelectionEnd is equal to <see cref="SelectionStart"/>, there is no 
+        /// selected text and it marks the caret position
+        /// </remarks>
         public int SelectionEnd
         {
             get => _selectionEnd;
@@ -371,19 +507,28 @@ namespace Avalonia.Controls
                 }
             }
         }
-
+        
+        /// <summary>
+        /// Gets or sets the maximum character length of the TextBox
+        /// </summary>
         public int MaxLength
         {
             get => GetValue(MaxLengthProperty);
             set => SetValue(MaxLengthProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the maximum number of lines the TextBox can contain
+        /// </summary>
         public int MaxLines
         {
             get => GetValue(MaxLinesProperty);
             set => SetValue(MaxLinesProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the spacing between characters
+        /// </summary>
         public double LetterSpacing
         {
             get => GetValue(LetterSpacingProperty);
@@ -399,6 +544,9 @@ namespace Avalonia.Controls
             set => SetValue(LineHeightProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the Text content of the TextBox
+        /// </summary>
         [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
             }
         }
 
+        /// <summary>
+        /// Gets or sets the text selected in the TextBox
+        /// </summary>
         public string SelectedText
         {
             get => GetSelection();
@@ -464,6 +621,9 @@ namespace Avalonia.Controls
             set => SetValue(VerticalContentAlignmentProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the <see cref="Media.TextAlignment"/> of the TextBox
+        /// </summary>
         public TextAlignment TextAlignment
         {
             get => GetValue(TextAlignmentProperty);
@@ -490,24 +650,36 @@ namespace Avalonia.Controls
             set => SetValue(UseFloatingWatermarkProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets custom content that is positioned on the left side of the text layout box
+        /// </summary>
         public object InnerLeftContent
         {
             get => GetValue(InnerLeftContentProperty);
             set => SetValue(InnerLeftContentProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets custom content that is positioned on the right side of the text layout box
+        /// </summary>
         public object InnerRightContent
         {
             get => GetValue(InnerRightContentProperty);
             set => SetValue(InnerRightContentProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets whether text masked by <see cref="PasswordChar"/> should be revealed
+        /// </summary>
         public bool RevealPassword
         {
             get => GetValue(RevealPasswordProperty);
             set => SetValue(RevealPasswordProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the <see cref="Media.TextWrapping"/> of the TextBox
+        /// </summary>
         public TextWrapping TextWrapping
         {
             get => GetValue(TextWrappingProperty);
@@ -567,6 +739,9 @@ namespace Avalonia.Controls
             set => SetValue(IsUndoEnabledProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the maximum number of items that can reside in the Undo stack
+        /// </summary>
         public int UndoLimit
         {
             get => _undoRedoHelper.Limit;
@@ -590,18 +765,45 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Gets a value that indicates whether the undo stack has an action that can be undone
+        /// </summary>
+        public bool CanUndo
+        {
+            get => _canUndo;
+            private set => SetAndRaise(CanUndoProperty, ref _canUndo, value);
+        }
+
+        /// <summary>
+        /// Gets a value that indicates whether the redo stack has an action that can be redone
+        /// </summary>
+        public bool CanRedo
+        {
+            get => _canRedo;
+            private set => SetAndRaise(CanRedoProperty, ref _canRedo, value);
+        }
+
+        /// <summary>
+        /// Raised when content is being copied to the clipboard
+        /// </summary>
         public event EventHandler<RoutedEventArgs>? CopyingToClipboard
         {
             add => AddHandler(CopyingToClipboardEvent, value);
             remove => RemoveHandler(CopyingToClipboardEvent, value);
         }
 
+        /// <summary>
+        /// Raised when content is being cut to the clipboard
+        /// </summary>
         public event EventHandler<RoutedEventArgs>? CuttingToClipboard
         {
             add => AddHandler(CuttingToClipboardEvent, value);
             remove => RemoveHandler(CuttingToClipboardEvent, value);
         }
 
+        /// <summary>
+        /// Raised when content is being pasted from the clipboard
+        /// </summary>
         public event EventHandler<RoutedEventArgs>? PastingFromClipboard
         {
             add => AddHandler(PastingFromClipboardEvent, value);
@@ -831,6 +1033,9 @@ namespace Avalonia.Controls
             return text;
         }
 
+        /// <summary>
+        /// Cuts the current text onto the clipboard
+        /// </summary>
         public async void Cut()
         {
             var text = GetSelection();
@@ -851,6 +1056,9 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Copies the current text onto the clipboard
+        /// </summary>
         public async void Copy()
         {
             var text = GetSelection();
@@ -869,6 +1077,9 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Pastes the current clipboard text content into the TextBox
+        /// </summary>
         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
             }
         }
 
+        /// <summary>
+        /// Clears the text in the TextBox
+        /// </summary>
         public void Clear()
         {
             Text = string.Empty;
@@ -1703,5 +1900,62 @@ namespace Avalonia.Controls
                 }
             }
         }
+
+        /// <summary>
+        /// Undoes the first action in the undo stack
+        /// </summary>
+        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;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Reapplies the first item on the redo stack
+        /// </summary>
+        public void Redo()
+        {
+            if (IsUndoEnabled && CanRedo)
+            {
+                try
+                {
+                    _isUndoingRedoing = true;
+                    _undoRedoHelper.Redo();
+                }
+                finally
+                {
+                    _isUndoingRedoing = false;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Called from the UndoRedoHelper when the undo stack is modified
+        /// </summary>
+        void UndoRedoHelper<UndoRedoState>.IUndoRedoHost.OnUndoStackChanged()
+        {
+            CanUndo = _undoRedoHelper.CanUndo;
+        }
+
+        /// <summary>
+        /// Called from the UndoRedoHelper when the redo stack is modified
+        /// </summary>
+        void UndoRedoHelper<UndoRedoState>.IUndoRedoHost.OnRedoStackChanged()
+        {
+            CanRedo = _undoRedoHelper.CanRedo;
+        }
     }
 }

+ 20 - 6
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<TState> _states = new LinkedList<TState>();
 
@@ -28,6 +25,10 @@ namespace Avalonia.Controls.Utils
         /// </summary>
         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();
         }
     }
 }

+ 170 - 0
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(),