My first attempt at a Custom .Net WinForms control

My current project has many data entry screens. Some of the fields on the screens need to act like lookup or search fields and if there aren't any matches act like a regular field. I didn't want to litter the form with a bunch of separate search fields complete with search LinkLabels or Buttons.  I started to Google for projects (or products) that would offer the following cababilities:

Did I mention I'm on a tight budget? ;0)

I didn't find anything that matched my needs.  This doesn't mean it doesn't exist, I just did find it before my rabid ADD kicked in and I decided to extend the existing stuff to fit my needs.

What I did

What I need to do

 How It's used

What it looks like




PaintableTextBox class

This is the main class that allows painting on the control. If you just override OnPaint() you had better get ready for HELL! :0) (seriously)

using System;
using System.Drawing;
using System.Windows.Forms;
using System.Runtime.InteropServices;

namespace PMD.Library.WinForms
{
    public class PaintableTextBox : TextBox
    {
        //  MUCH of this api code was borrowed from:
        //  http://www.vbaccelerator.com/home/NET/Code/Controls/ListBox_and_ComboBox/TextBox_Icon/article.asp
        #region UnmanagedMethods
        [StructLayout(LayoutKind.Sequential)]
        private struct RECT
        {
            public int left;
            public int top;
            public int right;
            public int bottom;
        }

        [DllImport("user32", CharSet = CharSet.Auto)]
        private extern static int SendMessage(
            IntPtr hwnd,
            int wMsg,
            int wParam,
            int lParam);

        [DllImport("user32", CharSet = CharSet.Auto)]
        private extern static IntPtr FindWindowEx(
            IntPtr hwndParent,
            IntPtr hwndChildAfter,
            [MarshalAs(UnmanagedType.LPTStr)]
			string lpszClass,
            [MarshalAs(UnmanagedType.LPTStr)]
			string lpszWindow);

        [DllImport("user32", CharSet = CharSet.Auto)]
        private extern static int GetWindowLong(
            IntPtr hWnd,
            int dwStyle);

        [DllImport("user32")]
        private extern static IntPtr GetDC(
            IntPtr hwnd);

        [DllImport("user32")]
        private extern static int ReleaseDC(
            IntPtr hwnd,
            IntPtr hdc);

        [DllImport("user32")]
        private extern static int GetClientRect(
            IntPtr hwnd,
            ref RECT rc);

        [DllImport("user32")]
        private extern static int GetWindowRect(
            IntPtr hwnd,
            ref RECT rc);

        private const int EC_LEFTMARGIN = 0x1;
        private const int EC_RIGHTMARGIN = 0x2;
        private const int EC_USEFONTINFO = 0xFFFF;
        private const int EM_SETMARGINS = 0xD3;
        private const int EM_GETMARGINS = 0xD4;

        private const int WM_PAINT = 0xF;

        private const int WM_SETFOCUS = 0x7;
        private const int WM_KILLFOCUS = 0x8;

        private const int WM_SETFONT = 0x30;

        private const int WM_MOUSEMOVE = 0x200;
        private const int WM_LBUTTONDOWN = 0x201;
        private const int WM_RBUTTONDOWN = 0x204;
        private const int WM_MBUTTONDOWN = 0x207;
        private const int WM_LBUTTONUP = 0x202;
        private const int WM_RBUTTONUP = 0x205;
        private const int WM_MBUTTONUP = 0x208;
        private const int WM_LBUTTONDBLCLK = 0x203;
        private const int WM_RBUTTONDBLCLK = 0x206;
        private const int WM_MBUTTONDBLCLK = 0x209;

        private const int WM_KEYDOWN = 0x0100;
        private const int WM_KEYUP = 0x0101;
        private const int WM_CHAR = 0x0102;


        private const int GWL_EXSTYLE = (-20);
        private const int WS_EX_RIGHT = 0x00001000;
        private const int WS_EX_LEFT = 0x00000000;
        private const int WS_EX_RTLREADING = 0x00002000;
        private const int WS_EX_LTRREADING = 0x00000000;
        private const int WS_EX_LEFTSCROLLBAR = 0x00004000;
        private const int WS_EX_RIGHTSCROLLBAR = 0x00000000;

        #endregion

