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