Adding inputbox-like control to XNA game

Having written such code a few times, I'd say it's not that difficult to program a basic textbox in XNA. You define a rectangle that you fill with a background color, a string that represents what the user has typed, and display the string using Spritebatch.DrawString() inside the rectangle ! Using SpriteFont.MeasureString(), you can align the text however you want, wrap text to the next line when it's off-limits, etc.

Then you look at Keyboard.GetState() each update and check which keys have been pressed. That's maybe the biggest problem, because if the user types fast, you'll miss some keystrokes - the game only updates so many times per second. The problem is widely documented on the internet, and has solutions, for example here.

Another option would be to use a pre-made XNA GUI component, such as what you get with the Nuclex framework.


Ah, the text input - I have very recent experience with this.

Problem

Usually, Keyboard.GetKeyboardState() sucks at getting text input, and that is for many reasons, some of them being:

  • You have to code a HUGE switch to detect what key had been pressed
  • You have to manually detect whether to capitalize letters (Shift or CapsLock)
  • You have to decipher those OemPeriod-like keys (as in test) to see where they actually are, and map them to specific values.
  • There is no way to detect/use keyboard layout or keyboard language
  • You have to implement own mechanism for timing repetition in case of key being held down

Second part of the problem is detecting which of your TextBoxes (or UI controls in general) is currently receiving this input, since you don't want all of your boxes to receive text as you type.

Third, you need to draw the TextBox in specified bounds, and you could also want to draw the caret (the blinking vertical position indicator), the current selection (if you want to go so far to implement it), the texture that represents the box, and the textures for highlighted (with mouse) or selected (has focus) state.

Fourth, you have to manually implement copy-paste features.


Quick note

You probably don't need all these features, as I didn't need them. You'd just want simple input, and detection for keys such as enter or tab, as well as mouse click. Maybe also paste.

Solution

The thing is (at least when we talk about Windows, not X-Box or WP7), the operating system already has the mechanisms necessary to implement everything you need from your keyboard:

  • Gives characters based on current keyboard layout and language
  • Automatically handles repeating input (in case of key being held down)
  • Automatically capitalizes and provides special characters

Solution I use for getting keyboard input, I've copied off this Gamedev.net forum post. It is the code below, and you just need to copy-paste it into a .cs file which you'll never have to open again.

It is used for receiving localized input from your keyboard, and all you need to do is initialize it in your Game.Initialize() override method (by using Game.Window), and hook up to the events to receive input anywhere you'd like.

You need to add PresentationCore (PresentationCore.dll) to your references in order to use this code (needed for System.Windows.Input namespace). This works for .NET 4.0 and for .NET 4.0 Client Profile.

EventInput

using System;
using System.Runtime.InteropServices;   
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using System.Text;
using System.Windows.Input;

namespace EventInput
{

    public class KeyboardLayout
    {
        const uint KLF_ACTIVATE = 1; //activate the layout
        const int KL_NAMELENGTH = 9; // length of the keyboard buffer
        const string LANG_EN_US = "00000409";
        const string LANG_HE_IL = "0001101A";

        [DllImport("user32.dll")]
        private static extern long LoadKeyboardLayout(
              string pwszKLID,  // input locale identifier
              uint Flags       // input locale identifier options
              );

        [DllImport("user32.dll")]
        private static extern long GetKeyboardLayoutName(
              System.Text.StringBuilder pwszKLID  //[out] string that receives the name of the locale identifier
              );

        public static string getName()
        {
            System.Text.StringBuilder name = new System.Text.StringBuilder(KL_NAMELENGTH);
            GetKeyboardLayoutName(name);
            return name.ToString();
        }
    }

    public class CharacterEventArgs : EventArgs
    {
        private readonly char character;
        private readonly int lParam;

        public CharacterEventArgs(char character, int lParam)
        {
            this.character = character;
            this.lParam = lParam;
        }

        public char Character
        {
            get { return character; }
        }

        public int Param
        {
            get { return lParam; }
        }

        public int RepeatCount
        {
            get { return lParam & 0xffff; }
        }

        public bool ExtendedKey
        {
            get { return (lParam & (1 << 24)) > 0; }
        }