        /// <summary>
        /// Calls the base WndProc and performs WM_PAINT
        /// processing to draw the icon if necessary.
        /// </summary>
        /// <param name="m">Windows Message</param>
        protected override void WndProc(ref Message m)
        {
            base.WndProc(ref m);

            if (true)
            {
                switch (m.Msg)
                {
                    case WM_SETFONT:
                        //setMargin();
                        break;
                    case WM_PAINT:
                        RePaint();
                        break;
                    case WM_SETFOCUS:
                    case WM_KILLFOCUS:
                        RePaint();
                        break;
                    case WM_LBUTTONDOWN:
                    case WM_RBUTTONDOWN:
                    case WM_MBUTTONDOWN:
                        RePaint();
                        break;
                    case WM_LBUTTONUP:
                    case WM_RBUTTONUP:
                    case WM_MBUTTONUP:
                        RePaint();
                        break;
                    case WM_LBUTTONDBLCLK:
                    case WM_RBUTTONDBLCLK:
                    case WM_MBUTTONDBLCLK:
                        RePaint();
                        break;
                    case WM_KEYDOWN:
                    case WM_CHAR:
                    case WM_KEYUP:
                        RePaint();
                        break;
                    case WM_MOUSEMOVE:
                        if (!m.WParam.Equals(IntPtr.Zero))
                        {
                            RePaint();
                        }
                        break;
                }
            }
            else
            {
                switch (m.Msg)
                {
                    case WM_PAINT:
                        //moveControl();
                        break;
                }
            }
        }

        /// <summary>
        /// Paints the control if necessary:
        /// </summary>
        private void RePaint()
        {
            IntPtr handle = IntPtr.Zero;
            IntPtr hdc = IntPtr.Zero;
            try
            {
                RECT rcClient = new RECT();
                GetClientRect(this.Handle, ref rcClient);

                handle = this.Handle;
                hdc    = GetDC(handle);
                using (Graphics g = Graphics.FromHdc(hdc))
                {
                    OnPaintEx(g);
                }
            }
            finally
            {
                ReleaseDC(handle, hdc);
            }
        }

        protected virtual void OnPaintEx(Graphics g)
        {
            throw new NotImplementedException("You need to override this method");
        }
    }
}

PMDTextBox class

using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Collections;
using System.Runtime.InteropServices;
using System.ComponentModel;
using System.Windows.Forms;
using System.Collections.Generic;

namespace PMD.Library.WinForms
{
    public class PMDTextBox : PaintableTextBox, IPMDLookupTextBox
    {
        #region fields

        private bool       _skipAutoSuggest            = false;
        private string     _lastHint                   = string.Empty;
        private int        _suggestionTriggerThreshold = 0;
        private Cursor     _busyCursor                 = Cursors.WaitCursor;
        private bool       _suggestionsFound           = true;
        private bool       _showAddNewOption           = true;
        private Dictionary<string, ITestSearchResult> _smartSourceList = null;

        //  Painting related fields
        private Point      _iconPosition       = new Point();
        private Rectangle  _iconBounds         = new Rectangle();
        private RectangleF _addNewLinkBounds   = new Rectangle();
        private Font       _addNewFont         = null;
        private bool       _addNewLinkHovering = false;

        #endregion

        public PMDTextBox()
        {
            UpdateIconDrawingStuff();
            _smartSourceList = new Dictionary<string, ITestSearchResult>();
        }

        #region Properties

        [Category("Behavior")]
        [Description("The number of characters entered before the suggestion " +
            "system will request a source list.")]
        [DefaultValue(4)]
        public int SuggestionTriggerThreshold
        {
            get { return _suggestionTriggerThreshold; }
            set { _suggestionTriggerThreshold = value; }
        }

        [Category("Behavior")]
        [Description("The Cursor to display while searching for suggestion results.")]
        public Cursor BusyCursor
        {
          get { return _busyCursor; }
          set { _busyCursor = value; }
        }

        [Category("Behavior")]
        [Description("If true and no matches are found, a link will be " +
            "displayed reading \"Add New\" that will raise the AddItemClick event.")]
        [DefaultValue(true)]
        public bool ShowAddNewOption
        {
            get { return _showAddNewOption; }
            set { _showAddNewOption = value; }
        }

        [Category("Appearance")]
        [Description("The font to use for the Add New link when there are no results.")]
        public Font AddNewFont
        {
            get
            {
                if (_addNewFont == null)
                {
                    return this.Font;
                }
                else
                {
                    return _addNewFont;
                }
            }
            set { _addNewFont = value; }
        }

        #endregion

