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("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 FormartString 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.Descrease.
///
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
// repesents 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;
}
}
}