Nov 18, 2011

NumericTextBox (C#.NET): A TextBox for numbers with built-in mathematical expression evaluation

Update:
The article was updated to the version 1.2. The links are already pointing to the new versions of the files. See details below. 

Introduction
In my project I had a TextBox on one of the forms for entering quantity, and I thought it would be a cool feature if it could understand such basic expressions as 10+42 or 4*8 and so on...
So decided to create a new Control derived from the TextBox, because this could be useful in the future, and now I'm sharing my result.

First of all, since I decided to create a new control, I wanted to make it as flexible as possible, so the expressions that can be understood by the control can include the four basic operations (+, -, *, /) and parentheses, in any valid combination (spaces can be used, they will be skipped).
e.g.: 40+2 or (12+3/ 4) * 4-3*3 or 10+2*(2+5* (4-(2-0.5))+1.5)

Second, I added some extra functionality to the TextBox some are taken from NumericUpDown control, but there are some which I used already but now I built them in the control (changing background color based on value).



Functionality based on NumericUpDown control:
  • Value property (also the Text property is still available)
  • ValueChanged event: Use this instead of the TextChanged event if you want to use the value of the expression, since the Value will only change when the expression in the text box could be evaluated (mostly those can't which are in the middle of editing: e.g. 1+2* ), while the Text changes with each character change.
  • Minimum/Maximum properties: The Value has to be between these limits, if it would be outside the interval it will be set to the Minimum or Maximum value.
  • DecimalPlaces property
  • InterceptArrowKeys property
  • Increment property
  • ThousandsSeparator property
Extra functionality:

A warning and error value can be set, and if the Value will exceed these values then the background color of the control will be changed accordingly.
           Note: If the error value is exceeded then the error color will be used, otherwise if the value is over the warning level then the warning color, if it's lower then it will be white.

The properties associated whit this feature:
  • EnableWarningValue
  • EnableErrorValue
  • WarningValue
  • ErrorValue
  • WarningColor
  • ErrorColor
New functionality in version 1.1

As VallarasuS  suggested I added the feature that if the Value changed the expression will be automatically validated after a given time, meaning instead of 1+2 it will show in the textbox 3.

This is achieved by adding an event handler for the ValueChanged event, and starting a timer. When the timer ticks the Validate() method is called, and it updates the Text of the TextBox.

Note: Also a bug is corrected, which caused the ValueChanged event fire every time when the Text was changed.  

For this there are two new properties:

  • bool AutoValidate To turn on or off this feature. Default: true
  • int AutoValidationTime To set the validation time in milliseconds. Default: 5000


I guess the usage is quite straight forward, so I wouldn't spend more time explaining it, let's see the code:
using System;
using System.ComponentModel;
using System.Windows.Forms;
using System.Drawing; 
using Rankep.MathsEvaluator;

namespace Rankep.NumericTextBox
{
    /// 
    /// A TextBox component to display numeric values.
    /// The 4 basic operations are supported in input.
    /// 
    [Serializable()]
    [ToolboxBitmap(typeof(TextBox))]
    public partial class NumericTextBox : TextBox
    {
        private Timer timer;

        #region NumericTextBox Properties
        private decimal value;
        /// 
        /// The current value of the numeric textbox control.
        /// 
        [Description("The current value of the numeric textbox control.")]
        public decimal Value
        {
            get { return this.value; }
            set 
            { 
                this.value = value;
                if (ValueChanged != null)
                    ValueChanged(this, new EventArgs());
            }
        }

        private decimal minimum;
        /// 
        /// Indicates the minimum value for the numeric textbox control.
        /// 
        [Description("Indicates the minimum value for the numeric textbox control.")]
        public decimal Minimum
        {
            get { return minimum; }
            set { minimum = value; }
        }

        private decimal maximum;
        /// 
        /// Indicates the maximum value for the numeric textbox control.
        /// 
        [Description("Indicates the maximum value for the numeric textbox control.")]
        public decimal Maximum
        {
            get { return maximum; }
            set { maximum = value; }
        }

        private int decimalPlaces;
        /// 
        /// Indicates the number of decimal places to display.
        /// 
        [Description("Indicates the number of decimal places to display.")]
        public int DecimalPlaces
        {
            get { return decimalPlaces; }
            set { decimalPlaces = value; }
        }

        private bool enableWarningValue;
        /// 
        /// Indicates whether the background should change if the warning value is exceeded.
        /// 
        [Description("Indicates whether the background should change if the warning value is exceeded.")]
        public bool EnableWarningValue
        {
            get { return enableWarningValue; }
            set { enableWarningValue = value; }
        }

        private bool enableErrorValue;
        /// 
        /// Indicates whether the background should change if the error value is exceeded.
        /// 
        [Description("Indicates whether the background should change if the error value is exceeded.")]
        public bool EnableErrorValue
        {
            get { return enableErrorValue; }
            set { enableErrorValue = value; }
        }

        private decimal warningValue;
        /// 
        /// Indicates the value from which the background of the numeric textbox control
        /// changes to the WarningColor
        /// 
        [Description("Indicates the value from which the background of the numeric textbox control changes to the WarningColor")]
        public decimal WarningValue
        {
            get { return warningValue; }
            set { warningValue = value; }
        }

        private decimal errorValue;
        /// 
        /// Indicates the value from which the background of the numeric textbox control
        /// changes to the ErrorColor
        /// 
        [Description("Indicates the value from which the background of the numeric textbox control changes to the ErrorColor")]
        public decimal ErrorValue
        {
            get { return errorValue; }
            set { errorValue = value; }
        }

        private bool interceptArrowKeys;
        /// 
        /// Indicates whether the numeric textbox control 
        /// will increment and decrement the value 
        /// when the UP ARROW and DOWN ARROW keys are pressed.
        /// 
        [Description("Indicates whether the numeric textbox control will increment and decrement the value when the UP ARROW and DOWN ARROW keys are pressed.")]
        public bool InterceptArrowKeys
        {
            get { return interceptArrowKeys; }
            set { interceptArrowKeys = value; }
        }

        private decimal increment;
        /// 
        /// Indicates the amount to increment or decrement on each UP or DOWN ARROW press.
        /// 
        [Description("Indicates the amount to increment or decrement on each UP or DOWN ARROW press.")]
        public decimal Increment
        {
            get { return increment; }
            set { increment = value; }
        }

        private bool thousandsSeparator;
        /// 
        /// Indicates whether the thousands separator will be inserted between every three decimal digits.
        /// 
        [Description("Indicates whether the thousands separator will be inserted between every three decimal digits.")]
        public bool ThousandsSeparator
        {
            get { return thousandsSeparator; }
            set { thousandsSeparator = value; }
        }

        private Color warningColor;
        /// 
        /// Indicates the background color of the numeric textbox control if the value exceeds the WarningValue.
        /// 
        [Description("Indicates the background color of the numeric textbox control if the value exceeds the WarningValue.")]
        public Color WarningColor
        {
            get { return warningColor; }
            set { warningColor = value; }
        }

        private Color errorColor;
        /// 
        /// Indicates the background color of the numeric textbox control if the value exceeds the ErrorValue.
        /// 
        [Description("Indicates the background color of the numeric textbox control if the value exceeds the ErrorValue.")]
        public Color ErrorColor
        {
            get { return errorColor; }
            set { errorColor = value; }
        }

        /// 
        /// Indicates whether the expression entered should be automatically validated after a time set with the AutoValidationTime property.
        /// 
        [Description("Indicates whether the expression entered should be automatically validated after a time set with the AutoValidationTime property.")]
        public bool AutoValidate
        { get; set; }

        /// 
        /// Gets or sets the time, in milliseconds, before the entered expression will be validated, after the last value change
        /// 
        [Description("Gets or sets the time, in milliseconds, before the entered expression will be validated, after the last value change")]
        public int AutoValidationTime
        { get; set; }

        #endregion

        /// 
        /// Occurs when the value in the numeric textbox control changes.
        /// 
        [Description("Occurs when the value in the numeric textbox control changes.")]
        public event EventHandler ValueChanged;

        #region NumericTextBox Initialization
        /// 
        /// Constructor
        /// 
        public NumericTextBox()
        {
            InitializeComponent();
            InitializeValues();
            TextChanged += new EventHandler(NumericTextBox_TextChanged);
            KeyUp += new KeyEventHandler(NumericTextBox_KeyUp);
            Leave += new EventHandler(NumericTextBox_Leave);
            ValueChanged += new EventHandler(NumericTextBox_ValueChanged);
            timer = new Timer();
            timer.Enabled = false;
            timer.Tick += new EventHandler(timer_Tick);
        }

        /// 
        /// Constructor
        /// 
        /// 


        public NumericTextBox(IContainer container)
        {
            container.Add(this);

            InitializeComponent();
            InitializeValues();
            TextChanged += new EventHandler(NumericTextBox_TextChanged);
            KeyUp += new KeyEventHandler(NumericTextBox_KeyUp);
            Leave += new EventHandler(NumericTextBox_Leave);
            ValueChanged += new EventHandler(NumericTextBox_ValueChanged);
            timer = new Timer();
            timer.Enabled = false;
            timer.Tick += new EventHandler(timer_Tick);
        }

        /// 
        /// Initialize some default values
        /// 
        private void InitializeValues()
        {
            warningColor = System.Drawing.Color.Gold;
            errorColor = System.Drawing.Color.OrangeRed;
            enableErrorValue = false;
            enableWarningValue = false;
            maximum = 100;
            minimum = 0;
            interceptArrowKeys = true;
            increment = 1;
            decimalPlaces = 0;
            AutoValidationTime = 5000;
            AutoValidate = true;
        }
        #endregion

        #region NumericTextBox Event handles
        /// 
        /// Starts a timer to validate the expression if the AutoValidate is set to true.
        /// 
        /// 


        /// 


        void NumericTextBox_ValueChanged(object sender, EventArgs e)
        {
            if (AutoValidate)
            {
                timer.Interval = AutoValidationTime;
                timer.Start();
            }
        }

        /// 
        /// Validates the expression.
        /// 
        /// 


        /// 


        void timer_Tick(object sender, EventArgs e)
        {
            timer.Stop();
            Validate();
            Select(Text.Length, 0);
        }

        /// 
        /// Handles the event when the focus leaves the control, and validates it's value.
        /// 
        /// 


        /// 


        void NumericTextBox_Leave(object sender, EventArgs e)
        {
            Validate();
        }

        /// 
        /// Handles the Up or Down key up events, if InterceptArrowKeys is true
        /// 
        /// 


        /// 


        void NumericTextBox_KeyUp(object sender, KeyEventArgs e)
        {
            if (InterceptArrowKeys)
            {
                switch (e.KeyCode)
                {
                    case Keys.Up:
                        Value += Increment;
                        Validate();
                        break;

                    case Keys.Down:
                        Value -= Increment;
                        Validate();
                        break;
                }
            }
        }

        /// 
        /// Handles the TextChanged event and tries to parse the text to a decimal value.
        /// 
        /// 


        /// 


        private void NumericTextBox_TextChanged(object sender, EventArgs e)
        {
            timer.Stop();
            decimal v;
            if (MathsEvaluator.MathsEvaluator.TryParse(Text, out v))
            {
                //check if it's between min and max
                if (v > Maximum)
                    v = Maximum;
                else if (v < Minimum)
                    v = Minimum;

                //Change the background color according to the warning and error levels
                Color c = Color.White;
                if (EnableErrorValue && v > ErrorValue)
                    c = ErrorColor;
                else if (EnableWarningValue && v > WarningValue)
                    c = WarningColor;

                BackColor = c;

                //Set the value property
                if (Value.CompareTo(v) != 0)
                    Value = v;
            }
        }
        #endregion

        /// 
        /// Exits editing mode, and replaces the Text to the formatted version of Value
        /// 
        public void Validate()
        {
            string dec = "";
            for (int i = 0; i < DecimalPlaces; i++)
                dec += "#";
            if (dec.Length > 0)
                dec = "." + dec;

            string s;
            if (ThousandsSeparator)
                s = String.Format("{0:0,0" + dec + "}", Value);
            else
                s = String.Format("{0:0" + dec + "}", Value);

            Text = s;
        }

    }
}

The code is using the static methods of my MathsEvaluator class, which I have written for this, and I'm sharing it together with this control.


Changes in v1.2  

The code of this has changed in v1.2, mostly small maintenance and a bug is corrected which caused false evaluation of expressions containing brackets inside brackets. Also a new operator (^ aka power) is supported, and the multiplication sign can be omitted next to a bracket (e.g. 2(3+4)5 is the same as 2*(3+4)*5 or (1+1)(1+1) is the same as (1+1)*(1+1)). 

I wrote an article about this evaluator class, which explains it in details. See it here.


Here is the code:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace Rankep.MathsEvaluator
{
    /// 
    /// A class to evaluate mathematical expressions
    /// 
    public static class MathsEvaluator
    {
        /// 
        /// Returns the evaluated value of the expression
        /// 
        /// Throws ArgumentExpression
        /// 
A mathematical expression
        /// Value of the expression
        public static decimal Parse(string expression)
        {
            decimal d;
            if (decimal.TryParse(expression, out d))
            {
                //The expression is a decimal number, so we are just returning it
                return d;
            }
            else
            {
                return CalculateValue(expression);
            }
        }
        /// 
        /// Tries to evaluate a mathematical expression.
        /// 
        /// 
The expression to evaluate
        /// 
The parsed value
        /// Indicates whether the evaluation was succesful
        public static bool TryParse(string expression, out decimal value)
        {
            if (IsExpression(expression))
            {
                try
                {
                    value = Parse(expression);
                    return true;
                }
                catch
                {
                    value = 0;
                    return false;
                }
            }
            else
            {
                value = 0;
                return false;
            }
        }
        /// 
        /// Determines if an expression contains only valid characters
        /// 
        /// 
The expression to check
        /// Indicates whether only valid characters were used in the expression
        public static bool IsExpression(string s)
        {
            //Determines whether the string contains illegal characters
            Regex RgxUrl = new Regex("^[0-9+*-/^()., ]+$");
            return RgxUrl.IsMatch(s);
        }
        /// 
        /// Splits an expression into elements
        /// 
        /// 
Mathematical expression
        /// 
Operators used as delimiters
        /// The list of elements
        private static List TokenizeExpression(string expression, Dictionary operators)
        {
            List elements = new List();
            string currentElement = string.Empty;
            int state = 0;
            /* STATES
                 * 0 - start
                 * 1 - after opening bracket '('
                 * 2 - after closing bracket ')'
                 * */
            int bracketCount = 0;
            for (int i = 0; i < expression.Length; i++)
            {
                switch (state)
                {
                    case 0:
                        if (expression[i] == '(')
                        {
                            //Change the state after an opening bracket is received
                            state = 1;
                            bracketCount = 0;
                            if (currentElement != string.Empty)
                            {
                                //if the currentElement is not empty, then assuming multiplication
                                elements.Add(currentElement);
                                elements.Add("*");
                                currentElement = string.Empty;
                            }
                        }
                        else if (operators.Keys.Contains(expression[i]))
                        {
                            //The current character is an operator
                            elements.Add(currentElement);
                            elements.Add(expression[i].ToString());
                            currentElement = string.Empty;
                        }
                        else if (expression[i] != ' ')
                        {
                            //The current character is neither an operator nor a space
                            currentElement += expression[i];
                        }
                        break;
                    case 1:
                        if (expression[i] == '(')
                        {
                            bracketCount++;
                            currentElement += expression[i];
                        }
                        else if (expression[i] == ')')
                        {
                            if (bracketCount == 0)
                            {
                                state = 2;
                            }
                            else
                            {
                                bracketCount--;
                                currentElement += expression[i];
                            }
                        }
                        else if (expression[i] != ' ')
                        {
                            //Add the character to the current element, omitting spaces
                            currentElement += expression[i];
                        }
                        break;
                    case 2:
                        if (operators.Keys.Contains(expression[i]))
                        {
                            //The current character is an operator
                            state = 0;
                            elements.Add(currentElement);
                            currentElement = string.Empty;
                            elements.Add(expression[i].ToString());
                        }
                        else if (expression[i] != ' ')
                        {
                            elements.Add(currentElement);
                            elements.Add("*");
                            currentElement = string.Empty;
                            if (expression[i] == '(')
                            {
                                state = 1;
                                bracketCount = 0;
                            }
                            else
                            {
                                currentElement += expression[i];
                                state = 0;
                            }
                        }
                        break;
                }
            }
            //Add the last element (which follows the last operation) to the list
            if (currentElement.Length > 0)
            {
                elements.Add(currentElement);
            }
            return elements;
        }
        /// 
        /// Calculates the value of an expression
        /// 
        /// 
The expression to evaluate
        /// The value of the expression
        private static decimal CalculateValue(string expression)
        {
            //Dictionary to store the supported operations
            //Key: Operation; Value: Precedence (higher number indicates higher precedence)
            Dictionary operators = new Dictionary
            { 
                {'+', 1}, {'-', 1}, {'*', 2}, {'/', 2}, {'^', 3}
            };
            //Tokenize the expression
            List elements = TokenizeExpression(expression, operators);
            //define a value which will be used as the return value of the function
            decimal value = 0;
            //loop from the highest precedence to the lowest
            for (int i = operators.Values.Max(); i >= operators.Values.Min(); i--)
            {
                //loop while there are any operators left in the list from the current precedence level
                while (elements.Count >= 3
                    && elements.Any(element => element.Length == 1 &&
                        operators.Where(op => op.Value == i)
                        .Select(op => op.Key).Contains(element[0])))
                {
                    //get the position of this element
                    int pos = elements
                        .FindIndex(element => element.Length == 1 &&
                        operators.Where(op => op.Value == i)
                        .Select(op => op.Key).Contains(element[0]));
                    //evaluate it's value
                    value = EvaluateOperation(elements[pos], elements[pos - 1], elements[pos + 1]);
                    //change the first operand of the operation to the calculated value of the operation
                    elements[pos - 1] = value.ToString();
                    //remove the operator and the second operand from the list
                    elements.RemoveRange(pos, 2);
                }
            }
            return value;
        }
        /// 
        /// Performs an operation on the operands
        /// 
        /// 
Operator
        /// 
Left operand
        /// 
Right operand
        /// Value of the operation
        private static decimal EvaluateOperation(string oper, string operand1, string operand2)
        {
            if (oper.Length == 1)
            {
                decimal op1 = Parse(operand1);
                decimal op2 = Parse(operand2);
                decimal value = 0;
                switch (oper[0])
                {
                    case '+':
                        value = op1 + op2;
                        break;
                    case '-':
                        value = op1 - op2;
                        break;
                    case '*':
                        value = op1 * op2;
                        break;
                    case '/':
                        value = op1 / op2;
                        break;
                    case '^':
                        value = Convert.ToDecimal(Math.Pow(Convert.ToDouble(op1), Convert.ToDouble(op2)));
                        break;
                    default:
                        throw new ArgumentException("Unsupported operator");
                }
                return value;
            }
            else
            {
                throw new ArgumentException("Unsupported operator");
            }
        }
    }
}

Downloads: (version 1.2)

You can use the code and the compiled dll for any purposes, however if possible please mark the source.

Thank you for reading! Any problems, ideas, help, constructive criticism are welcome in the comments.

No comments:

Post a Comment