        public bool AltPressed
        {
            get { return (lParam & (1 << 29)) > 0; }
        }

        public bool PreviousState
        {
            get { return (lParam & (1 << 30)) > 0; }
        }

        public bool TransitionState
        {
            get { return (lParam & (1 << 31)) > 0; }
        }
    }

    public class KeyEventArgs : EventArgs
    {
        private Keys keyCode;

        public KeyEventArgs(Keys keyCode)
        {
            this.keyCode = keyCode;
        }

        public Keys KeyCode
        {
            get { return keyCode; }
        }
    }

    public delegate void CharEnteredHandler(object sender, CharacterEventArgs e);
    public delegate void KeyEventHandler(object sender, KeyEventArgs e);

    public static class EventInput
    {
        /// <summary>
        /// Event raised when a character has been entered.
        /// </summary>
        public static event CharEnteredHandler CharEntered;

        /// <summary>
        /// Event raised when a key has been pressed down. May fire multiple times due to keyboard repeat.
        /// </summary>
        public static event KeyEventHandler KeyDown;

        /// <summary>
        /// Event raised when a key has been released.
        /// </summary>
        public static event KeyEventHandler KeyUp;

        delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);

        static bool initialized;
        static IntPtr prevWndProc;
        static WndProc hookProcDelegate;
        static IntPtr hIMC;

        //various Win32 constants that we need
        const int GWL_WNDPROC = -4;
        const int WM_KEYDOWN = 0x100;
        const int WM_KEYUP = 0x101;
        const int WM_CHAR = 0x102;
        const int WM_IME_SETCONTEXT = 0x0281;
        const int WM_INPUTLANGCHANGE = 0x51;
        const int WM_GETDLGCODE = 0x87;
        const int WM_IME_COMPOSITION = 0x10f;
        const int DLGC_WANTALLKEYS = 4;

        //Win32 functions that we're using
        [DllImport("Imm32.dll", CharSet = CharSet.Unicode)]
        static extern IntPtr ImmGetContext(IntPtr hWnd);

        [DllImport("Imm32.dll", CharSet = CharSet.Unicode)]
        static extern IntPtr ImmAssociateContext(IntPtr hWnd, IntPtr hIMC);

        [DllImport("user32.dll", CharSet = CharSet.Unicode)]
        static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

        [DllImport("user32.dll", CharSet = CharSet.Unicode)]
        static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);


        /// <summary>
        /// Initialize the TextInput with the given GameWindow.
        /// </summary>
        /// <param name="window">The XNA window to which text input should be linked.</param>
        public static void Initialize(GameWindow window)
        {
            if (initialized)
                throw new InvalidOperationException("TextInput.Initialize can only be called once!");

            hookProcDelegate = new WndProc(HookProc);
            prevWndProc = (IntPtr)SetWindowLong(window.Handle, GWL_WNDPROC,
                (int)Marshal.GetFunctionPointerForDelegate(hookProcDelegate));

            hIMC = ImmGetContext(window.Handle);
            initialized = true;
        }

        static IntPtr HookProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
        {
            IntPtr returnCode = CallWindowProc(prevWndProc, hWnd, msg, wParam, lParam);

            switch (msg)
            {
                case WM_GETDLGCODE:
                    returnCode = (IntPtr)(returnCode.ToInt32() | DLGC_WANTALLKEYS);
                    break;

                case WM_KEYDOWN:
                    if (KeyDown != null)
                        KeyDown(null, new KeyEventArgs((Keys)wParam));
                    break;

                case WM_KEYUP:
                    if (KeyUp != null)
                        KeyUp(null, new KeyEventArgs((Keys)wParam));
                    break;

                case WM_CHAR:
                    if (CharEntered != null)
                        CharEntered(null, new CharacterEventArgs((char)wParam, lParam.ToInt32()));
                    break;

                case WM_IME_SETCONTEXT:
                    if (wParam.ToInt32() == 1)
                        ImmAssociateContext(hWnd, hIMC);
                    break;

                case WM_INPUTLANGCHANGE:
                    ImmAssociateContext(hWnd, hIMC);
                    returnCode = (IntPtr)1;
                    break;
            }

            return returnCode;
        }
    }
}