        #region events
        public delegate void AutoCompleteListNeededHandler(object sender, AutoCompleteListNeededEventArgs args);

        [Category("Action")]
        [Description("Fires when the control needs an AutoCompleteSource.")]
        public event AutoCompleteListNeededHandler AutoCompleteListNeeded = delegate{ };

        [Category("Action")]
        [Description("Fires when user clicks on little icon - just for learning! :0)")]
        public event EventHandler IconClick = delegate { };

        [Category("Action")]
        [Description("Fires when user clicks the Add New link")]
        public event EventHandler AddItemClick = delegate { };

        #endregion

        #region Overrides
        /// <summary>
        /// We need to set a flag if they arrow keys are pressed, they will screw
        /// up the navigation when a user naturally wants to arrow through the
        /// suggestion list.
        /// </summary>
        /// <param name="e"></param>
        protected override void OnPreviewKeyDown(PreviewKeyDownEventArgs e)
        {
            base.OnPreviewKeyDown(e);
            _skipAutoSuggest = (e.KeyCode == Keys.Up || e.KeyCode == Keys.Down);
        }

        int eventIndex = 0;
        protected override void OnKeyUp(KeyEventArgs e)
        {
            base.OnKeyUp(e);
        }

        protected override void OnTextChanged(EventArgs e)
        {
            base.OnTextChanged(e);
            HandleSuggestionRequest();
        }

        private void HandleSuggestionRequest()
        {
            if (_skipAutoSuggest)
            {
                return;
            }

            //  Are we past the threshold?
            if (this.Text.Length >= _suggestionTriggerThreshold)
            {
                //  Is the current hint going to result in a 
                //  different suggestion list?
                if (string.IsNullOrEmpty(_lastHint) == false && this.Text.StartsWith(_lastHint))
                {
                    //  Bail!
                    return;
                }

                _lastHint = this.Text;

                //  Different text, fire the SuggestionSource needed event
                try
                {
                    //  Set the busy cursor
                    this.Cursor = _busyCursor;

                    //  Create the eventArgs that the user will specify the 
                    //  IList in
                    AutoCompleteListNeededEventArgs args =
                        new AutoCompleteListNeededEventArgs(this.Text);

                    //  Fire the event
                    AutoCompleteListNeeded(this, args);
                    _suggestionsFound = false;

                    //  Get the suggestion list from the event args
                    string[] suggestionItems = null;
                    if (args.SourceList != null )
                    {
                        _suggestionsFound = true;
                        suggestionItems = args.SourceList;
                    }

                    //  If there was a smart list supplied, store the reference
                    //  and build the key string[] for the suggestion list
                    if(args.SourceListEx != null)
                    {
                        _suggestionsFound = true;
                        _smartSourceList = args.SourceListEx;
                        suggestionItems = new string[_smartSourceList.Count];
                        _smartSourceList.Keys.CopyTo(suggestionItems, 0);
                    }

                    //  Set the AutoCompletion mode to None before updating the
                    //  source, I'm pretty sure this prevents a Access Violation
                    AutoCompleteMode modeToRestore = this.AutoCompleteMode;
                    this.AutoCompleteMode = AutoCompleteMode.None;
                    this.AutoCompleteCustomSource.Clear();
                    this.AutoCompleteCustomSource.AddRange(suggestionItems);
                    this.AutoCompleteMode = modeToRestore;
                }
                finally
                {
                    //  Restore the cursor
                    this.Cursor = Cursors.Default;
                }
            }
        }
        
        /// <summary>
        /// There are many problems with simply overriding OnPaint for a derived
        /// TextBox.  Google TextBox UserPaint for more info.  After hours of
        /// frustrating struggles and failed attempts I came across this article:
        /// http://www.vbaccelerator.com/home/NET/Code/Controls/ListBox_and_ComboBox/TextBox_Icon/article.asp
        /// 
        /// It holds the SOLUTION!  :0)
        /// Anyway, there is a partial class that handles all the "hack" stuff
        /// so I don't liter this file with it.
        /// The below function is the equivalent to OnPaint() as far as I can
        /// tell.
        /// </summary>
        /// <param name="g">Graphics object created from RePaint, don't dispose, 
        /// RePaint will do it for you.</param>
        protected override void OnPaintEx(Graphics g)
        {
            //  Just for an example, draw a little something that shows
            //  this is a searchable textbox.
            g.FillRectangle(Brushes.LightSteelBlue, _iconBounds);
            g.DrawRectangle(Pens.DarkSlateBlue, _iconBounds);

            if(_showAddNewOption && _suggestionsFound == false)
            {
                // draw the "Add New" link
                g.DrawString("Add New", AddNewFont, Brushes.Blue, _addNewLinkBounds);

                //  If the user is hovering, draw the underline
                if(_addNewLinkHovering)
                {
                    g.DrawLine(Pens.Blue, 
                        _addNewLinkBounds.X, _addNewLinkBounds.Bottom,
                        _addNewLinkBounds.Right, _addNewLinkBounds.Bottom);
                }
            }
        }

