« Back to tutorial listing

— Creating a basic widget system —
Part 6: Text input widget

Note: this tutorial assumes knowledge of C, as well as prior tutorials.

Introduction

Another widget that comes in handy from time to time is a text input field. Typically, this is used for entering the name of a player, a server address, and so on. We'll create a very simple one here, that allows for the user to enter the name of the player.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./widgets06 to run the code. You will see a window open like the one above. Use the Up and Down arrows on you keyboard to change the highlighted menu option. Select the Name widget and press Return or Space to start editing the text. When you're finished, either press Escape or Return. When you're done, either select Exit or close the window.

Inspecting the code

As usual, we're defining our input widget in JSON. There won't be too many surprises here:

{
	"type" : "WT_INPUT",
	"name" : "name",
	"x" : 0,
	"y" : 400,
	"label" : "Name",
	"maxLength" : 16
}

Our input widget has one custom field, called maxLength. This will be used to limit the number of characters that the widget can hold. Next, let's look at the update we've made to defs.h:


enum {
	WT_BUTTON,
	WT_SELECT,
	WT_SLIDER,
	WT_INPUT
};

As always, we need to add the enum value for our new widget. WT_INPUT will be used to define the type of our input widget. We'll also need to update structs.h to create our new widget type:


typedef struct {
	int x;
	int y;
	int maxLength;
	char *text;
} InputWidget;

Our InputWidget supports x and y fields for positioning, and also has the maxLength field we saw in the JSON. The text itself is a char array pointer. We'll be setting this up when we come to create the widget. One other change we've made in structs.h is to add in a field called inputText to the App struct:


typedef struct {
	struct {
		void (*logic)(void);
		void (*draw)(void);
	} delegate;
	SDL_Renderer *renderer;
	SDL_Window *window;
	int keyboard[MAX_KEYBOARD_KEYS];
	double deltaTime;
	Widget *activeWidget;
	char inputText[MAX_INPUT_LENGTH];
	struct {
		int fps;
	} dev;
} App;

