Creating an Ink-Aware Masked Numeric Input Component for Tablet PC

 

Leszynski Group, Inc.

March 2006

Applies to:
Microsoft Tablet PC Platform SDK
Microsoft Windows XP Tablet PC Edition 2005
Microsoft Visual Studio .NET 2003

Summary: This article and sample code demonstrate how to write your own custom component to collect, recognize, format, and validate data on a Tablet PC using masked ink input fields. (17 printed pages)

Click here to download the code sample for this article.

Contents

Introduction
InkNumber Overview
InkNumber Architecture
Building InkNumber
Editing Existing Values
Extending InkNumber
Conclusion
Biography

Introduction

This article and sample code provide an example of how to write a custom control for use in Windows Forms that can be called by your solutions instead of the Tablet PC Input Panel. The sample code creates a component called InkNumber that combines a text box, a pop-up form, and fine-tuned ink collectors to demonstrate how to:

  • Develop a cell-based ink input control.
  • Perform simple recognition on a character-by-character basis.
  • Coerce recognition of strokes by using wordlists and factoids.
  • Provide a simple and elegant user experience for Tablet PC neophyte users.

The custom control is contained in an assembly that you can use in your applications as-is or with customization. The assembly is Microsoft.Samples.TabletPC.InkNumber.dll, which contains the control and its code.

The sample was created in Visual Studio 2003 (.NET Framework 1.1) using C#. The sample also includes compiled binaries that can be demonstrated without Visual Studio; see the document called Using the InkNumber Sample (UsingInkNumber.doc enclosed with the sample) for installation instructions and demo use cases.

The sample code can be converted successfully to Visual Studio 2005 without errors but has not been extensively tested with this version.

InkNumber Overview

The goal of the InkNumber sample is to provide a simple, yet realistic, example of the type of user interface widgets that can be built with the Tablet PC SDK to improve and/or customize the user experience in custom software solutions. In many situations, you need to provide a data entry experience that goes beyond what is available in the Input Panel. Currently, the only programmable customization available in the Input Panel is found in the SetInputScope APIs in the Tablet PC SDK, which allow you to pass coercion information to the Input Panel. However, this is where the customization ends—you cannot programmatically change the size, color, caption, recognition timeout, and other properties of the Input Panel.

InkNumber presents an alternative component that provides an ink entry experience where the developer has full control, via object properties or source code, of these elements of the user experience:

  • Window caption (prompt)
  • Cell size
  • Color
  • Font
  • Input mask
  • Recognition coercion (at the character level)
  • Recognition timeout
  • Validation

Figure 1 shows the InkNumber component collecting a phone number on a Windows Form. By providing the component with a mask value that contains the parentheses and dash characters, the developer of this data entry form has created an input mask that removes from the user the burden of entering and recognizing standard, static characters in the phone number data stream.

Figure 1. InkNumber component on a form.

Figure 1 demonstrates how a component like InkNumber can improve the user experience on a Tablet PC, by:

  • Providing a large, ink-enabled surface that fosters rapid data entry and improved recognition.
  • Binding a custom wordlist to the component so that only specified characters (digits, in this case) are recognized, no matter how poorly written.
  • Pre-loading the component with static values (the dash), seed values (the area code), and previous data (digits from the current phone number, if any), to minimize the amount of data entry required by the user.
  • Using high contrast coloration to provide visual cues for ink-enabled surfaces and button controls, making the component's features easily discoverable for neophyte users and highly visible for users working in varied lighting.

InkNumber Architecture

Figure 2 diagrams the architecture of the InkNumber component.

Figure 2. Component architecture of InkNumber