Now you could already use this as it is (by subscribing to EventInput.CharEntered event), and use logic to detect where to send your input.


KeyboardDispatcher, IKeyboardSubscriber

What I did was create a class KeyboardDispatcher, which handles the dispatching of keyboard input by way of having a property of type IKeyboardSubscriber to which it sends received input. The idea is that you set this property to that UI control that you want to receive input.

Definitions are as follows:

public interface IKeyboardSubscriber
{
    void RecieveTextInput(char inputChar);
    void RecieveTextInput(string text);
    void RecieveCommandInput(char command);
    void RecieveSpecialInput(Keys key);

    bool Selected { get; set; } //or Focused
}

public class KeyboardDispatcher
{
    public KeyboardDispatcher(GameWindow window)
    {
        EventInput.EventInput.Initialize(window);
        EventInput.EventInput.CharEntered += new EventInput.CharEnteredHandler(EventInput_CharEntered);
        EventInput.EventInput.KeyDown += new EventInput.KeyEventHandler(EventInput_KeyDown);
    }

    void EventInput_KeyDown(object sender, EventInput.KeyEventArgs e)
    {
        if (_subscriber == null)
            return;

        _subscriber.RecieveSpecialInput(e.KeyCode);
    }

    void EventInput_CharEntered(object sender, EventInput.CharacterEventArgs e)
    {
        if (_subscriber == null)
            return;
        if (char.IsControl(e.Character))
        {
            //ctrl-v
            if (e.Character == 0x16)
            {
                //XNA runs in Multiple Thread Apartment state, which cannot recieve clipboard
                Thread thread = new Thread(PasteThread);
                thread.SetApartmentState(ApartmentState.STA);
                thread.Start();
                thread.Join();
                _subscriber.RecieveTextInput(_pasteResult);
            }
            else
            {
                _subscriber.RecieveCommandInput(e.Character);
            }
        }
        else
        {
            _subscriber.RecieveTextInput(e.Character);
        }
    }

    IKeyboardSubscriber _subscriber;
    public IKeyboardSubscriber Subscriber
    {
        get { return _subscriber; }
        set
        {
            if (_subscriber != null)
                _subscriber.Selected = false;
            _subscriber = value;
            if(value!=null)
                value.Selected = true;
        }
    }

    //Thread has to be in Single Thread Apartment state in order to receive clipboard
    string _pasteResult = "";
    [STAThread]
    void PasteThread()
    {
        if (Clipboard.ContainsText())
        {
            _pasteResult = Clipboard.GetText();
        }
        else
        {
            _pasteResult = "";
        }
    }
}

Usage is fairly simple, instantiate KeyboardDispatcher, i.e. in Game.Initialize() and keep a reference to it (so you can switch between selected [focused] controls), and pass it a class that uses the IKeyboardSubscriber interface, such as your TextBox.


TextBox

Next up is your actual control. Now I've originally programed a fairly complicated box that used render targets to render the text to a texture so I could move it around (if text was larger than the box), but then after a lot of pain i scrapped it and made a really simple version. Feel free to improve it!

public delegate void TextBoxEvent(TextBox sender);

public class TextBox : IKeyboardSubscriber
{
    Texture2D _textBoxTexture;
    Texture2D _caretTexture;

    SpriteFont _font;

    public int X { get; set; }
    public int Y { get; set; }
    public int Width { get; set; }
    public int Height { get; private set; }

    public bool Highlighted { get; set; }

    public bool PasswordBox { get; set; }

    public event TextBoxEvent Clicked;

    string _text = "";
    public String Text
    {
        get
        {
            return _text;
        }
        set
        {
            _text = value;
            if (_text == null)
                _text = "";

            if (_text != "")
            {
                //if you attempt to display a character that is not in your font
                //you will get an exception, so we filter the characters
                //remove the filtering if you're using a default character in your spritefont
                String filtered = "";
                foreach (char c in value)
                {
                    if (_font.Characters.Contains(c))
                        filtered += c;
                }

                _text = filtered;

                while (_font.MeasureString(_text).X > Width)
                {
                    //to ensure that text cannot be larger than the box
                    _text = _text.Substring(0, _text.Length - 1);
                }
            }
        }
    }

