C Programming | ncurses part I

FreeBSD gearing-up

Index


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();
    getmaxyx(stdscr, rows, cols);
    noecho();
    curs_set(0);
    keypad(stdscr, true);

    //termcap checking
    if(!has_colors()) {
        printf("your terminal doesn't support colors\n");
    } else {
        start_color();
    }

    //add a border to the screen
    box(stdscr, 0, 0);

    //printing our message into the screen
    mvprintw(rows / 2, (cols - strlen(msg)) / 2, "%s", msg);

    //wait for the user to press any key
    getch();

    //terminate ncurses(3X)
    endwin();
    return 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).

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(;;) {
        c = getch();

        switch(c) {
            // different actions go here
            ...
            // 27 is ESC key in ascii
            case 27:
                return;
            default:
                break;
        }
    }
    endwin();
    return 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;
    getmaxyx(stdscr, rows, cols);
    WINDOW *win;

    win = newwin(cols, rows);
    box(win, 0, 0);
    ...
}

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:

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) {
    ...
    mvwprintw(win, "ncurses bsdworks demo");
    wrefresh(win);
    ...
}

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):

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:

As an example, we can decorate our welcome message on the program:

...
int main(int argc, char** argv) {
    ...
    wattron(win, A_STANDOUT);
    mvwprintw(win, "ncurses bsdworks demo");
    wattroff(win, A_STANDOUT);
    wrefresh(win);
    ...
}

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()) {
        printf("Your terminal does not support color\n");
    } else {
        start_color();
        init_pair(1, COLOR_RED, COLOR_BLACK);
        init_pair(2, COLOR_GREEN, COLOR_BLACK);
    }
    ...

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;
    WINDOW *win;
} 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:

...
app_t *init_app();

We can populate the new function over a n_app.c source file, similar to this:

#include "n_app.h"

app_t *init_app {
    //get values from terminal size
    int y_max, x_max;
    getmaxyx(stdscr, y_max, x_max);

    //allocate app struct memory
    app_t *_app = calloc(1, sizeof(app_t));

    //associate values for screen size. To be used in resizing
    _app->rows = y_max;
    _app->cols = x_max;

    //create the window component
    _app->win = newwin(_app->rows, _app->cols, 0, 0);

    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) {
    delwin(app->win);
    free(app);
}

This way we can modify our main function to work as follows:

#include "n_app.h"

