using System; using System.Globalization; using System.IO; using System.Linq; using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.Utilities; namespace Avalonia.Controls { /// /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. /// public class NumericUpDown : TemplatedControl { /// /// Defines the property. /// public static readonly StyledProperty AllowSpinProperty = ButtonSpinner.AllowSpinProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty ButtonSpinnerLocationProperty = ButtonSpinner.ButtonSpinnerLocationProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty ShowButtonSpinnerProperty = ButtonSpinner.ShowButtonSpinnerProperty.AddOwner(); /// /// Defines the property. /// public static readonly DirectProperty ClipValueToMinMaxProperty = AvaloniaProperty.RegisterDirect(nameof(ClipValueToMinMax), updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b); /// /// Defines the property. /// public static readonly DirectProperty CultureInfoProperty = AvaloniaProperty.RegisterDirect(nameof(CultureInfo), o => o.CultureInfo, (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture); /// /// Defines the property. /// public static readonly StyledProperty FormatStringProperty = AvaloniaProperty.Register(nameof(FormatString), string.Empty); /// /// Defines the property. /// public static readonly StyledProperty IncrementProperty = AvaloniaProperty.Register(nameof(Increment), 1.0d, validate: OnCoerceIncrement); /// /// Defines the property. /// public static readonly StyledProperty IsReadOnlyProperty = AvaloniaProperty.Register(nameof(IsReadOnly)); /// /// Defines the property. /// public static readonly StyledProperty MaximumProperty = AvaloniaProperty.Register(nameof(Maximum), double.MaxValue, validate: OnCoerceMaximum); /// /// Defines the property. /// public static readonly StyledProperty MinimumProperty = AvaloniaProperty.Register(nameof(Minimum), double.MinValue, validate: OnCoerceMinimum); /// /// Defines the property. /// public static readonly DirectProperty ParsingNumberStyleProperty = AvaloniaProperty.RegisterDirect(nameof(ParsingNumberStyle), updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style); /// /// Defines the property. /// public static readonly DirectProperty TextProperty = AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v, defaultBindingMode: BindingMode.TwoWay); /// /// Defines the property. /// public static readonly DirectProperty ValueProperty = AvaloniaProperty.RegisterDirect(nameof(Value), updown => updown.Value, (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay); /// /// Defines the property. /// public static readonly StyledProperty WatermarkProperty = AvaloniaProperty.Register(nameof(Watermark)); private IDisposable _textBoxTextChangedSubscription; private double _value; private string _text; private bool _internalValueSet; private bool _clipValueToMinMax; private bool _isSyncingTextAndValueProperties; private bool _isTextChangedFromUI; private CultureInfo _cultureInfo; private NumberStyles _parsingNumberStyle = NumberStyles.Any; /// /// Gets the Spinner template part. /// private Spinner Spinner { get; set; } /// /// Gets the TextBox template part. /// private TextBox TextBox { get; set; } /// /// Gets or sets the ability to perform increment/decrement operations via the keyboard, button spinners, or mouse wheel. /// public bool AllowSpin { get { return GetValue(AllowSpinProperty); } set { SetValue(AllowSpinProperty, value); } } /// /// Gets or sets current location of the . /// public Location ButtonSpinnerLocation { get { return GetValue(ButtonSpinnerLocationProperty); } set { SetValue(ButtonSpinnerLocationProperty, value); } } /// /// Gets or sets a value indicating whether the spin buttons should be shown. /// public bool ShowButtonSpinner { get { return GetValue(ShowButtonSpinnerProperty); } set { SetValue(ShowButtonSpinnerProperty, value); } } /// /// Gets or sets if the value should be clipped when minimum/maximum is reached. /// public bool ClipValueToMinMax { get { return _clipValueToMinMax; } set { SetAndRaise(ClipValueToMinMaxProperty, ref _clipValueToMinMax, value); } } /// /// Gets or sets the current CultureInfo. /// public CultureInfo CultureInfo { get { return _cultureInfo; } set { SetAndRaise(CultureInfoProperty, ref _cultureInfo, value); } } /// /// Gets or sets the display format of the . /// public string FormatString { get { return GetValue(FormatStringProperty); } set { SetValue(FormatStringProperty, value); } } /// /// Gets or sets the amount in which to increment the . /// public double Increment { get { return GetValue(IncrementProperty); } set { SetValue(IncrementProperty, value); } } /// /// Gets or sets if the control is read only. /// public bool IsReadOnly { get { return GetValue(IsReadOnlyProperty); } set { SetValue(IsReadOnlyProperty, value); } } /// /// Gets or sets the maximum allowed value. /// public double Maximum { get { return GetValue(MaximumProperty); } set { SetValue(MaximumProperty, value); } } /// /// Gets or sets the minimum allowed value. /// public double Minimum { get { return GetValue(MinimumProperty); } set { SetValue(MinimumProperty, value); } } /// /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. /// public NumberStyles ParsingNumberStyle { get { return _parsingNumberStyle; } set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); } } /// /// Gets or sets the formatted string representation of the value. /// public string Text { get { return _text; } set { SetAndRaise(TextProperty, ref _text, value); } } /// /// Gets or sets the value. /// public double Value { get { return _value; } set { value = OnCoerceValue(value); SetAndRaise(ValueProperty, ref _value, value); } } /// /// Gets or sets the object to use as a watermark if the is null. /// public string Watermark { get { return GetValue(WatermarkProperty); } set { SetValue(WatermarkProperty, value); } } /// /// Initializes new instance of class. /// public NumericUpDown() { Initialized += (sender, e) => { if (!_internalValueSet && IsInitialized) { SyncTextAndValueProperties(false, null, true); } SetValidSpinDirection(); }; } /// /// Initializes static members of the class. /// static NumericUpDown() { CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged); FormatStringProperty.Changed.Subscribe(FormatStringChanged); IncrementProperty.Changed.Subscribe(IncrementChanged); IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged); MaximumProperty.Changed.Subscribe(OnMaximumChanged); MinimumProperty.Changed.Subscribe(OnMinimumChanged); TextProperty.Changed.Subscribe(OnTextChanged); ValueProperty.Changed.Subscribe(OnValueChanged); } /// protected override void OnTemplateApplied(TemplateAppliedEventArgs e) { if (TextBox != null) { TextBox.PointerPressed -= TextBoxOnPointerPressed; _textBoxTextChangedSubscription?.Dispose(); } TextBox = e.NameScope.Find("PART_TextBox"); if (TextBox != null) { TextBox.Text = Text; TextBox.PointerPressed += TextBoxOnPointerPressed; _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged()); } if (Spinner != null) { Spinner.Spin -= OnSpinnerSpin; } Spinner = e.NameScope.Find("PART_Spinner"); if (Spinner != null) { Spinner.Spin += OnSpinnerSpin; } SetValidSpinDirection(); } /// protected override void OnKeyDown(KeyEventArgs e) { switch (e.Key) { case Key.Enter: var commitSuccess = CommitInput(); e.Handled = !commitSuccess; break; } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnCultureInfoChanged(CultureInfo oldValue, CultureInfo newValue) { if (IsInitialized) { SyncTextAndValueProperties(false, null); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnFormatStringChanged(string oldValue, string newValue) { if (IsInitialized) { SyncTextAndValueProperties(false, null); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnIncrementChanged(double oldValue, double newValue) { if (IsInitialized) { SetValidSpinDirection(); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnIsReadOnlyChanged(bool oldValue, bool newValue) { SetValidSpinDirection(); } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnMaximumChanged(double oldValue, double newValue) { if (IsInitialized) { SetValidSpinDirection(); } if (ClipValueToMinMax) { Value = MathUtilities.Clamp(Value, Minimum, Maximum); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnMinimumChanged(double oldValue, double newValue) { if (IsInitialized) { SetValidSpinDirection(); } if (ClipValueToMinMax) { Value = MathUtilities.Clamp(Value, Minimum, Maximum); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnTextChanged(string oldValue, string newValue) { if (IsInitialized) { SyncTextAndValueProperties(true, Text); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnValueChanged(double oldValue, double newValue) { if (!_internalValueSet && IsInitialized) { SyncTextAndValueProperties(false, null, true); } SetValidSpinDirection(); RaiseValueChangedEvent(oldValue, newValue); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual double OnCoerceIncrement(double baseValue) { return baseValue; } /// /// Called when the property has to be coerced. /// /// The value. protected virtual double OnCoerceMaximum(double baseValue) { return Math.Max(baseValue, Minimum); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual double OnCoerceMinimum(double baseValue) { return Math.Min(baseValue, Maximum); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual double OnCoerceValue(double baseValue) { return baseValue; } /// /// Raises the OnSpin event when spinning is initiated by the end-user. /// /// The event args. protected virtual void OnSpin(SpinEventArgs e) { if (e == null) { throw new ArgumentNullException(nameof(e)); } var handler = Spinned; handler?.Invoke(this, e); if (e.Direction == SpinDirection.Increase) { DoIncrement(); } else { DoDecrement(); } } /// /// Raises the event. /// /// The old value. /// The new value. protected virtual void RaiseValueChangedEvent(double oldValue, double newValue) { var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue); RaiseEvent(e); } /// /// Converts the formatted text to a value. /// private double ConvertTextToValue(string text) { double result = 0; if (string.IsNullOrEmpty(text)) { return result; } // Since the conversion from Value to text using a FormatString may not be parsable, // we verify that the already existing text is not the exact same value. var currentValueText = ConvertValueToText(); if (Equals(currentValueText, text)) { return Value; } result = ConvertTextToValueCore(currentValueText, text); if (ClipValueToMinMax) { return MathUtilities.Clamp(result, Minimum, Maximum); } ValidateMinMax(result); return result; } /// /// Converts the value to formatted text. /// /// private string ConvertValueToText() { //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. if (FormatString.Contains("{0")) { return string.Format(CultureInfo, FormatString, Value); } return Value.ToString(FormatString, CultureInfo); } /// /// Called by OnSpin when the spin direction is SpinDirection.Increase. /// private void OnIncrement() { var result = Value + Increment; Value = MathUtilities.Clamp(result, Minimum, Maximum); } /// /// Called by OnSpin when the spin direction is SpinDirection.Decrease. /// private void OnDecrement() { var result = Value - Increment; Value = MathUtilities.Clamp(result, Minimum, Maximum); } /// /// Sets the valid spin directions. /// private void SetValidSpinDirection() { var validDirections = ValidSpinDirections.None; // Zero increment always prevents spin. if (Increment != 0 && !IsReadOnly) { if (Value < Maximum) { validDirections = validDirections | ValidSpinDirections.Increase; } if (Value > Minimum) { validDirections = validDirections | ValidSpinDirections.Decrease; } } if (Spinner != null) { Spinner.ValidSpinDirection = validDirections; } } /// /// Called when the property value changed. /// /// The event args. private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (CultureInfo)e.OldValue; var newValue = (CultureInfo)e.NewValue; upDown.OnCultureInfoChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void IncrementChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (double)e.OldValue; var newValue = (double)e.NewValue; upDown.OnIncrementChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void FormatStringChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (string)e.OldValue; var newValue = (string)e.NewValue; upDown.OnFormatStringChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (bool)e.OldValue; var newValue = (bool)e.NewValue; upDown.OnIsReadOnlyChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void OnMaximumChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (double)e.OldValue; var newValue = (double)e.NewValue; upDown.OnMaximumChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void OnMinimumChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (double)e.OldValue; var newValue = (double)e.NewValue; upDown.OnMinimumChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void OnTextChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (string)e.OldValue; var newValue = (string)e.NewValue; upDown.OnTextChanged(oldValue, newValue); } } /// /// Called when the property value changed. /// /// The event args. private static void OnValueChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (double)e.OldValue; var newValue = (double)e.NewValue; upDown.OnValueChanged(oldValue, newValue); } } private void SetValueInternal(double value) { _internalValueSet = true; try { Value = value; } finally { _internalValueSet = false; } } private static double OnCoerceMaximum(NumericUpDown upDown, double value) { return upDown.OnCoerceMaximum(value); } private static double OnCoerceMinimum(NumericUpDown upDown, double value) { return upDown.OnCoerceMinimum(value); } private static double OnCoerceIncrement(NumericUpDown upDown, double value) { return upDown.OnCoerceIncrement(value); } private void TextBoxOnTextChanged() { try { _isTextChangedFromUI = true; if (TextBox != null) { Text = TextBox.Text; } } finally { _isTextChangedFromUI = false; } } private void OnSpinnerSpin(object sender, SpinEventArgs e) { if (AllowSpin && !IsReadOnly) { var spin = !e.UsingMouseWheel; spin |= ((TextBox != null) && TextBox.IsFocused); if (spin) { e.Handled = true; OnSpin(e); } } } private void DoDecrement() { if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease) { OnDecrement(); } } private void DoIncrement() { if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Increase) == ValidSpinDirections.Increase) { OnIncrement(); } } public event EventHandler Spinned; private void TextBoxOnPointerPressed(object sender, PointerPressedEventArgs e) { if (e.Device.Captured != Spinner) { Dispatcher.UIThread.InvokeAsync(() => { e.Device.Capture(Spinner); }, DispatcherPriority.Input); } } /// /// Defines the event. /// public static readonly RoutedEvent ValueChangedEvent = RoutedEvent.Register(nameof(ValueChanged), RoutingStrategies.Bubble); /// /// Raised when the changes. /// public event EventHandler ValueChanged { add { AddHandler(ValueChangedEvent, value); } remove { RemoveHandler(ValueChangedEvent, value); } } private bool CommitInput() { return SyncTextAndValueProperties(true, Text); } /// /// Synchronize and properties. /// /// If value should be updated from text. /// The text. private bool SyncTextAndValueProperties(bool updateValueFromText, string text) { return SyncTextAndValueProperties(updateValueFromText, text, false); } /// /// Synchronize and properties. /// /// If value should be updated from text. /// The text. /// Force text update. private bool SyncTextAndValueProperties(bool updateValueFromText, string text, bool forceTextUpdate) { if (_isSyncingTextAndValueProperties) return true; _isSyncingTextAndValueProperties = true; var parsedTextIsValid = true; try { if (updateValueFromText) { if (!string.IsNullOrEmpty(text)) { try { var newValue = ConvertTextToValue(text); if (!Equals(newValue, Value)) { SetValueInternal(newValue); } } catch { parsedTextIsValid = false; } } } // Do not touch the ongoing text input from user. if (!_isTextChangedFromUI) { var keepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text); if (!keepEmpty) { var newText = ConvertValueToText(); if (!Equals(Text, newText)) { Text = newText; } } // Sync Text and textBox if (TextBox != null) { TextBox.Text = Text; } } if (_isTextChangedFromUI && !parsedTextIsValid) { // Text input was made from the user and the text // represents an invalid value. Disable the spinner in this case. if (Spinner != null) { Spinner.ValidSpinDirection = ValidSpinDirections.None; } } else { SetValidSpinDirection(); } } finally { _isSyncingTextAndValueProperties = false; } return parsedTextIsValid; } private double ConvertTextToValueCore(string currentValueText, string text) { double result; if (IsPercent(FormatString)) { result = decimal.ToDouble(ParsePercent(text, CultureInfo)); } else { // Problem while converting new text if (!double.TryParse(text, ParsingNumberStyle, CultureInfo, out var outputValue)) { var shouldThrow = true; // Check if CurrentValueText is also failing => it also contains special characters. ex : 90° if (!double.TryParse(currentValueText, ParsingNumberStyle, CultureInfo, out var _)) { // extract non-digit characters var currentValueTextSpecialCharacters = currentValueText.Where(c => !char.IsDigit(c)); var textSpecialCharacters = text.Where(c => !char.IsDigit(c)); // same non-digit characters on currentValueText and new text => remove them on new Text to parse it again. if (currentValueTextSpecialCharacters.Except(textSpecialCharacters).ToList().Count == 0) { foreach (var character in textSpecialCharacters) { text = text.Replace(character.ToString(), string.Empty); } // if without the special characters, parsing is good, do not throw if (double.TryParse(text, ParsingNumberStyle, CultureInfo, out outputValue)) { shouldThrow = false; } } } if (shouldThrow) { throw new InvalidDataException("Input string was not in a correct format."); } } result = outputValue; } return result; } private void ValidateMinMax(double value) { if (value < Minimum) { throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum)); } else if (value > Maximum) { throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum)); } } /// /// Parse percent format text /// /// Text to parse. /// The culture info. private static decimal ParsePercent(string text, IFormatProvider cultureInfo) { var info = NumberFormatInfo.GetInstance(cultureInfo); text = text.Replace(info.PercentSymbol, null); var result = decimal.Parse(text, NumberStyles.Any, info); result = result / 100; return result; } private bool IsPercent(string stringToTest) { var PIndex = stringToTest.IndexOf("P", StringComparison.Ordinal); if (PIndex >= 0) { //stringToTest contains a "P" between 2 "'", it's considered as text, not percent var isText = stringToTest.Substring(0, PIndex).Contains("'") && stringToTest.Substring(PIndex, FormatString.Length - PIndex).Contains("'"); return !isText; } return false; } } }