using System; using System.Globalization; using System.IO; using System.Linq; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Data; using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.Utilities; namespace Avalonia.Controls { /// /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values. /// [TemplatePart("PART_Spinner", typeof(Spinner))] [TemplatePart("PART_TextBox", typeof(TextBox))] 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 StyledProperty ClipValueToMinMaxProperty = AvaloniaProperty.Register(nameof(ClipValueToMinMax)); /// /// Defines the property. /// public static readonly StyledProperty NumberFormatProperty = AvaloniaProperty.Register(nameof(NumberFormat), NumberFormatInfo.CurrentInfo); /// /// 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.0m, coerce: 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), decimal.MaxValue, coerce: OnCoerceMaximum); /// /// Defines the property. /// public static readonly StyledProperty MinimumProperty = AvaloniaProperty.Register(nameof(Minimum), decimal.MinValue, coerce: OnCoerceMinimum); /// /// Defines the property. /// public static readonly StyledProperty ParsingNumberStyleProperty = AvaloniaProperty.Register(nameof(ParsingNumberStyle), NumberStyles.Any); /// /// Defines the property. /// public static readonly StyledProperty TextProperty = AvaloniaProperty.Register(nameof(Text), defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); /// /// Defines the property. /// public static readonly StyledProperty TextConverterProperty = AvaloniaProperty.Register(nameof(TextConverter), defaultBindingMode: BindingMode.OneWay); /// /// Defines the property. /// public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register(nameof(Value), coerce: (s,v) => ((NumericUpDown)s).OnCoerceValue(v), defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); /// /// Defines the property. /// public static readonly StyledProperty WatermarkProperty = AvaloniaProperty.Register(nameof(Watermark)); /// /// Defines the property. /// public static readonly StyledProperty HorizontalContentAlignmentProperty = ContentControl.HorizontalContentAlignmentProperty.AddOwner(); /// /// Defines the property. /// public static readonly StyledProperty VerticalContentAlignmentProperty = ContentControl.VerticalContentAlignmentProperty.AddOwner(); private IDisposable? _textBoxTextChangedSubscription; private bool _internalValueSet; private bool _isSyncingTextAndValueProperties; private bool _isTextChangedFromUI; /// /// 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 => GetValue(ClipValueToMinMaxProperty); set => SetValue(ClipValueToMinMaxProperty, value); } /// /// Gets or sets the current NumberFormatInfo /// public NumberFormatInfo? NumberFormat { get => GetValue(NumberFormatProperty); set => SetValue(NumberFormatProperty, 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 decimal 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 decimal Maximum { get { return GetValue(MaximumProperty); } set { SetValue(MaximumProperty, value); } } /// /// Gets or sets the minimum allowed value. /// public decimal Minimum { get { return GetValue(MinimumProperty); } set { SetValue(MinimumProperty, value); } } /// /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any. /// Note that Hex style does not work with decimal. /// For hexadecimal display, use . /// public NumberStyles ParsingNumberStyle { get => GetValue(ParsingNumberStyleProperty); set => SetValue(ParsingNumberStyleProperty, value); } /// /// Gets or sets the formatted string representation of the value. /// public string? Text { get => GetValue(TextProperty); set => SetValue(TextProperty, value); } /// /// Gets or sets the custom bidirectional Text-Value converter. /// Non-null converter overrides , providing finer control over /// string representation of the underlying value. /// public IValueConverter? TextConverter { get => GetValue(TextConverterProperty); set => SetValue(TextConverterProperty, value); } /// /// Gets or sets the value. /// public decimal? Value { get => GetValue(ValueProperty); set => SetValue(ValueProperty, 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); } } /// /// Gets or sets the horizontal alignment of the content within the control. /// public HorizontalAlignment HorizontalContentAlignment { get => GetValue(HorizontalContentAlignmentProperty); set => SetValue(HorizontalContentAlignmentProperty, value); } /// /// Gets or sets the vertical alignment of the content within the control. /// public VerticalAlignment VerticalContentAlignment { get => GetValue(VerticalContentAlignmentProperty); set => SetValue(VerticalContentAlignmentProperty, 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() { NumberFormatProperty.Changed.Subscribe(OnNumberFormatChanged); FormatStringProperty.Changed.Subscribe(FormatStringChanged); IncrementProperty.Changed.Subscribe(IncrementChanged); IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged); MaximumProperty.Changed.Subscribe(OnMaximumChanged); MinimumProperty.Changed.Subscribe(OnMinimumChanged); TextProperty.Changed.Subscribe(OnTextChanged); TextConverterProperty.Changed.Subscribe(OnTextConverterChanged); ValueProperty.Changed.Subscribe(OnValueChanged); } /// protected override void OnLostFocus(RoutedEventArgs e) { CommitInput(true); base.OnLostFocus(e); } /// protected override void OnApplyTemplate(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 to update the validation state for properties for which data validation is /// enabled. /// /// The property. /// The current data binding state. /// The current data binding error, if any. protected override void UpdateDataValidation( AvaloniaProperty property, BindingValueType state, Exception? error) { if (property == TextProperty || property == ValueProperty) { DataValidationErrors.SetError(this, error); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnNumberFormatChanged(NumberFormatInfo? oldValue, NumberFormatInfo? 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(decimal oldValue, decimal 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(decimal oldValue, decimal newValue) { if (IsInitialized) { SetValidSpinDirection(); } if (ClipValueToMinMax && Value.HasValue) { SetCurrentValue(ValueProperty, MathUtilities.Clamp(Value.Value, Minimum, Maximum)); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnMinimumChanged(decimal oldValue, decimal newValue) { if (IsInitialized) { SetValidSpinDirection(); } if (ClipValueToMinMax && Value.HasValue) { SetCurrentValue(ValueProperty, MathUtilities.Clamp(Value.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 OnTextConverterChanged(IValueConverter? oldValue, IValueConverter? newValue) { if (IsInitialized) { SyncTextAndValueProperties(false, null); } } /// /// Called when the property value changed. /// /// The old value. /// The new value. protected virtual void OnValueChanged(decimal? oldValue, decimal? newValue) { if (!_internalValueSet && IsInitialized) { SyncTextAndValueProperties(false, null, true); } SetValidSpinDirection(); RaiseValueChangedEvent(oldValue, newValue); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual decimal OnCoerceIncrement(decimal baseValue) { return baseValue; } /// /// Called when the property has to be coerced. /// /// The value. protected virtual decimal OnCoerceMaximum(decimal baseValue) { return Math.Max(baseValue, Minimum); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual decimal OnCoerceMinimum(decimal baseValue) { return Math.Min(baseValue, Maximum); } /// /// Called when the property has to be coerced. /// /// The value. protected virtual decimal? OnCoerceValue(decimal? 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(decimal? oldValue, decimal? newValue) { var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue); RaiseEvent(e); } /// /// Converts the formatted text to a value. /// private decimal? ConvertTextToValue(string? text) { decimal? result = null; 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 && result.HasValue) { return MathUtilities.Clamp(result.Value, Minimum, Maximum); } ValidateMinMax(result); return result; } /// /// Converts the value to formatted text. /// /// private string? ConvertValueToText() { if (TextConverter != null) { return TextConverter.ConvertBack(Value, typeof(string), null, CultureInfo.CurrentCulture)?.ToString(); } //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind. if (FormatString.Contains("{0")) { return string.Format(NumberFormat, FormatString, Value); } return Value?.ToString(FormatString, NumberFormat); } /// /// Called by OnSpin when the spin direction is SpinDirection.Increase. /// private void OnIncrement() { decimal result; if (Value.HasValue) { result = Value.Value + Increment; } else { result = Minimum; } SetCurrentValue(ValueProperty, MathUtilities.Clamp(result, Minimum, Maximum)); } /// /// Called by OnSpin when the spin direction is SpinDirection.Decrease. /// private void OnDecrement() { decimal result; if (Value.HasValue) { result = Value.Value - Increment; } else { result = Maximum; } SetCurrentValue(ValueProperty, 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.HasValue) { validDirections = ValidSpinDirections.Increase | ValidSpinDirections.Decrease; } 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 OnNumberFormatChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (NumberFormatInfo?)e.OldValue; var newValue = (NumberFormatInfo?)e.NewValue; upDown.OnNumberFormatChanged(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 = (decimal)e.OldValue!; var newValue = (decimal)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 = (decimal)e.OldValue!; var newValue = (decimal)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 = (decimal)e.OldValue!; var newValue = (decimal)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 OnTextConverterChanged(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is NumericUpDown upDown) { var oldValue = (IValueConverter?)e.OldValue; var newValue = (IValueConverter?)e.NewValue; upDown.OnTextConverterChanged(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 = (decimal?)e.OldValue; var newValue = (decimal?)e.NewValue; upDown.OnValueChanged(oldValue, newValue); } } private void SetValueInternal(decimal? value) { _internalValueSet = true; try { SetCurrentValue(ValueProperty, value); } finally { _internalValueSet = false; } } private static decimal OnCoerceMaximum(AvaloniaObject instance, decimal value) { if (instance is NumericUpDown upDown) { return upDown.OnCoerceMaximum(value); } return value; } private static decimal OnCoerceMinimum(AvaloniaObject instance, decimal value) { if (instance is NumericUpDown upDown) { return upDown.OnCoerceMinimum(value); } return value; } private static decimal OnCoerceIncrement(AvaloniaObject instance, decimal value) { if (instance is NumericUpDown upDown) { return upDown.OnCoerceIncrement(value); } return value; } private void TextBoxOnTextChanged() { try { _isTextChangedFromUI = true; if (TextBox != null) { SetCurrentValue(TextProperty, 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.Pointer.Captured != Spinner) { Dispatcher.UIThread.InvokeAsync(() => { e.Pointer.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(bool forceTextUpdate = false) { return SyncTextAndValueProperties(true, Text, forceTextUpdate); } /// /// 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) { 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) { if (forceTextUpdate) { var newText = ConvertValueToText(); if (!Equals(Text, newText)) { SetCurrentValue(TextProperty, 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 decimal? ConvertTextToValueCore(string? currentValueText, string? text) { decimal result; if (string.IsNullOrEmpty(text)) { return null; } if (TextConverter != null) { var valueFromText = TextConverter.Convert(text, typeof(decimal?), null, CultureInfo.CurrentCulture); return (decimal?)valueFromText; } if (IsPercent(FormatString)) { result = ParsePercent(text, NumberFormat); } else { // Problem while converting new text if (!decimal.TryParse(text, ParsingNumberStyle, NumberFormat, out var outputValue)) { var shouldThrow = true; // Check if CurrentValueText is also failing => it also contains special characters. ex : 90° if (!string.IsNullOrEmpty(currentValueText) && !decimal.TryParse(currentValueText, ParsingNumberStyle, NumberFormat, 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).Any()) { foreach (var character in textSpecialCharacters) { text = text.Replace(character.ToString(), string.Empty); } // if without the special characters, parsing is good, do not throw if (decimal.TryParse(text, ParsingNumberStyle, NumberFormat, out outputValue)) { shouldThrow = false; } } } if (shouldThrow) { throw new InvalidDataException("Input string was not in a correct format."); } } result = outputValue; } return result; } private void ValidateMinMax(decimal? value) { if (!value.HasValue) { return; } if (value < Minimum) { throw new ArgumentOutOfRangeException(nameof(value), string.Format("Value must be greater than Minimum value of {0}", Minimum)); } else if (value > Maximum) { throw new ArgumentOutOfRangeException(nameof(value), 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; } } }