        protected override void OnResize(EventArgs e)
        {
            base.OnResize(e);
            UpdateIconDrawingStuff();
        }

        protected override void OnMouseMove(MouseEventArgs e)
        {
            base.OnMouseMove(e);

            //  Change the cursor to point if over the little box
            if (_showAddNewOption)
            {
                if(_suggestionsFound == false && _addNewLinkBounds.Contains(e.Location))
                {
                    Cursor = Cursors.Hand;
                }
                else
                {
                    Cursor = Cursors.IBeam;
                }

                //  Check if the user has the cursor over the link
                if (_addNewLinkBounds.Contains(e.Location))
                {
                    Console.WriteLine("Hovering should be active");
                    _addNewLinkHovering = true;
                }
                else
                {
                    _addNewLinkHovering = false;
                }

                //  Invalidate the hover region
                Invalidate( new Rectangle(
                    (int)_addNewLinkBounds.X,
                    (int)_addNewLinkBounds.Bottom,
                    (int)_addNewLinkBounds.Width + 4, 
                    3));
            }
        }

        protected override void OnMouseClick(MouseEventArgs e)
        {
            base.OnMouseClick(e);

            if(_iconBounds.Contains(e.Location))
            {
                IconClick(this, EventArgs.Empty);
            }

            if(_suggestionsFound == false && _addNewLinkBounds.Contains(e.Location))
            {
                AddItemClick(this, EventArgs.Empty);
            }
        }

        #endregion

        private void UpdateIconDrawingStuff()
        {
            //  Update the drawing structures for the little icon thingy
            Size iconSize = new Size(6, ClientRectangle.Height - 1);
            _iconPosition = new Point(ClientRectangle.Right - iconSize.Width - 1, ClientRectangle.Top);
            _iconBounds = new Rectangle(_iconPosition, iconSize);
            
            //  This is hard coded, if I was a good programmer I would
            //  calculate the size of the string with Graphics.MeasureString()
            //  I'm a bad programmer.
            using(Graphics g = Graphics.FromHwnd(this.Handle))
            {
                SizeF sz = g.MeasureString("Add New", AddNewFont);
                _addNewLinkBounds = new RectangleF(_iconPosition.X - sz.Width - 2,
                    (ClientRectangle.Height - sz.Height) / 2, sz.Width, sz.Height);
            }
        }

        #region IPMDLookupTextBox Members

        public ITestSearchResult GetSelectedDataItem()
        {
            ITestSearchResult data;// = (ITestSearchResult)new object();
            if(_smartSourceList.TryGetValue(this.Text, out data))
            {
                return data as ITestSearchResult;
            }

            return null;
        }

        #endregion
    }
}

AutoCompleteListNeededEventArgs

EventArgs class that is used to notify that suggestion data is needed. Also responsible for obtaining the suggestion data from the handler.

This is an experimental version that is using an interface for the "smart data" (I don't know what to call it) - I would like to come up with something using generics that would allow me to pass the ACTUAL object that I want rather than wrapping it in this interface. Suggestions welcome.

using System;
using System.Collections.Generic;

namespace PMD.Library.WinForms
{
    public class AutoCompleteListNeededEventArgs : EventArgs
    {
        private string[] _sourceList = null;
        private Dictionary<string, ITestSearchResult> _sourceListEx = null;
        private string _hint = string.Empty;

        internal AutoCompleteListNeededEventArgs(string hint)
        {
            _hint = hint;
        }

        public string[] SourceList
        {
            get { return _sourceList; }
            set { _sourceList = value; }
        }

        public Dictionary<string, ITestSearchResult> SourceListEx
        {
            get { return _sourceListEx; }
            set { _sourceListEx = value; }
        }

        public string Hint
        {
            get { return _hint; }
        }
    }
}