From 16f3114e61b4fdf535613aae47972d028f7f6766 Mon Sep 17 00:00:00 2001
From: amwx <40413319+amwx@users.noreply.github.com>
Date: Sun, 20 Nov 2022 23:53:34 -0500
Subject: [PATCH 1/5] TextBox programmatic Undo/Redo & CanUndo/CanRedo
---
src/Avalonia.Controls/TextBox.cs | 109 ++++++++++++++----
src/Avalonia.Controls/Utils/UndoRedoHelper.cs | 20 ++++
2 files changed, 109 insertions(+), 20 deletions(-)
diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs
index d5b45398e7..b06ec3492c 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
@@ -166,6 +165,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.
///
@@ -232,6 +243,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;
@@ -590,6 +603,24 @@ 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);
+ }
+
public event EventHandler? CopyingToClipboard
{
add => AddHandler(CopyingToClipboardEvent, value);
@@ -943,30 +974,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;
}
@@ -1703,5 +1717,60 @@ namespace Avalonia.Controls
}
}
}
+
+ ///
+ /// Undoes the first action in the undo stack
+ ///
+ public void Undo()
+ {
+ if (IsUndoEnabled && CanUndo)
+ {
+ try
+ {
+ 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..976dbb5d5f 100644
--- a/src/Avalonia.Controls/Utils/UndoRedoHelper.cs
+++ b/src/Avalonia.Controls/Utils/UndoRedoHelper.cs
@@ -14,6 +14,10 @@ namespace Avalonia.Controls.Utils
public interface IUndoRedoHost
{
TState UndoRedoState { get; set; }
+
+ void OnUndoStackChanged();
+
+ void OnRedoStackChanged();
}
@@ -28,6 +32,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 +47,8 @@ namespace Avalonia.Controls.Utils
{
_currentNode = _currentNode.Previous;
_host.UndoRedoState = _currentNode.Value;
+ _host.OnUndoStackChanged();
+ _host.OnRedoStackChanged();
}
}
@@ -72,6 +82,8 @@ namespace Avalonia.Controls.Utils
{
while (_currentNode?.Next != null)
_states.Remove(_currentNode.Next);
+
+ _host.OnRedoStackChanged();
}
public void Redo()
@@ -80,6 +92,8 @@ namespace Avalonia.Controls.Utils
{
_currentNode = _currentNode.Next;
_host.UndoRedoState = _currentNode.Value;
+ _host.OnRedoStackChanged();
+ _host.OnUndoStackChanged();
}
}
@@ -94,6 +108,9 @@ namespace Avalonia.Controls.Utils
_currentNode = _states.Last;
if (Limit != -1 && _states.Count > Limit)
_states.RemoveFirst();
+
+ _host.OnUndoStackChanged();
+ _host.OnRedoStackChanged();
}
}
@@ -101,6 +118,9 @@ namespace Avalonia.Controls.Utils
{
_states.Clear();
_currentNode = null;
+
+ _host.OnUndoStackChanged();
+ _host.OnRedoStackChanged();
}
}
}
From 3f1a342e6f3052f0f319924209680dfbe56ae708 Mon Sep 17 00:00:00 2001
From: amwx <40413319+amwx@users.noreply.github.com>
Date: Sun, 20 Nov 2022 23:53:41 -0500
Subject: [PATCH 2/5] Add some tests
---
.../TextBoxTests.cs | 170 ++++++++++++++++++
1 file changed, 170 insertions(+)
diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs
index 23a330c96f..52a89cd13d 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(),
From 4b089c0e823dfccfab096fe4057a55e5fe9a2b8f Mon Sep 17 00:00:00 2001
From: amwx <40413319+amwx@users.noreply.github.com>
Date: Mon, 21 Nov 2022 00:16:44 -0500
Subject: [PATCH 3/5] Add some missing xml docs & a little cleanup
---
src/Avalonia.Controls/TextBox.cs | 178 +++++++++++++++++-
src/Avalonia.Controls/Utils/UndoRedoHelper.cs | 8 +-
2 files changed, 178 insertions(+), 8 deletions(-)
diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs
index b06ec3492c..dbab912716 100644
--- a/src/Avalonia.Controls/TextBox.cs
+++ b/src/Avalonia.Controls/TextBox.cs
@@ -28,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,
@@ -89,6 +137,9 @@ namespace Avalonia.Controls
defaultBindingMode: BindingMode.TwoWay,
enableDataValidation: true);
+ ///
+ /// Defines the property
+ ///
public static readonly StyledProperty TextAlignmentProperty =
TextBlock.TextAlignmentProperty.AddOwner();
@@ -119,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