There are a number of key architectural considerations shown in Figure 2:

  • InkNumber inherits from a TextBox control so that you can easily store, validate, and data-bind the results of the data entry. Additionally, the text box provides a central location where you can set properties for the visible control (the text box) as well as the input panel control (the pop-up).
  • A tap on the text box instantiates the pop-up, called a Comb object. The Comb is a Form object whose primary constituent is a Panel control that contains one or more cells. A single cell can contain static text or an ink collector for collecting and recognizing ink.
  • Editable cells are based on an InputPanel object, which is a user control. The InputPanel comes in two variants: NumberInputPanel, which accepts ink and coerces it to a digit; and SignInputPanel, which accepts ink and coerces it to a plus or minus sign. An additional type of cell (non-editable) is available using the StaticPanel object, which displays a static character that is included with the return value. This multi-flavored cell architecture was chosen for two reasons:
    • It enables you to create additional cell objects with their own unique properties and easily add them to the solution.
    • It allows for each cell type to behave in different ways—for example, the BackColor property of the StaticPanel object is different from other types of cells.

Building InkNumber

We chose to build InkNumber as a pop-up connected to a TextBox-derived control named InkNumberControl, in order to:

  • Provide the simplest developer experience in the Visual Studio designer, and
  • Make it easy for developers to retrieve and store the text value generated by the data entry.

The following code shows the partial declaration for the InkNumberControl:

/// <summary>
/// Custom TextBox with attached Comb pop-up for ink input.
/// </summary>
public class InkNumberControl : System.Windows.Forms.TextBox
{
   ...
   private Comb comb;

InkNumberControl declares a private member of the pop-up component – Comb – which is a Windows Form that provides:

  • The ink-enabled cells for ink input.
  • The Input Panel text prompting for data entry.
  • The OK and Cancel buttons.
  • A container for the primary code logic.

On load, the Comb constructs one or more entry cells by digesting its NumberMask property value, where the developer has defined the data entry experience. The NumberMask property is a string containing "marker" characters from this list:

  • + - The plus sign marker indicates that a blank cell should be created; this cell will collect ink and only recognize the ink as a plus (+) or minus (-) character. This cell is seeded with a plus character to indicate to the user that it is a signed cell.
  • # - The numeric marker indicates that a blank cell should be created; this cell will collect ink and only recognize the ink as a digit (0 through 9).
  • Numbers – A number marker from 0 through 9 indicates that a cell should be created that displays the number as its initial (seed) value; this cell will collect ink and only recognize the ink as a digit (0 through 9).
  • Keyboard characters – Any other keyboard character used as a marker (letters and symbols) indicates that a non-editable cell should be created that displays the character as its static value; this cell will not collect ink.

As an example, the NumberMask property for the Comb pop-up shown in Figure 1 includes three of the four types of marker characters – the numeric marker, numbers used as seed values, and keyboard characters used as static text, like this:

comb.NumberMask = "(425)###-####";

The Comb's CreateInputPanels method dissects the NumberMask property and generates panels that match the mask markers:

private void CreateInputPanels()
{   
   // Width of control in addition to the panel controls.
   int width = 33;  
   controlsPanel.Controls.Clear();
   Control ctl = null;
   for (int i = 0; i < numberMask.Length; i++)
   {
      switch (numberMask[i])
      {
         case '#':
            ctl = new NumberInputPanel();
            break;
         case '0':
         case '1':
         case '2':
         case '3':
         case '4':
         case '5':
         case '6':
         case '7':
         case '8':
         case '9':
            ctl = new NumberInputPanel();
            ctl.Text = numberMask[i].ToString();
            break;
         case '+':
            ctl = new SignInputPanel();
            // Seed the cell to indicate the cell type.
            ctl.Text = "+";
            break;
         default:
            ctl = new StaticPanel();
            ctl.Text = numberMask[i].ToString();
            break;
      }
      if (ctl is InputPanel)
      {
         ((InputPanel) ctl).CharRecognized += new EventHandler(Comb_CharRecognized);
         ((InputPanel) ctl).RecognitionTimeout = recognitionTimeout;
      }
      controlsPanel.Controls.Add(ctl);
      ctl.Dock = DockStyle.Left;
      ctl.BringToFront();
      width += ctl.Width;
   }

   // Check to see whether the comb will fit on the screen and throw an exception if it doesn't. 
   if (width > Screen.PrimaryScreen.WorkingArea.Width)
   {
      throw new InvalidOperationException("The Comb is too wide to fit on the screen.");
   }

   Width = width;
}

While simple, the CreateInputPanels method code is designed to be infinitely scalable, allowing you to extend the types of cell controls that can be managed by the Comb, as well as the usable markers in the mask.

In the CreateInputPanels method, the recognitionTimeout property of the Comb is passed to the InputPanel object. Recognition latency is configured by a Timer set to an interval property configurable by the developer; this setting defaults to 500 milliseconds. When the Timer object's Tick event fires, the ink is processed and the RecognizeChar event is fired:

private void timer_Tick(object sender, EventArgs e)
{
   // It's possible that a timer Tick could occur after the Dispose method
   // has been called. To handle this, we simply return to avoid trying 
   // to use resources that have already been disposed.
   if (startedDisposing)
   {
      return;
   }

   timer.Stop();
   RecognizeChar();
}

private void RecognizeChar()
{
   if (!recognitionInProgress)
   {
      try
      {
         recognitionInProgress = true;
         recognitionContext.EndInkInput();
      
         // Proceed only if there are strokes to recognize.
         if (inkCollector.Ink.Strokes.Count < 1)
         {
            return;
         }

         RecognitionStatus status;
         recognitionResult = recognitionContext.Recognize(out status);
         if (recognitionResult != null)
         {
            if (recognitionResult.TopString.Length > 0)
            {
               character = recognitionResult.TopString[0];
            }
            else
            {
               character = ' ';
            }

            inkPanel.Refresh();

            if (CharRecognized != null)
            {
               CharRecognized(this, new System.EventArgs());
            }
         }
         inkCollector.Ink.Strokes.Clear();
         recognitionContext.Strokes.Clear();
      }
      finally
      {
         recognitionInProgress = false;
      }
   }
}

In the CreateInputPanels method, the InputPanel object's CharRecognized event is propagated through the Comb back to the host form, allowing the form's developer to control how the application responds when recognition occurs. For purposes of this sample, the form performs validation after recognition occurs in a cell, to determine if all cells in the Comb have been populated and, if so, to enable the OK button:

private void inkNumber_CharRecognized(object sender, EventArgs e)
{
   // Perform validation.
   if (validate.Checked)
   {
      bool enabled = true;
      if (inkNumber.Number.Length < inkNumber.Mask.Length)
      {
         enabled = false;
      }
      else
      {
         for (int n = 0; n < inkNumber.Mask.Length; n++)
         {
            if (inkNumber.Number[n] == ' ' && inkNumber.Mask[n] != ' ')
            {
               enabled = false;
               break;
            }
         }
      }
      inkNumber.OkButtonEnabled = enabled;
   }
}

Coercion of ink to numbers or signs in the Comb is achieved by using the Factoid property on the cell's RecognizerContext object. In the case of the NumberInputPanel object, recognition is set to coerce to digits only:

protected override void SetRecognizerContext()
{
   // Interpret strokes as a digit only.
   recognitionContext.Factoid = Factoid.Digit;
}

In the case of the SignInputPanel object, recognition is set to coerce to a custom WordList:

protected override void SetRecognizerContext()
{
   // Interpret strokes as a "+" or a "-" only.
   wordList.Add("+");
   wordList.Add("-");
   recognitionContext.WordList = wordList;
   recognitionContext.Factoid = Factoid.WordList;
}

Editing Existing Values

If there is an existing value in the InkNumber.Text property when the Comb is displayed, InkNumber attempts to coerce the text to fit within the current defined mask. Seeding the pop-up with the previous text value allows the user to modify existing digits instead of re-entering the entire value.

Pre-loading the Comb may present challenges when text that was previously created with InkNumber has been modified by some other process between entry and editing. For example, a date created in InkNumber using a mask of ##/##/#### will appear as 01/01/2005 after creation, but may be shortened before editing to 1/1/2005 as a result of form code or storing it in a database.

InkNumber uses the following rules to coerce an existing value into the current mask:

  • Leading zeros are added to the value where necessary to fill numeric cells (those with "#" or a number in the mask).
  • A "+" prefix is added if a sign marker is in the mask but is missing from the value.

To simplify the coercion process, fields are created to support mask parsing:

// Private class definitions used for fitting existing strings into masks.
private class NumberMaskField
{
   public int Length = 1;
}

private class SignMaskField
{
}

private class StaticMaskField
{
   public StaticMaskField(char c)
   {
      this.Char = c;
   }

   public char Char = ' ';
}

Next, the mask is parsed into an array of these fields, each with a type and length:

private void ShowComb()
{
   if (comb != null)
   {
      comb.Dispose();
      comb = null;
   }
   comb = new Comb(recognitionTimeout);
   comb.TipText = tipText;
   comb.NumberMask = mask;
   comb.OkButtonEnabled = OkButtonInitiallyEnabled;
   if (comb.NumberMask.Length == 0)
   {
      throw new InvalidOperationException("This value cannot be edited without a mask.");
   }

   // Attempt to fit the existing string into the mask.
   // First, parse the mask into an array of mask fields.
   ArrayList maskFields = new ArrayList();
   int maskPtr = comb.NumberMask.Length - 1;
   object currentMaskField = null;
   while (maskPtr >= 0)
   {
      char c = comb.NumberMask[maskPtr];
      
      // Check for the continuation of a number field.
      if (currentMaskField is NumberMaskField)
      {
         if (IsADigitOrPound(c))
         {
            // This is still a number field; increment the length.
            ((NumberMaskField) currentMaskField).Length++;
         }
         else
         {
            // The number field has ended.
            currentMaskField = null;
         }
      }

      if (currentMaskField == null)
      {
         if (IsADigitOrPound(c))
         {
            // Start a new number field.
            currentMaskField = new NumberMaskField();
            maskFields.Add(currentMaskField);
         }
         else if (c == '+')
         {
            // Add a sign field.
            maskFields.Add(new SignMaskField());
         }
         else
         {
            // Add a static field.
            maskFields.Add(new StaticMaskField(c));
         }
      }

      maskPtr--;
   }

After the mask has been parsed into typed fields, the text is coerced to fit into these fields. If the text is empty, no coercion is attempted. If the text can't be coerced to fit reasonably into the mask, an exception is generated and the Comb remains empty:

   // Now attempt to fit the existing string into the parsed mask.
   bool done = false;
   bool ok = true;
   
   if (Text.Length == 0)
   {
      // There is no existing string, so use default values.
      done = true;
   }

   int textIndex = Text.Length - 1;
   int maskFieldIndex = 0;
   currentMaskField = maskFields[0];
   int numberCount = 0;
   StringBuilder sb = new StringBuilder();

   while (!done)
   {
      // Default the character to 0 in case we're out of characters;
      // this is used to zero pad the leftmost field if appropriate.
      char c = '0';
      if (textIndex >= 0)
      {
         c = Text[textIndex];
      }

      if (currentMaskField is NumberMaskField)
      {
         if (IsADigitOrPound(c))
         {
            sb.Insert(0, c);
            textIndex--;

            if (++numberCount >= ((NumberMaskField) currentMaskField).Length)
            {
               // All the possible digits have been found for this field; 
               // go on to the next field.
               maskFieldIndex++;
            }
         }
         else
         {
            // Didn't find a digit, so pad the remaining positions with zeros.
            while (numberCount++ < ((NumberMaskField) currentMaskField).Length)
            {
               sb.Insert(0, '0');
            }

            // Go on to the next field.
            maskFieldIndex++;
         }
      }
      else if (currentMaskField is SignMaskField)
      {
         char signChar = '+';
         if (c == '-')
         {
            signChar = '-';
            textIndex--;
         }
         else if (c == '+')
         {
            textIndex--;
         }
         else
         {
            // Assume a plus sign; don't decrement the text index.
         }

         sb.Insert(0, signChar);
         
         // Go on to the next field.
         maskFieldIndex++;
      }
      else
      {
         // The current mask field is a StaticMaskField.
         if (((StaticMaskField) currentMaskField).Char == c)
         {
            // The character matched the expected value.
            sb.Insert(0, c);
            textIndex--;
            
            // Go on to the next field.
            maskFieldIndex++;
         }
         else
         {
            // The character didn't match the expected value;
            // the parse failed.
            ok = false;
            done = true;
         }
      }

      if (!done)
      {
         if (maskFieldIndex == maskFields.Count)
         {
            // We are at the end of the mask fields.

            if (textIndex >= 0)
            {
               // There was extra text left over; the parse failed.
               ok = false;
            }

            done = true;
         }
         else if (currentMaskField != maskFields[maskFieldIndex])
         {
            // We have gone on to the next field; update the pointer 
            // and reset the number count.
            currentMaskField = maskFields[maskFieldIndex];
            numberCount = 0;
         }
      }
   }

   if (ok)  
   {
      comb.Number = sb.ToString();
   }
   else
   {
      throw new InvalidOperationException("The current value cannot be fit into the mask. Fix it or delete it, then retry.");
   }
   comb.OkButtonEnabled = true;

   comb.CharRecognized += new EventHandler(comb_CharRecognized);
   comb.AutoPositionRelativeTo(this);
   if (comb.ShowDialog(this) == DialogResult.OK)
   {
      Text = comb.Number;
   }
}

Extending InkNumber

Here are some suggested ways that you can extend InkNumber to provide customized functionality for your enterprise or your unique application scenarios:

  • Cells. You can create additional types of cells that derive from InputPanel and provide specific functionality. For example, you could create a PunctuationInputPanel cell that coerced recognition to commas, periods, and similar characters.
  • Cell-level Properties. You can extend each type of cell in the Comb to expose more properties that can be set at design time or run time. For example, the color information of each cell could be exposed in the object model to make it accessible through the inkNumber.comb.controlsPanel object on the calling form.
  • Masking. You can extend the types and capabilities of mask characters to expose additional factoids or custom wordlists. For example, you could create a mask and custom cell object to leverage the Factiod.UpperChar field and coerce ink entry to uppercase letters. If you extend the mask logic, you may also need to make matching changes to the algorithm used to pre-load current values into the Comb for editing.
  • Validation. The sample form demonstrates only rudimentary validation. The Comb could be extended to provide its own validation methods so that, unlike the Input Panel, the user cannot create invalid data and send it to the target text box.
  • Instantiation. In the sample, the Comb is launched by tapping on the text box (if blank) or by selecting Edit from the text box's shortcut menu. You could extend the InkNumberControl text box to provide users with alternate ways to display the Comb. For example, you could create a floating tool tip icon that mimics the Tablet PC Input Panel icon.

Conclusion

The Tablet PC Input Panel provides an excellent, out-of-the-box, user experience for the entry of words and phrases, but entering unique business data such as part numbers and calibration measurements can prove challenging. The Input Panel also lacks APIs that developers can leverage.

Using the techniques and the code in this article, your custom applications can expose a component that provides ink collection on a per-character basis. You can leverage the Tablet PC SDK to tune this ink collection experience with coercion and validation that fit the needs of your business. Also, you can fully control the component's visual elements to provide your users with a data entry experience that best matches their abilities and work environment.

Biography

Leszynski Group is a solution provider and independent software developer specializing in development tools, Tablet PC applications, and mobilized databases. Leszynski Group is a regular contributor of MSDN content, and the creator of popular utilities such as the Snipping Tool and Physics Illustrator.