-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Description
Description
Undo / Redo functionality of System.Windows.Controls.TextBox stops working correctly when the Text property is data-bound using either StringFormat or a Converter.
This issue occurs even when using UpdateSourceTrigger=LostFocus, not only PropertyChanged.
Reproduction Steps
Just create a Window like that and run tests
<Window
x:Class="UIX.Tests.ControlTests.DecimalTextBoxTests.DecimalWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
xmlns:local="clr-namespace:UIX.Tests.ControlTests.DecimalTextBoxTests"
xmlns:control="clr-namespace:AppControl;assembly=AppControl"
d:DataContext="{d:DesignInstance Type=local:TestViewModel}">
<Window.Resources>
<local:StringFormatConverter x:Key="DecimalConverter" />
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition
Height="auto" />
<RowDefinition />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Row="1"
Grid.Column="1">
<Button
Grid.Row="2"
Margin="0 10"
Grid.Column="1"
Content="save" />
<TextBox
x:Name="DefaultTextBox"
Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
<TextBox x:Name="StringFormatTextBox"
Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True,StringFormat=N2}" />
<TextBox x:Name="ConverterTextBox"
Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True,Converter={StaticResource DecimalConverter}}" />
</StackPanel>
</Grid>
</Window>and that's the converter
internal class StringFormatConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (value is null) return string.Empty;
var amount = value.ToString();
if (decimal.TryParse(amount, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result))
{
return result.ToString("N2");
}
return value!;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return value ?? Binding.DoNothing;
}
} and that the tests
public class DefaultTextBoxTests
{
[WpfFact]
public async Task DefaultTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.DefaultTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7000", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5000", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7000", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5000", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
[WpfFact]
public async Task StringFormatTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.StringFormatTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
[WpfFact]
public async Task ConverterTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.ConverterTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
}Expected behavior
User typing should be recorded in the undo stack
Ctrl + Z / Ctrl + Y should undo/redo user input
Formatting should not invalidate Undo/Redo history
Actual behavior
Undo stack is cleared after binding updates
Ctrl + Z either does nothing
Happens consistently when:
StringFormat is used
OR a Converter is used
Even with UpdateSourceTrigger=LostFocus
Regression?
No response
Known Workarounds
No response
Impact
DefaultTextBox_Should_UndoAndRedoFullValueChanges is pass while the others not
Configuration
dotnet 10
Other information
No response