int main(int argc, char** argv) {
    ...
    app = init_app();
    ...
    while(1) {
        ...
    }
    ...
    destroy_app(app);
    return 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:

completed ncurses application running in urxvt

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 {
    N_MENU = 0,
    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 {
    WINDOW *win;
    int num_menus;
    menu_t *menus;
} menu_bar_t;

For the active menu tracking, let's add an enum over the n_common.h file:

...
typedef enum {
    A_FILE = 0,
    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 {
    WINDOW *win;
    int num_menus;
    menu_t *menus;
    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.

...
menu_t init_menu (char *name, char trigger);

menu_bar_t* init_menu_bar (WINDOW *win, int num_menus, menu_t *menus);

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 {
    WINDOW *win;
    int num_menus;
    menu_t *menus;
    ac_ctx_e selected_menu;
} menu_bar_t;

menu_t init_menu(char *name, char trigger);

menu_bar_t* init_menu_bar(WINDOW *win, int num_menus, menu_t *menus);

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"

menu_t init_menu (char *name, char trigger) {
    menu_t _menu;
    _menu.name = name;
    _menu.trigger = trigger;

    return _menu;
}

menu_bar_t* init_menu_bar (WINDOW *win, int num_menus, menu_t *menus) {
    menu_bar_t *_menu_bar = NULL;
    _menu_bar = calloc(1, sizeof(menu_bar_t));

    _menu_bar->win = win;
    _menu_bar->num_menus = num_menus;
    _menu_bar->menus = menus;

    //set active menu by default
    _menu_bar->selected_menu = 0;

    int start_pos = 2;

    for (int i = 0; i < num_menus; i++) {
        _menu_bar->menus[i].start_pos = start_pos;
        // this makes the menus on the Y direction to maintain space
        start_pos += 1;
    }

    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) {
            wattron(menu_bar->win, A_STANDOUT);
        }

        mvwprintw(menu_bar->win, menu_bar->menus[i].start_pos, 1, "%s", menu_bar->menus[i].name);
        wattroff(menu_bar->win, A_STANDOUT);
    }
}

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:

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:

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;
    getmaxyx(win, sz_y, sz_x);

    mvwprintw(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:         _____");


    wrefresh(win);
    if(is_action_w == N_ACTION){
    }
}

void ac_file(WINDOW *win, int is_action_w) {
    int sz_y, sz_x;
    getmaxyx(win, sz_y, sz_x);

    int l_start_x = 1;
    int l_start_y = 3;

    mvwprintw(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");
    wrefresh(win);
    if(is_action_w == N_ACTION){
    }
}

void ac_about(WINDOW *win, int is_action_w) {
    int sz_y, sz_x;
    getmaxyx(win, sz_y, sz_x);

    mvwprintw(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");
    wrefresh(win);
    if(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:

...
#include "n_menu.h"
#include "n_action.h"

typedef struct {
    WINDOW *win;
    WINDOW *menu_win;
    WINDOW *action_win;
    menu_bar_t *menu_bar;
    menu_t *menus;
    active_win_e active;
    int cur_y, cur_x;
} app_t;

extern app_t *app;

app_t *init_app();

void destroy_app(app_t *app);

Inside the init_app() function we can update all this new data:

app_t *init_app() {
    //get values from terminal size
    int y_max, x_max;
    getmaxyx(stdscr, y_max, x_max);
    
    //allocate app struct memory
    app_t *_app = calloc(1, sizeof(app_t));

    //associate values for screen size
    _app->cur_y = y_max;
    _app->cur_x = x_max;

    _app->win = newwin(_app->cur_y, _app->cur_x, 0, 0);

    //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
    _app->menu_win = newwin(y_max - 4, menu_w_x, 2, 1);

    //create the action window
    _app->action_win = newwin(y_max - 4, action_w_x, 2, menu_w_x + 1);
    
    //print decorative bars

    //here goes box borders

    //init menus
    _app->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');

    //set active window by default
    _app->active = N_MENU;

    //call the action window content by default
    ac_file(_app->action_win, _app->active);

    //refresh the windows
    wnoutrefresh(_app->win);
    wnoutrefresh(_app->menu_win);
    wnoutrefresh(_app->action_win);
    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();
    box(app->win, 0, 0);

    if(app->active == N_MENU) {
        box(app->action_win, 0, 0);
        wattron(app->menu_win, COLOR_PAIR(1));
        box(app->menu_win, 0, 0);
        wattroff(app->menu_win, COLOR_PAIR(1));
    } else {
        box(app->menu_win, 0, 0);
        wattron(app->action_win, COLOR_PAIR(1));
        box(app->action_win, 0, 0);
        wattroff(app->action_win, COLOR_PAIR(1));
    }

    //refresh the windows
    wnoutrefresh(app->win);
    wnoutrefresh(app->menu_win);
    wnoutrefresh(app->action_win);
    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) {
    wattrset(app->win, A_REVERSE);

    // print top and bottom bars
    for(int i = 1; i < app->cur_x -1; i++) {
        mvwprintw(app->win, 1, i, " ");
        mvwprintw(app->win, app->cur_y -2, i, " ");
    }

    //add content to the top bar
    mvwprintw(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);

    //add content to the bottom bar
    mvwprintw(app->win, app->win_params.cur_y -2, 2, "F1 - Help | F9 - Quit");

    wattroff(app->win, A_REVERSE);
}

Now we can place them over the comments on the init_app function:

app_t *init_app() {
    ...
    //print decorative bars
    print_bars(_app);

    //here goes box borders
    draw_borders(_app);
    ...
}

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();
    curs_set(0);
    keypad(stdscr, TRUE);

    if (!has_colors()) {
        printf("Your terminal does not support color\n");
    } else {
        start_color();
        init_pair(1, COLOR_RED, COLOR_BLACK);
        init_pair(2, COLOR_GREEN, COLOR_BLACK);
    }
}

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:

int main( int argc, char **argv ) {
    app = calloc(1, sizeof(app_t));

    init_ui();

    app = init_app();

    app->menu_bar = init_menu_bar(app->menu_win, 3, app->menus);
    draw_menu(app->menu_bar);
    
    wrefresh(app->menu_win);
    wrefresh(app->action_win);
    
    event_loop(app);

    destroy_app(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:

void event_loop (app_t *app) {
    int c;

    for (;;) {
        c = wgetch(app->win);

        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:
                app->menu_bar->selected_menu = -1;
                break;
            //9 or \t is horizontal tab
            case 9:
                if(app->active == N_MENU) {
                    app->active = N_ACTION;
                } else {
                    app->active = N_MENU;
                }
                break;
            default:
                break;
        }
    
        draw_borders(app);
        
        wnoutrefresh(app->win);
        wnoutrefresh(app->menu_win);
        wnoutrefresh(app->action_win);
        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.