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/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(),