inputText is a rather large char array to hold the input text that has been captured during the SDL input processing (we'll cover this at the end of the tutorial).

That's out defs.h and structs.h updated, so now we can look at the changes made to widgets.c, starting with initWidgets:


void initWidgets(void)
{
	memset(&widgetHead, 0, sizeof(Widget));
	widgetTail = &widgetHead;

	loadWidgets("data/widgets/options.json");

	sliderDelay = 0;

	cursorBlink = 0;

	handleInputWidget = 0;
}

We've added two new static variables, to help handle our input state: cursorBlink and handleInputWidget. cursorBlink will be used to show a blinking cursor when a user is entering text into the input widget, while handleInputWidget will be used as a flag to say that we wish our logic to focus exclusively on working with an input widget. We'll see these in action in our logic and rendering phases.

As with the other widgets, we need to expand our createWidget function, to handle the new type:


static void createWidget(cJSON *root)
{
	Widget *w;
	int type;

	type = getWidgetType(cJSON_GetObjectItem(root, "type")->valuestring);

	if (type != -1)
	{
		w = malloc(sizeof(Widget));
		memset(w, 0, sizeof(Widget));
		widgetTail->next = w;
		w->prev = widgetTail;
		widgetTail = w;

		STRCPY(w->name, cJSON_GetObjectItem(root, "name")->valuestring);
		STRCPY(w->label, cJSON_GetObjectItem(root, "label")->valuestring);
		w->type = getWidgetType(cJSON_GetObjectItem(root, "type")->valuestring);
		w->x = cJSON_GetObjectItem(root, "x")->valueint;
		w->y = cJSON_GetObjectItem(root, "y")->valueint;

		switch (w->type)
		{
			case WT_BUTTON:
				createButtonWidget(w, root);
				break;

			case WT_SELECT:
				createSelectWidget(w, root);
				break;

			case WT_SLIDER:
				createSliderWidget(w, root);
				break;

			case WT_INPUT:
				createInputWidget(w, root);
				break;

			default:
				break;
		}
	}
}

Now, when we encounter a WT_INPUT type during loading, we'll call a new function - createInputWidget. createInputWidget is an easy function to understand:


static void createInputWidget(Widget *w, cJSON *root)
{
	InputWidget *i;

	i = malloc(sizeof(InputWidget));
	memset(i, 0, sizeof(InputWidget));
	w->data = i;

	i->maxLength = cJSON_GetObjectItem(root, "maxLength")->valueint;
	i->text = malloc(i->maxLength + 1);

	calcTextDimensions(w->label, &w->w, &w->h);
}

We'll malloc an InputWidget and assign it to the main widget's data field. For the InputWidget itself, we'll grab the maxLength from the JSON object, and assign it to the InputWidget's maxLength field. Here is also where we'll malloc our text field for the InputWidget. Will allocate a number of bytes equal to the maxLength + 1, to accomodate the null terminator. With that done, we'll make the usual call to calcTextDimensions for the main widget's size.

Now, let's look at the changes to our logic. doWidgets has seen a few tweaks here and there:


void doWidgets(void)
{
	sliderDelay = MAX(sliderDelay - app.deltaTime, 0);

	cursorBlink += app.deltaTime;

	if (!handleInputWidget)
	{
		if (app.keyboard[SDL_SCANCODE_UP])
		{
			app.keyboard[SDL_SCANCODE_UP] = 0;

			app.activeWidget = app.activeWidget->prev;

			if (app.activeWidget == &widgetHead)
			{
				app.activeWidget = widgetTail;
			}
		}

		if (app.keyboard[SDL_SCANCODE_DOWN])
		{
			app.keyboard[SDL_SCANCODE_DOWN] = 0;

			app.activeWidget = app.activeWidget->next;

			if (app.activeWidget == NULL)
			{
				app.activeWidget = widgetHead.next;
			}
		}

		if (app.keyboard[SDL_SCANCODE_LEFT])
		{
			changeWidgetValue(-1);
		}

		if (app.keyboard[SDL_SCANCODE_RIGHT])
		{
			changeWidgetValue(1);
		}

		if (app.keyboard[SDL_SCANCODE_SPACE] || app.keyboard[SDL_SCANCODE_RETURN])
		{
			app.keyboard[SDL_SCANCODE_SPACE] = app.keyboard[SDL_SCANCODE_RETURN] = 0;

			if (app.activeWidget->type == WT_INPUT)
			{
				cursorBlink = 0;

				handleInputWidget = 1;

				memset(app.inputText, 0, sizeof(app.inputText));
			}
			else if (app.activeWidget->action != NULL)
			{
				app.activeWidget->action();
			}
		}
	}
	else
	{
		doInputWidget();
	}
}

The first thing we're doing is incrementing cursorBlink by app.deltaTime. Next, we're checking to see if our handleInputWidget is set. If not, we'll process our widgets as normal. When it comes to checking if the we've pressed Space or Return, we'll check if the active widget is a text input widget (WT_INPUT). If so, we'll reset the cursor blink to 0 (for aesthetic reasons), and also set handleInputWidget to 1. Setting this to 1 will put our doWidgets into a state where we are only interested in handling the text input widget. We'll also clear app.inputText, so that any text that has been captured so far is disposed of. Not doing so could mean that our input widget would immediately receive unwanted text.

doInputWidget will be called whenever handleInputWidget is set to 1, and we call doWidgets. doInputWidget appears to have a lot going on, but as we go through it, we'll see it's not all that complex.


static void doInputWidget(void)
{
	InputWidget *iw;
	int i, l, n;

	iw = (InputWidget*) app.activeWidget->data;

	l = strlen(iw->text);
	n = strlen(app.inputText);

	if (n + l > iw->maxLength)
	{
		n = iw->maxLength - l;
	}

	for (i = 0 ; i < n ; i++)
	{
		iw->text[l++] = app.inputText[i];
	}

	memset(app.inputText, 0, sizeof(app.inputText));

	if (l > 0 && app.keyboard[SDL_SCANCODE_BACKSPACE])
	{
		iw->text[--l] = '\0';

		app.keyboard[SDL_SCANCODE_BACKSPACE] = 0;
	}

	if (app.keyboard[SDL_SCANCODE_RETURN] || app.keyboard[SDL_SCANCODE_ESCAPE])
	{
		app.keyboard[SDL_SCANCODE_RETURN] = app.keyboard[SDL_SCANCODE_ESCAPE] = 0;

		handleInputWidget = 0;

		if (app.activeWidget->action != NULL)
		{
			app.activeWidget->action();
		}
	}
}

First, we extract the InputWidget from the widget's data field, then copy the InputWidget's text into a temporary string. We've made the temporary buffer rather large, to accomodate the text. We'll also measure the length of the InputWidget's current text, as well as the length of app.indexText. So that we don't overflow the InputText's text buffer, we'll only copy in the number of characters that the text buffer can hold (assigning to a variable called n). With that done, we'll copy each new character into the InputWidget's text field from app.indexText.

Now that the characters have been appended from app.inputText, we'll clear it using memset, to ensure it is cleared. We then need to find out if the user has pressed Backspace, to delete a character. We test if the InputWidget has any text in its buffer (l > 0) and if Backspace has been pressed, and then remove the last character in the InputWidget's text field by setting it to a NULL terminator. We'll also clear the backspace key, so that all the text isn't removed at once.

Finally, we need to give the user the ability to finish editing. We'll test if Return or Escape has been pressed, and then set handleInputWidget to 0, to tell the widget logic it is free to handle other widgets once more. If we've set an action function on the widget, we'll invoke it at this point.

That's our text handling logic do. We can now look at how the InputWidget is rendered. It's quite simple, as you'll see:


static void drawInputWidget(Widget *w)
{
	SDL_Color c;
	InputWidget *i;
	int width, height;

	i = (InputWidget*) w->data;

	if (w == app.activeWidget)
	{
		c.g = 255;
		c.r = c.b = 0;
	}
	else
	{
		c.r = c.g = c.b = 255;
	}

	drawText(w->label, w->x, w->y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

	drawText(i->text, i->x, i->y, c.r, c.g, c.b, TEXT_ALIGN_LEFT, 0);

	if (handleInputWidget && app.activeWidget == w && ((int)cursorBlink % (int)FPS < (FPS / 2)))
	{
		calcTextDimensions(i->text, &width, &height);

		drawRect(i->x + width + 4, i->y + 14, 32, 32, 0, 255, 0, 255);
	}
}

Like the other widgets, we're rendering in green if the widget is active or white otherwise. We're also drawing the widget's label and the InputWidget's current text. We're only doing one further thing, and that is to draw a block cursor at the end of the text string when the widget is being edited. We do this by first testing if handleInputWidget is set to 1, the widget in question is the current widget, and if the modulo of our cursorBlink variable is less than half FPS (in other words, it will blink one and off every half a second). With these conditions met, we'll call calcTextDimensions on the InputWidget's text, to find out its length (we can disregard the height), and draw a filled green rectangle to the right of the text. This isn't an exact science, as you can see due to the tweaks; fonts can have inherent padding that needs to be accomodated for, hence the +4 and + 14 in our case.

Our logic and rendering for our InputWidget is done, so we can now turn to demo.c and make use of the widget:


void initDemo(void)
{
	Widget *w;
	SliderWidget *s;
	InputWidget *i;

	w = getWidget("difficulty");
	w->action = difficulty;
	w->x = 400;
	((SelectWidget*) w->data)->x = 700;

	app.activeWidget = w;

	w = getWidget("subtitles");
	w->action = subtitles;
	w->x = 400;
	((SelectWidget*) w->data)->x = 700;

	w = getWidget("language");
	w->action = language;
	w->x = 400;
	((SelectWidget*) w->data)->x = 700;

	w = getWidget("sound");
	w->action = sound;
	w->x = 400;
	s = (SliderWidget*) w->data;
	s->x = 700;
	s->y = w->y + 16;
	s->w = 300;
	s->h = 32;
	s->value = 100;

	w = getWidget("music");
	w->action = music;
	w->x = 400;
	s = (SliderWidget*) w->data;
	s->x = 700;
	s->y = w->y + 16;
	s->w = 300;
	s->h = 32;
	s->value = 50;

	w = getWidget("name");
	w->action = name;
	w->x = 400;
	i = (InputWidget*) w->data;
	i->x = 700;
	i->y = w->y;
	STRNCPY(i->text, "Player", i->maxLength);

	w = getWidget("exit");
	w->x = 400;
	w->action = quit;

	STRCPY(message, "Adjust options.");

	app.delegate.logic = &logic;
	app.delegate.draw = &draw;
}

Our InputWidget is called "name", so we're looking it up with getWidget. We're assigning its action and render coordinates, and also defaulting the text value of the InputWidget to "Player", using STRNCPY.

The InputWidget's function pointer is called "name", and works much like the other demo function pointers:


static void name(void)
{
	InputWidget *i;

	i = (InputWidget*) app.activeWidget->data;

	sprintf(message, "Player name changed to '%s'", i->text);
}

All we're doing is extracting the InputWidget and setting the message to reflect what the text of the InputWidget was set to.

Before we finish, let's turn to input.c, to see how we're capturing the all-important user input:


void doInput(void)
{
	SDL_Event event;

	while (SDL_PollEvent(&event))
	{
		switch (event.type)
		{
			case SDL_QUIT:
				exit(0);
				break;

			case SDL_KEYDOWN:
				doKeyDown(&event.key);
				break;

			case SDL_KEYUP:
				doKeyUp(&event.key);
				break;

			case SDL_TEXTINPUT:
				STRNCPY(app.inputText, event.text.text, MAX_INPUT_LENGTH);
				break;

			default:
				break;
		}
	}
}

SDL2 provides a nice event called SDL_TEXTINPUT that contains the text that was recently typed. It can be found in a variable called text.text in the event object. All we need do is copy the text in this buffer into our own. We'll use our STRNCPY macro to only copy a limited number of characters. event.text.text is usually only 1 or 2 characters, while MAX_INPUT_LENGTH is 16 characters wide, which should be enough to hold the text. Still, we want to make sure we don't overrun the buffer by limiting the number of characters copied.

We have one more widget that we'll touch on in this tutorial - one to support configurable controls. We'll see how we can create such a widget next.

Purchase

The source code for all parts of this tutorial (including assets) is available for purchase:

From itch.io

It is also available as part of the SDL2 tutorial bundle:

Desktop site