C Programming | ncurses part I
Index
- What is ncurses
- The basic recipe
- The event loop
- Drawing content
- Organize the project
- Creating a complete program
- Summing up
Diving into the world of graphical user interfaces is quite a thing, the more one explores about the topic, the more complexity we have and the more tricky it can get until it's mastered. That's why the world uses pre-made libraries to help in the process.
Through all the articles of the bsdworks project we have been creating command line programs, so we are used to work inside a terminal emulator. ncurses(3X)
gives us the possibility to start working in GUIs without leaving the command line as a canvas, in a text based user interface manner (aka TUI).
What is ncurses
curses
is a C library for screen manipulation. It provides us with an API to interact using the C programming language. ncurses(3X)
is the new curses implementation.
The main goal in this guide is to create an interactive program that displays a TUI using ncurses(3X)
where the user can interact.
Before jumping into the code editor, check if the package ncurses
is installed in your system.
$ pkg install ncurses
The basic recipe
Writing programs with ncurses(3X)
is not as complicated as it may seem at a first glance. As a mandatory hello world program for ncurses(3X)
let's take a look at the following code and explain it.
#include <ncurses.h>
int main(int argc, char** argv) {
//message to print
char* msg = "Welcome to bsdworks!";
//variables to store screen size
int rows, cols;
//ncurses(3X) initialization process
();
initscr(stdscr, rows, cols);
getmaxyx();
noecho(0);
curs_set(stdscr, true);
keypad
//termcap checking
if(!has_colors()) {
("your terminal doesn't support colors\n");
printf} else {
();
start_color}
//add a border to the screen
(stdscr, 0, 0);
box
//printing our message into the screen
(rows / 2, (cols - strlen(msg)) / 2, "%s", msg);
mvprintw
//wait for the user to press any key
();
getch
//terminate ncurses(3X)
();
endwinreturn 0;
}
There is a lot to cover in around 20 lines of code. Let's digest the major points, and we'll be discovering the rest in depth while we create our example app with ncurses(3X)
.
- First we initialize the standard screen with
initscr()
. - As our second step, we use the function
getmaxyx()
to get the number of rows and columns that our terminal emulator has. This is required to know the boundaries of our canvas. - Then we set some options for the cursor using
noecho()
andcurs_set(0)
.noecho()
avoids the pressed keys to be displayed in the screen.curs_set(0)
hides the cursor in the screen.
- We also set the keypad to work in the
stdscr
withkeypad(stdscr, true)
.stdscr
is the default window forncurses(3X)
and it's always available. - The conditional that follows checks whether our terminal emulator supports color or not.
has_colors()
returns a boolean based on thetermcap
info from our terminal emulator. - The
box()
function draws a border around the specified window. - Once we have initialized
ncurses(3X)
we can print our message in the screen using the API functionmvprintw()
. - Finally we wait for the user to press any key using
getch()
- Once the user presses any key, the program will follow with the function
endwin()
to ensure a cleanncurses(3X)
exit.
Not that complicated, I told you. Let's compile the program now. For that we need to link the ncurses(3X)
library in the compilation instructions. The command is as follows:
$ clang main.c -lncurses -o test
The event loop
In the hello world example we've seen that after pressing a key, the program finishes. In order to keep ncurses(3X)
alive during the execution of our program, we need to introduce the so called event loop.
The concept of the event loop is to keep the program running and listening to the user's actions until that action means exit the program. A simple while
loop or for
loop does the job.
int main(int argc, char** argv) {
...
int c;
for(;;) {
= getch();
c
switch(c) {
// different actions go here
...
// 27 is ESC key in ascii
case 27:
return;
default:
break;
}
}
();
endwinreturn 0;
...
}
Drawing content
Now that we control the event loop, let's learn how to paint stuff in the terminal emulator using ncurses(3X)
.
Content that we want to display in the program is tied to a window. By default ncurses(3X)
serves the stdscr
to work with, but in order to keep things organized, we are going to create new windows for our content.
Using windows
ncurses(3X)
provides us with a WINDOW
structure to create window pointers in our code. The newwin()
function takes the desired rows and columns to determine the size of the new window.
int main(int argc, char** argv) {
...
int rows, cols;
(stdscr, rows, cols);
getmaxyx*win;
WINDOW
= newwin(cols, rows);
win (win, 0, 0);
box...
}
Printing text
Now we can target our win
component to draw content and call actions in to it, by passing its reference in the API calls. ncurses(3X)
provides us with some functions similar to the C standard to print content inside a window:
printw()
directly prints a string on the current cursor position.wprintw()
prints a string on the desired window.mvprintw()
moves the cursor to a desired position and then prints a string.mvwprintw()
similar tomvprintw
but in this scenario we select the target window to do it.vw_printw()
works likewprintw()
but its last parameter is type ofva_list
(a pointer to a list of arguments).
For most of our work, we are more likely to use mvwprintw()
since it allows us to both select the target window and move the cursor to place the desired text on the position we want.
Using our new window let's place some text in the screen:
int main(int argc, char** argv) {
...
(win, "ncurses bsdworks demo");
mvwprintw(win);
wrefresh...
}
As you may notice, we've introduced a new API function call wrefresh()
after printing our text in the window. This is required to actually see what we did in the program printed in the screen.
ncurses(3X)
doesn't handle window updates automatically, instead it relies in the developer for that task. This gives us complete control on when to use resources to refresh the screen
There are several functions to redraw our screen within ncurses(3X)
:
refresh()
actually redraws the output based on thestdscr
.wrefresh()
does the same asrefresh()
but it takes a target window as a parameter.wnoutrefresh()
is a good option to use when we want to update several windows at once, sincewrefresh()
alternateswnoutrefresh()
anddoupdate()
and the screen may tear.doupdate()
is required after a call townoutrefresh()
to complete the refreshing.redrawwin()
should be used when we find some corrupted lines.wredrawln()
similar toredrawwin()
but it only affects the selected line in a window.
Using attributes
The ncurses(3X)
API provides functions to manipulate the attributes of a window. Attributes are character properties, that are applied as a modification of the original ones.
To make it a bit more graphical, let's say we want to change the background and foreground colors of a text line. With the provided functionality, we only need to activate and deactivate the attribute A_STANDOUT
when we decide, using the following function calls:
wattron()
enables the desired attribute on the target window.wattroff()
disables the desired attribute on the target window.
As an example, we can decorate our welcome message on the program:
...
int main(int argc, char** argv) {
...
(win, A_STANDOUT);
wattron(win, "ncurses bsdworks demo");
mvwprintw(win, A_STANDOUT);
wattroff(win);
wrefresh...
}
Keeping in mind that we are trying to cover the overall process of a complete ncurses(3x)
demo application, we don't need much more functionality on the attributes for now. The man page curs_attr(3X)
lists in detail all the available attributes and API functions for this task.
It's 2022, where are the colors? — Glad you asked. As we've seen in the 101 recipe, ncurses(3X)
provides some functions to get information about our terminal emulator. Depending on which one we use, we may or may not have certain ncurses(3X)
features available. But assuming we have them, using colors in ncurses(3X)
works by defining color-pairs.
ncurses(3X)
colors work in pair, always. It's a foreground color for the text, and a background color for the blank field on which the characters are displayed.
The first thing we need to do after checking that our terminal emulator supports color, is to call the ncurses(3X)
start_color()
routine. After that call, we can modify color pairs calling the function init_pair()
. This last function takes three arguments, being the first one a valid color-pair to modify, and the remaining ones a valid foreground and background color respectively.
...
if (!has_colors()) {
("Your terminal does not support color\n");
printf} else {
();
start_color(1, COLOR_RED, COLOR_BLACK);
init_pair(2, COLOR_GREEN, COLOR_BLACK);
init_pair}
...
Organize the project
In order to keep the progress of the guide and the code tided, let's try to organize our variables and functions a bit.
First of all, we need to create a struct
to encapsulate the application components such as the windows, the rows, and the columns. The best place to store this information is in a header file. Let's name it n_app.h
.
#ifndef N_APP_H
#define N_APP_H
#include <ncurses.h>
typedef struct {
int rows, cols;
*win;
WINDOW } app_t;
extern app_t *app;
#endif //N_APP_H
We will add more content to our app struct
later. For now we can have a function that initializes our app, covering the steps we've been making over the main
function earlier. Over the header file we've just created add:
...
*init_app(); app_t
We can populate the new function over a n_app.c
source file, similar to this:
#include "n_app.h"
*init_app {
app_t //get values from terminal size
int y_max, x_max;
(stdscr, y_max, x_max);
getmaxyx
//allocate app struct memory
*_app = calloc(1, sizeof(app_t));
app_t
//associate values for screen size. To be used in resizing
->rows = y_max;
_app->cols = x_max;
_app
//create the window component
->win = newwin(_app->rows, _app->cols, 0, 0);
_app
return _app;
}
Since we are manually allocating memory for our app structure, we also need to create a function to free that memory once we exit the program. Let's create one too in the source file.
void destroy_app(app_t *app) {
(app->win);
delwin(app);
free}
This way we can modify our main
function to work as follows:
#include "n_app.h"
int main(int argc, char** argv) {
...
= init_app();
app ...
while(1) {
...
}
...
(app);
destroy_appreturn 0;
}
At this point, any resource needed from the app can be extended inside the app structure, and managed internally.
Creating a complete program
We've covered the basic requirements to start working with ncurses(3X)
so far, such as initialize the library, handle an event loop, display content in the screen or refresh the output.
In order to consolidate the concepts, we are going to create a small demo program that draws several windows in the terminal emulator, add interaction between them, and cover some more advanced topics using ncurses(3X)
.
The program's user interface will (hopefully) look like this at the end:
Enough introduction, let's prepare the required file architecture.
We are reusing our n_app.c/.h
files as the core of our program. These source files will include everything related to launching and closing the application, and managing the windows' interaction.
The next thing we need to do is to create a source file for each window, in order to keep the code readable and clean. Since both of the source files are related to each other, it's worth having a common header for shared information.
In this header file (named n_common.h
) we can define information such as the active window, the active context of the program, and the window parameters. For now we only know that we have two interactable windows, so we can add an enum
to keep track which one is active.
#ifndef N_COMMON_H
#define N_COMMON_H
typedef enum {
= 0,
N_MENU
N_ACTION} active_win_e;
#endif // N_COMMON_H
Let's start with the left side window. From now on, this is going to be the menu window. Similar to our n_app
, we need a header and a source file.
Implementing a menu
Since our user interface is text based, we can define a menu item as a struct
that takes a name to display, a trigger that activates it, and a starting position so the program knows where to place it.
#ifndef N_MENU_H
#define N_MENU_H
#include <curses.h>
#include <string.h>
#include "n_common.h"
// we need a menu structure
typedef struct {
char *name;
int start_pos;
char trigger;
} menu_t;
Since the menu item alone isn't useful at all, we need to define a menu component like a menu bar, where all the menu items can be placed. Let's create another struct
for it. Our menu bar can handle which menu is designated to it, the number of menus as well as a pointer to the menus array, and keep track of the selected menu.
...
typedef struct {
*win;
WINDOW int num_menus;
*menus;
menu_t } menu_bar_t;
For the active menu tracking, let's add an enum
over the n_common.h
file:
...
typedef enum {
= 0,
A_FILE ,
A_EDIT
A_ABOUT} ac_ctx_e;
And back into our n_menu.h
file we can implement it into the menu bar struct
:
...
typedef struct {
*win;
WINDOW int num_menus;
*menus;
menu_t ;
ac_ctx_e selected_menu} menu_bar_t;
Having the structures defined, we can move on into creating the functions that initialize the menus and menu bar, and the functions that draw the components.
...
(char *name, char trigger);
menu_t init_menu
* init_menu_bar (WINDOW *win, int num_menus, menu_t *menus);
menu_bar_t
void draw_menu(menu_bar_t *menu_bar);
Our final header implementation looks like this:
#ifndef N_MENU_H
#define N_MENU_H
#include <curses.h>
#include <string.h>
#include "n_common.h"
// we need a menu structure
typedef struct {
char *name;
int start_pos;
char trigger;
} menu_t;
// we need a menu bar to check user input for triggers
typedef struct {
*win;
WINDOW int num_menus;
*menus;
menu_t ;
ac_ctx_e selected_menu} menu_bar_t;
(char *name, char trigger);
menu_t init_menu
* init_menu_bar(WINDOW *win, int num_menus, menu_t *menus);
menu_bar_t
void draw_menu(menu_bar_t *menu_bar);
#endif // N_MENU_H
—With the header ready, now we have to implement the menu window logic inside n_menu.c
. The functions that initialize the menu and the menu bar are pretty much straight forward:
#include <stdlib.h>
#include "n_menu.h"
(char *name, char trigger) {
menu_t init_menu ;
menu_t _menu.name = name;
_menu.trigger = trigger;
_menu
return _menu;
}
* init_menu_bar (WINDOW *win, int num_menus, menu_t *menus) {
menu_bar_t*_menu_bar = NULL;
menu_bar_t = calloc(1, sizeof(menu_bar_t));
_menu_bar
->win = win;
_menu_bar->num_menus = num_menus;
_menu_bar->menus = menus;
_menu_bar
//set active menu by default
->selected_menu = 0;
_menu_bar
int start_pos = 2;
for (int i = 0; i < num_menus; i++) {
->menus[i].start_pos = start_pos;
_menu_bar// this makes the menus on the Y direction to maintain space
+= 1;
start_pos }
return _menu_bar;
}
For the draw menu function, we can make use of the A_STANDOUT
attribute so the user knows which option is selected:
...
void draw_menu(menu_bar_t *menu_bar) {
for (int i = 0; i < menu_bar->num_menus; i++) {
if(menu_bar->selected_menu == i) {
(menu_bar->win, A_STANDOUT);
wattron}
(menu_bar->win, menu_bar->menus[i].start_pos, 1, "%s", menu_bar->menus[i].name);
mvwprintw(menu_bar->win, A_STANDOUT);
wattroff}
}
Implementing an action window
The menu window handles which content is displayed on the action window. Here is where the user can read and write data. Similar to the menu structure, let's start by creating an action window header.
#ifndef N_MENU_H
#define N_ACTION_H
#include <curses.h>
#include <string.h>
#include "n_common.h"
#endif // N_ACTION_H
For now we only need a function for each action window we have. In order to trigger the ability to work inside them we need two parameters:
- The window target, which for now is going to be the same for all of them.
- A boolean to enable action features like enabling the cursor or letting the user write.
void ac_edit(WINDOW *win, int is_action_w);
void ac_file(WINDOW *win, int is_action_w);
void ac_about(WINDOW *win, int is_action_w);
Moving into the implementation of the action window functions, let's focus in this first part on having the content printed on the screen. We'll be covering the user interaction in the next part of the guides.
As you may notice we are repeating some steps that could be unified in a helper function later:
- We create a variable for the y and x size of the window.
- We get the actual window size using
getmaxyx()
and we store the info in the created variables.
The next step (for now) in each action function is to move the cursor around the target window and place some text.
#define LEFT_MARGIN 4
void ac_edit(WINDOW *win, int is_action_w) {
int sz_y, sz_x;
(win, sz_y, sz_x);
getmaxyx
(win, 1, (sz_x - 10) / 2, "EDIT FRAME");
mvwprintw(win, 3, LEFT_MARGIN, "type someting: ______\tand another thing: _____");
mvwprintw(win, 4, LEFT_MARGIN, "more info: ______\tsign here: _____");
mvwprintw
(win);
wrefreshif(is_action_w == N_ACTION){
}
}
void ac_file(WINDOW *win, int is_action_w) {
int sz_y, sz_x;
(win, sz_y, sz_x);
getmaxyx
int l_start_x = 1;
int l_start_y = 3;
(win, 1, (sz_x - 17) / 2, "FILE SELECT FRAME");
mvwprintw(win, 3, LEFT_MARGIN, "this should list files with *.nap");
mvwprintw(win, 4, LEFT_MARGIN, "dummy list TODO");
mvwprintw(win);
wrefreshif(is_action_w == N_ACTION){
}
}
void ac_about(WINDOW *win, int is_action_w) {
int sz_y, sz_x;
(win, sz_y, sz_x);
getmaxyx
(win, 1, (sz_x - 10) / 2, "ABOUT FRAME");
mvwprintw(win, 3, LEFT_MARGIN, "bsdworks example program for ncurses in C");
mvwprintw(win, 4, LEFT_MARGIN, "this is the info window");
mvwprintw(win);
wrefreshif(is_action_w == N_ACTION){
}
}
We refresh the target window using
wrefresh()
after printing the new lines of text to actually see them in the screen.
As you can see, we are leaving an empty if
statement where the user interaction will take place if we have the action window focused.
Composing the main user interface
The next part we need is to combine both the menu window and the action window in the main window of our program. To do so, we can take advantage of our app struct
where we can place the required variables, and in the n_app.c
source file we can implement the necessary functions to load and update the content,as well as controlling the application behavior.
First of all, we need to import the headers from our menu and action windows. Next, we need to take care of some updates into our app_t struct
. We have to store the following:
- Three window pointers, one for the main window, one for the menu window, and another one for the action window.
- A menu bar pointer.
- A menus pointer with the actual menus' content.
- The tracking of the active window with an
enum
. - The main window's size expressed in (y)rows and (x)cols.
...
#include "n_menu.h"
#include "n_action.h"
typedef struct {
*win;
WINDOW *menu_win;
WINDOW *action_win;
WINDOW *menu_bar;
menu_bar_t *menus;
menu_t ;
active_win_e activeint cur_y, cur_x;
} app_t;
extern app_t *app;
*init_app();
app_t
void destroy_app(app_t *app);
Inside the init_app()
function we can update all this new data:
*init_app() {
app_t //get values from terminal size
int y_max, x_max;
(stdscr, y_max, x_max);
getmaxyx
//allocate app struct memory
*_app = calloc(1, sizeof(app_t));
app_t
//associate values for screen size
->cur_y = y_max;
_app->cur_x = x_max;
_app
->win = newwin(_app->cur_y, _app->cur_x, 0, 0);
_app
//define the witdh for each sub window
int menu_w_x = x_max/6;
int action_w_x = x_max - menu_w_x -2;
//create the menu window
->menu_win = newwin(y_max - 4, menu_w_x, 2, 1);
_app
//create the action window
->action_win = newwin(y_max - 4, action_w_x, 2, menu_w_x + 1);
_app
//print decorative bars
//here goes box borders
//init menus
->menus = malloc(3 * sizeof(menu_t));
_app->menus[0] = init_menu("(F)ile", 'f');
_app->menus[1] = init_menu("(E)dit", 'e');
_app->menus[2] = init_menu("(A)bout", 'a');
_app
//set active window by default
->active = N_MENU;
_app
//call the action window content by default
(_app->action_win, _app->active);
ac_file
//refresh the windows
(_app->win);
wnoutrefresh(_app->menu_win);
wnoutrefresh(_app->action_win);
wnoutrefresh();
doupdate
return _app;
}
The most interesting part here is defining a dynamic space for each window. Since we have two “subwindows” inside the main one, we are defining a width constant for each of the “subwindows”. That constant acts as a constrain that can be later updated on resizing, based on the stdscr
.
The menu window and the action window aren't really sub windows.
ncurses(3X)
has some functions to create child windows, but for now, we are not using that functionality.
In order to have the default view loaded before entering the event loop, we set the active window to be the menu, and we manually call the file list action so the action window gets some data to print.
We have left some comments trough the code with parts of the functionality that we still need to create. If we take a look back into the demo screenshot, we can notice we have a header and a footer standing out at the top and bottom of the screen respectively. Most notably each window has a border around, and the active window displays it in a different color.
We could hard code this functionality in the app initialization, but then we'll struggle to repaint all of the features in each update and / or screen resize. Hand over the header file of n_app
and add the following declarations:
void draw_borders(app_t *app);
void print_bars(app_t *app);
Then we can implement them over the source file of n_app
. Let's start with the interactive window borders. Since we actually keep track on which window is active, we can conditionally enable the border color using the attribute functions.
void draw_borders(app_t *app) {
();
clear(app->win, 0, 0);
box
if(app->active == N_MENU) {
(app->action_win, 0, 0);
box(app->menu_win, COLOR_PAIR(1));
wattron(app->menu_win, 0, 0);
box(app->menu_win, COLOR_PAIR(1));
wattroff} else {
(app->menu_win, 0, 0);
box(app->action_win, COLOR_PAIR(1));
wattron(app->action_win, 0, 0);
box(app->action_win, COLOR_PAIR(1));
wattroff}
//refresh the windows
(app->win);
wnoutrefresh(app->menu_win);
wnoutrefresh(app->action_win);
wnoutrefresh();
doupdate}
For the header and the footer lines, we can also take advantage of the attribute functions provided by ncurses(3X)
and make the application stand out a bit. We can use these bars to print some helpful information to the user, such as key functionalities or log information.
void print_bars(app_t *app) {
(app->win, A_REVERSE);
wattrset
// print top and bottom bars
for(int i = 1; i < app->cur_x -1; i++) {
(app->win, 1, i, " ");
mvwprintw(app->win, app->cur_y -2, i, " ");
mvwprintw}
//add content to the top bar
(app->win, 1, 2, "example curses app | ");
mvwprintw(app->win, 1, 20, "size %d, %d", app->win_params.scr_cols, app->win_params.scr_rows);
mvwprintw
//add content to the bottom bar
(app->win, app->win_params.cur_y -2, 2, "F1 - Help | F9 - Quit");
mvwprintw
(app->win, A_REVERSE);
wattroff}
Now we can place them over the comments on the init_app
function:
*init_app() {
app_t ...
//print decorative bars
(_app);
print_bars
//here goes box borders
(_app);
draw_borders...
}
The same we have done with the application initialization, can be done with the ncurses(3X)
specific steps for initialization. We can create a function init_nc()
to encapsulate all the steps we do to initialize the library.
void init_nc() {
();
initscr();
noecho(0);
curs_set(stdscr, TRUE);
keypad
if (!has_colors()) {
("Your terminal does not support color\n");
printf} else {
();
start_color(1, COLOR_RED, COLOR_BLACK);
init_pair(2, COLOR_GREEN, COLOR_BLACK);
init_pair}
}
Join the pieces together
We are almost there! Now it's time to glue the pieces together in the main
function of the program. Over the main
source file we can create an event loop function so all app events go inside that, leaving the main
function clean.
The event_loop
function takes an app_t*
as an argument so we can work inside the loop with our instance application. Another solution to this is making the application instance static.
#include <stdlib.h>
#include "n_app.h"
void event_loop (app_t *app);
The main
function should have the following steps:
- Allocate memory for our application instance.
- Initialize
ncurses(3X)
. - Initialize our application instance.
- Initialize our menu bar, draw the menus.
- Enter the event loop.
- Clean everything before closing the program once exit condition is triggered.
int main( int argc, char **argv ) {
= calloc(1, sizeof(app_t));
app
();
init_ui
= init_app();
app
->menu_bar = init_menu_bar(app->menu_win, 3, app->menus);
app(app->menu_bar);
draw_menu
(app->menu_win);
wrefresh(app->action_win);
wrefresh
(app);
event_loop
(app);
destroy_app();
endwin
return 0;
}
Finally, our latest task in this first part is to implement a simple event loop to start handling the application events. As we mentioned earlier, a simple infinite for
loop is enough to achieve the functionality. Some key features used worth mentioning are the following:
- We are using
wgetch()
to get the input from the desired window. In this case we are targeting the main window of the application instance. - The input we are getting is stored in an integer variable, and the switch operator is given letters in ASCII notation.
- After the switch operator runs, we need to redraw the screen with the updated content, if any.
void event_loop (app_t *app) {
int c;
for (;;) {
= wgetch(app->win);
c
switch(c) {
//113 is q key. This exits the program
case 113:
return;
case KEY_F(9):
return;
//27 is ESC key. Usage of ESC is not recommended, but for now this clears the menus
case 27:
->menu_bar->selected_menu = -1;
appbreak;
//9 or \t is horizontal tab
case 9:
if(app->active == N_MENU) {
->active = N_ACTION;
app} else {
->active = N_MENU;
app}
break;
default:
break;
}
(app);
draw_borders
(app->win);
wnoutrefresh(app->menu_win);
wnoutrefresh(app->action_win);
wnoutrefresh();
doupdate};
}
Summing up
In the next part we'll take on the user interaction, covering how to let the user write content in the screen, and how to store it. We'll also look into loading content from a file and resizing the terminal emulator while using our ncurses(3X)
program.