diff --git a/src/MahApps.Metro/Controls/NumericUpDown.cs b/src/MahApps.Metro/Controls/NumericUpDown.cs
index f7aa34b7e..1348c9522 100644
--- a/src/MahApps.Metro/Controls/NumericUpDown.cs
+++ b/src/MahApps.Metro/Controls/NumericUpDown.cs
@@ -365,6 +365,28 @@ public bool InterceptManualEnter
set => this.SetValue(InterceptManualEnterProperty, BooleanBoxes.Box(value));
}
+ /// Identifies the dependency property.
+ public static readonly DependencyProperty SyncTextWithValueWhileEditingProperty
+ = DependencyProperty.Register(nameof(SyncTextWithValueWhileEditing),
+ typeof(bool),
+ typeof(NumericUpDown),
+ new PropertyMetadata(BooleanBoxes.FalseBox));
+
+ ///
+ /// Gets or sets a value indicating whether the displayed text is kept in sync with the
+ /// while the control is being edited (has keyboard focus), when the value
+ /// is changed from an external source such as a binding. When set to
+ /// (the default), the current behavior is kept: the text is only refreshed from the value once
+ /// the control loses focus.
+ ///
+ [Category("Behavior")]
+ [DefaultValue(false)]
+ public bool SyncTextWithValueWhileEditing
+ {
+ get => (bool)this.GetValue(SyncTextWithValueWhileEditingProperty);
+ set => this.SetValue(SyncTextWithValueWhileEditingProperty, BooleanBoxes.Box(value));
+ }
+
/// Identifies the dependency property.
public static readonly DependencyProperty ValueProperty
= DependencyProperty.Register(nameof(Value),
@@ -1238,6 +1260,22 @@ protected virtual void OnValueChanged(double? oldValue, double? newValue)
this.InternalSetText(newValue);
}
}
+ else if (this.SyncTextWithValueWhileEditing && this.valueTextBox != null)
+ {
+ var textRepresentsValue = newValue.HasValue
+ && this.ValidateText(this.valueTextBox.Text, out var textValue)
+ && FormattedValue(textValue, this.StringFormat, this.SpecificCultureInfo).IsCloseTo(newValue.Value);
+
+ if (!textRepresentsValue)
+ {
+ this.InternalSetText(newValue);
+
+ if (this.valueTextBox.IsKeyboardFocused)
+ {
+ this.valueTextBox.SelectAll();
+ }
+ }
+ }
this.EnableDisableUpDown();
diff --git a/src/Mahapps.Metro.Tests/Tests/NumericUpDownTests.cs b/src/Mahapps.Metro.Tests/Tests/NumericUpDownTests.cs
index b1a6fdce3..c371790a0 100644
--- a/src/Mahapps.Metro.Tests/Tests/NumericUpDownTests.cs
+++ b/src/Mahapps.Metro.Tests/Tests/NumericUpDownTests.cs
@@ -4,11 +4,13 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel;
using System.Globalization;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Data;
using System.Windows.Input;
using MahApps.Metro.Controls;
using MahApps.Metro.Tests.TestHelpers;
@@ -403,6 +405,16 @@ public void ShouldConvertHexadecimalTextInputWithStringFormat(string text, strin
}
private static void SetText(TextBox theTextBox, string theText)
+ {
+ TypeText(theTextBox, theText);
+
+ theTextBox.RaiseEvent(new RoutedEventArgs(UIElement.LostFocusEvent));
+ }
+
+ ///
+ /// Simulates the manual text input, but keeps the control in editing state, so no LostFocus event is raised here.
+ ///
+ private static void TypeText(TextBox theTextBox, string theText)
{
theTextBox.Clear();
foreach (var c in theText)
@@ -413,8 +425,6 @@ private static void SetText(TextBox theTextBox, string theText)
textCompositionEventArgs.RoutedEvent = UIElement.TextInputEvent;
theTextBox.RaiseEvent(textCompositionEventArgs);
}
-
- theTextBox.RaiseEvent(new RoutedEventArgs(UIElement.LostFocusEvent));
}
[Test]
@@ -462,5 +472,157 @@ public void ShouldSetDefaultValue()
Assert.That(nud.DefaultValue, Is.EqualTo(nud.Minimum));
Assert.That(nud.Value, Is.EqualTo(nud.Minimum));
}
+
+ [Test]
+ public void ShouldNotSyncTextWithValueWhileEditingByDefault()
+ {
+ Assert.That(this.window, Is.Not.Null);
+
+ Assert.That(this.window.TheNUD.SyncTextWithValueWhileEditing, Is.False);
+ }
+
+ [Theory]
+ // The value coerced by the view model is shown while the user is still editing
+ [TestCase(true, "", "10", "10")]
+ [TestCase(true, "{}{0:N2} cm", "10.00 cm", "10.00 cm")]
+ // The old behavior: the typed text stays until the control loses the focus
+ [TestCase(false, "", "50", "10")]
+ [TestCase(false, "{}{0:N2} cm", "50", "10.00 cm")]
+ public void ShouldSyncTextWithCoercedValueFromBindingWhileEditing(bool syncTextWithValueWhileEditing, string format, string expectedText, string expectedTextAfterLostFocus)
+ {
+ Assert.That(this.window, Is.Not.Null);
+
+ var textBox = this.window.TheNUD.FindChild();
+
+ Assert.That(textBox, Is.Not.Null);
+
+ var nud = this.window.TheNUD;
+ nud.Culture = CultureInfo.InvariantCulture;
+ nud.NumericInputMode = NumericInput.All;
+ nud.StringFormat = format;
+ nud.SyncTextWithValueWhileEditing = syncTextWithValueWhileEditing;
+
+ var viewModel = new ClampingTestViewModel { MaxAllowedValue = 10d };
+
+ BindingOperations.SetBinding(nud,
+ NumericUpDown.ValueProperty,
+ new Binding(nameof(ClampingTestViewModel.Value))
+ {
+ Source = viewModel,
+ Mode = BindingMode.TwoWay,
+ UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
+ });
+
+ // The user types a value which is too large for the view model, so it gets clamped there
+ TypeText(textBox, "50");
+
+ Assert.That(viewModel.Value, Is.EqualTo(10d));
+ Assert.That(nud.Value, Is.EqualTo(10d));
+ Assert.That(textBox.Text, Is.EqualTo(expectedText));
+
+ // On lost focus the text is refreshed from the value in both cases
+ textBox.RaiseEvent(new RoutedEventArgs(UIElement.LostFocusEvent));
+
+ Assert.That(textBox.Text, Is.EqualTo(expectedTextAfterLostFocus));
+
+ BindingOperations.ClearBinding(nud, NumericUpDown.ValueProperty);
+ }
+
+ [Test]
+ public void ShouldSelectTextAfterExternalValueSyncWhileEditing()
+ {
+ Assert.That(this.window, Is.Not.Null);
+
+ var textBox = this.window.TheNUD.FindChild();
+
+ Assert.That(textBox, Is.Not.Null);
+
+ var nud = this.window.TheNUD;
+ nud.Culture = CultureInfo.InvariantCulture;
+ nud.NumericInputMode = NumericInput.All;
+ nud.SyncTextWithValueWhileEditing = true;
+
+ var viewModel = new ClampingTestViewModel { MaxAllowedValue = 10d };
+
+ BindingOperations.SetBinding(nud,
+ NumericUpDown.ValueProperty,
+ new Binding(nameof(ClampingTestViewModel.Value))
+ {
+ Source = viewModel,
+ Mode = BindingMode.TwoWay,
+ UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
+ });
+
+ textBox.Focus();
+
+ // The text is only selected if the TextBox really has the keyboard focus,
+ // which is not always possible on a build agent.
+ Assume.That(textBox.IsKeyboardFocused, Is.True);
+
+ try
+ {
+ TypeText(textBox, "50");
+
+ Assert.That(textBox.Text, Is.EqualTo("10"));
+ Assert.That(textBox.SelectedText, Is.EqualTo("10"));
+ }
+ finally
+ {
+ Keyboard.ClearFocus();
+
+ BindingOperations.ClearBinding(nud, NumericUpDown.ValueProperty);
+ }
+ }
+
+ [Theory]
+ [TestCase("42.", "")]
+ [TestCase("42.", "{}{0:N2} cm")]
+ [TestCase("-.5", "")]
+ [TestCase("-0.39678", "{}{0:P1}")] // The rounded value must not overwrite the typed text
+ public void ShouldKeepInProgressTextWhileTypingWithSyncTextWithValueWhileEditing(string text, string format)
+ {
+ Assert.That(this.window, Is.Not.Null);
+
+ var textBox = this.window.TheNUD.FindChild();
+
+ Assert.That(textBox, Is.Not.Null);
+
+ var nud = this.window.TheNUD;
+ nud.Culture = CultureInfo.InvariantCulture;
+ nud.NumericInputMode = NumericInput.All;
+ nud.StringFormat = format;
+ nud.SyncTextWithValueWhileEditing = true;
+
+ // The typed text must not be reformatted while the user is still typing
+ TypeText(textBox, text);
+
+ Assert.That(textBox.Text, Is.EqualTo(text));
+ }
+
+ private sealed class ClampingTestViewModel : INotifyPropertyChanged
+ {
+ private double? value;
+
+ public double? MaxAllowedValue { get; set; }
+
+ public double? Value
+ {
+ get => this.value;
+ set
+ {
+ var coercedValue = value > this.MaxAllowedValue ? this.MaxAllowedValue : value;
+
+ if (Nullable.Equals(this.value, coercedValue))
+ {
+ return;
+ }
+
+ this.value = coercedValue;
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Value)));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
}
}
\ No newline at end of file