    public TextBox(Texture2D textBoxTexture, Texture2D caretTexture, SpriteFont font)
    {
        _textBoxTexture = textBoxTexture;
        _caretTexture = caretTexture;
        _font = font;           

        _previousMouse = Mouse.GetState();
    }

    MouseState _previousMouse;
    public void Update(GameTime gameTime)
    {
        MouseState mouse = Mouse.GetState();
        Point mousePoint = new Point(mouse.X, mouse.Y);

        Rectangle position = new Rectangle(X, Y, Width, Height);
        if (position.Contains(mousePoint))
        {
            Highlighted = true;
            if (_previousMouse.LeftButton == ButtonState.Released && mouse.LeftButton == ButtonState.Pressed)
            {
                if (Clicked != null)
                    Clicked(this);
            }
        }
        else
        {
            Highlighted = false;
        }
    }

    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        bool caretVisible = true;

        if ((gameTime.TotalGameTime.TotalMilliseconds % 1000) < 500)
            caretVisible = false;
        else
            caretVisible = true;

        String toDraw = Text;

        if (PasswordBox)
        {
            toDraw = "";
            for (int i = 0; i < Text.Length; i++)
                toDraw += (char) 0x2022; //bullet character (make sure you include it in the font!!!!)
        } 

        //my texture was split vertically in 2 parts, upper was unhighlighted, lower was highlighted version of the box
        spriteBatch.Draw(_textBoxTexture, new Rectangle(X, Y, Width, Height), new Rectangle(0, Highlighted ? (_textBoxTexture.Height / 2) : 0, _textBoxTexture.Width, _textBoxTexture.Height / 2), Color.White);



        Vector2 size = _font.MeasureString(toDraw);

        if (caretVisible && Selected)
            spriteBatch.Draw(_caretTexture, new Vector2(X + (int)size.X + 2, Y + 2), Color.White); //my caret texture was a simple vertical line, 4 pixels smaller than font size.Y

        //shadow first, then the actual text
        spriteBatch.DrawString(_font, toDraw, new Vector2(X, Y) + Vector2.One, Color.Black);
        spriteBatch.DrawString(_font, toDraw, new Vector2(X, Y), Color.White);
    }


    public void RecieveTextInput(char inputChar)
    {
        Text = Text + inputChar;
    }
    public void RecieveTextInput(string text)
    {
        Text = Text + text;
    }
    public void RecieveCommandInput(char command)
    {
        switch (command)
        {
            case '\b': //backspace
                if (Text.Length > 0)
                    Text = Text.Substring(0, Text.Length - 1);
                break;
            case '\r': //return
                if (OnEnterPressed != null)
                    OnEnterPressed(this);
                break;
            case '\t': //tab
                if (OnTabPressed != null)
                    OnTabPressed(this);
                break;
            default:
                break;
        }
    }
    public void RecieveSpecialInput(Keys key)
    {

    }

    public event TextBoxEvent OnEnterPressed;
    public event TextBoxEvent OnTabPressed;

    public bool Selected
    {
        get;
        set;
    }
}

When you instantiate a TextBox, don't forget to set X, Y, and Width (!!!) values on the instance (Height is auto-set by font).

The texture I used for the box was TextBoxTexture (unhighlighted has a gradient, which looks nice on a black background :) )

To display the box call the .Draw() method on the instance (in your Game.Draw() method), with spritebatch already started (SpriteBatch.Begin() called!!!). For each box you're displaying, if you want it to recieve mouse input you should call .Update() method.

When you want a specific instance to recieve keyboard input, use your KeyboardDispatcher instance to subscribe it, such as:

_keyboardDispatcher.Subscriber = _usernameTextBox;

You can use the Click, Tab and Enter events on the textbox to switch subscribers (which I recommend as it gives a really nice feel to the UI when you can tab through it, and click to select).


Unresolved issues

Ofc, I had talked about some features I had not implemented, such as the box being able to pan the text if the text was wider than the box, the ability to move the caret around (insert the text, not just append), to select and copy text, etc.

These problems you could solve with a light to medium effort, I'm sure of it, but before you do, ask yourself:

Do I really need it?

Tags:

C#

Xna