C Programming | ncurses part II

FreeBSD gearing-up

Index


Most of the programs we make are meant to be interactive, that is they allow communication between the computer and the end user. In this ncurses(3X) guide we'll approach user interactivity with our program. We'll take a look on how a user can navigate through directories, load files, edit file content, and saving files. As a final tip, we'll implement the ability to resize our program window dynamically.

Reading data from the user

Programs work with data. One of the most common ways to get data is through the user via input fields in the program. The ncurses(3X) library allows us to use several methods to capture user input into a variable in our program.

The most common methods to get data from the user are:

We will be using the wgetnstr for this case scenario, since it allows us to select the working window and limit the input length but, no matter which method we use, we have to be aware that the user input will be a string, so we'll need to convert the values before storing them in our data if they differ.

The next step to consider is where is the user data going to be stored until we write a file. For that we can create a temporary data struct containing the different inputs the program can read. In this example, let's say our program is meant to store data from a car workshop were they measure certain engine data. Our temporary struct could look like:

struct tmp {
    char av_volt[6];
    char max_rpm[5];
    char av_temp[6];
    char max_temp[6];
    char av_co[6];
} _data;

Remember, the user input is stored as a string. We'll need to convert those values later to store in a file if we want them as float (atof) or integer (atoi) values.

Let's improve our edit action function a bit, so it can read and display the user data. We need to associate the user input fields with the temporal data struct. Then we need to enable the cursor in the action window, and echo the input from the user, so they can see what they're typing. Lastly, when the user finishes typing the inputs, we need to disable the cursor and the echo, and we need to convert the temporal data into our temporal file.

void ac_edit(app_t *app) {
    int sz_y, sz_x;
    getmaxyx(app->action_win, sz_y, sz_x);

    mvwprintw(app->action_win, 1, (sz_x - 10) / 2, "EDIT FRAME");

    //move through the window and print the temporary values of our inputs. They may be empty
    mvwprintw(app->action_win, 3, LEFT_MARGIN, "Average voltage: %s\t\tmax RPM: %s",\
            _data.av_volt, _data.max_rpm);
    mvwprintw(app->action_win, 4, LEFT_MARGIN, "Average temperature %s:\tmax temperature: %s",\
            _data.av_temp, _data.msx_temp);
    mvwprintw(app->action_win, 5, LEFT_MARGIN, "Average Co: %s, _data.av_co");

    wrefresh(app->action_win);

    //if we enter the action window we enable the cursor and the user input
    if(app->active == N_ACTION){
        draw_borders(app);

        wmove(app->action_win, 3, 21);
        curs_set(1);
        echo();
        wgetnstr(app->action_win, _data.av_volt, 5);
        wmove(app->action_win, 3, 41);
        wgetnstr(app->action_win, _data.max_rpm, 4);
        ...
        noecho();
        curs_set(0);
        app->tmp_file->data.av_volt = atof(_data.av_volt);
        app->tmp_file->data.max_rpm = atoi(_data.max_rpm);
        ...

        write_to_file(app->tmp_file, "example");
    }
}

Storing data in the disk

We already covered how to write both text and binary data to a file during the working with files guide series, so in this section we'll summarize a bit what we learnt there, so we can merge the knowledge into the ncurses(3X) project.

You may have noticed that in the previous block of code, we have stored our user input values into a struct named tmp_file->data. This is the approach we'll use for this demo. The quick recipe to have binary data written in a file requires the following components:

typedef struct {
    unsigned char id[12];
    unsigned char version;
    unsigned char data_offset;
} file_header_t;

extern unsigned char fct_id[12];

typedef struct {
    float av_volt;
    unsigned int max_rpm;
    float av_co;
    float av_temp;
    float max_temp;
} file_data_t;

typedef struct {
    file_header_t header;
    file_data_t data;
} fct_file_t;

Once we have all those structures defined in the header file, we can implement a quick function to store the data into a file:

int write_to_file(fct_file_t *file, char *filename) {
    const char *ext = ".log";
    char *_tmpfn = malloc(sizeof(char*) * (strlen(filename) + 1));
    strcpy(_tmpfn, filename);

    char *_ctm = current_time();
    strcat(_tmpfn, _ctm);
    strcat(_tmpfn, ext);

    FILE *out = fopen(_tmpfn, "w");

    if(out == NULL) {
        printf("there's been an error with the file %s", _tmpfn);
        return 1;
    }

    fwrite(file, sizeof(fct_file_t), 1, out);
    fclose(out);

    free(_tmpfn);
    free(_ctm);
    return 0;
}

There is a mysterious function call in that code block, current_time(). If you run the program, you'll notice that it create a temporary name automatically that doesn't overwrite other existing files by using the time as part of the name.

To do that we need to import the <time.h> header, and the helper function current_time() looks like this:

char *current_time() {
    time_t rawtime;
    time(&rawtime);
    struct tm *tm_now = localtime(&rawtime);

    char *_res = malloc(sizeof(char) * 16);
    snprintf(_res, sizeof(_res), "_%02d%02d", tm_now->tm_hour, tm_now->tm_min);

    return _res;
}

In order to return a char pointer with a dynamic content inside it, we can use snprintf.

Reading data from the system

Since we have a way to store the data from the user input in our program, it will be a nice feature to load that data into the program, whether to be read or to be modified.

Programs usually allow the user to store more than one file into the disk, and that opens a requirement when asking the user to load data. That is, we need to provide a way to allow the user to select which file they want to read or edit.

GUI programs often solve this issue by displaying a floating menu window where the user can navigate to a directory, and choose the desired file. In our case, we are going to do something similar: we are going to ask the user for the directory path they want to load, and then we are going to display all the files inside that match the file extension our program can understand, letting the user to select a specific file from the list.

Reading files from a directory.

As we mentioned, a good way to read files inside a directory is to ask the user at which directory should the program look at. If we do that step dynamic, the next question is to ask ourselves which data the program would require to work with that directory.

So we need two things in the recipe to read files from a directory:

For the library to use, let's choose dirent(5). It provides us with a structure that defines the format of directory entries, as well as a handler to open directory pointers DIR that works similar to FILE for files.

As for our dir_t struct we are going to need the following information to be stored:

typedef struct {
    char *d_path;
    char **f_names;
    unsigned int f_count;
    unsigned int sel_file;
} dir_t;

To keep things simple (or as simple as possible in this guide) let's track the working directory inside our app struct:

typedef struct {
    ...
    dir_t *wdir;
} app_t;

And remember to initialize it when we call init_app() the first time:

app_t *init_app() {
    ...
    _app->wdir = (dir_t*)calloc(1, sizeof(dir_t));
    ...
}

Now we need to perform two tasks in order to have the directory info displayed in the screen. First we need to scan the desired directory to get the files we want to store. Once we have that info, we need to print the file names into our program so the user can interact with them somehow.

— For the first task let's use the dirent(5) library in a function that takes a path and a working directory as parameters. In this case, we have a DIR pointer to our path that is read into a dirent pointer, which holds the directory entries.

void ctm_dir_ls(char *path, dir_t *wd) {
    if(!path) {
        printf("path is %p\n", path);
        exit(1);
    }

    if(wd->d_path != NULL) {
        return;
    }

    //allocate memory for our working dir struct and initialize some values
    wd->d_path = malloc(strlen(path) +1 * sizeof(char));
    strcpy(wd->d_path, path);

    wd->f_names = malloc(1 * sizeof(char*));
    wd->f_count = 0;
    wd->sel_file = 0;

    //open directory path
    DIR *dp = opendir(path);

    if(dp == NULL)
        exit(1);

    //this holds directory entries
    struct dirent *dir;

    //check if we can read something from the path
    while((dir = readdir(dp)) != NULL) {

        //check we are only reading files
        if(dir->d_type != DT_DIR) {

            //store the file name in a buffer to later print and use
            char *dot = strrchr(dir->d_name, '.');
            if (dot && !strcmp(dot, ".log")){

                //if there is more than one file, we need to realloc memory
                if(wd->f_count > 0) {
                    void *tmp = realloc(wd->f_names, wd->f_count +1 * sizeof(char*));
                    wd->f_names = tmp;
                }
                wd->f_names[wd->f_count] = (char*)malloc(256 * sizeof(char));
                strcpy(wd->f_names[wd->f_count], dir->d_name);
                wd->f_count++;
            }
        }
    }

    //close the directory
    closedir(dp);
}

Let's digest the code block a bit:

A major flaw in this approach is that we can read the directory path only once. A good refactor will be allowing the program to re-scan the directory and push updates to our program state.

— It's time to load the file names into the screen so the user can interact with them. We can perform this action following the next steps:

void print_files(app_t *app) {
    if(app->wdir->d_path == NULL) {
        return;
    }
    int start_y = 6;
    for(int i=0; i<app->wdir->f_count; i++) {
            // highlight selected file
            if(i == app->wdir->sel_file)
                wattron( app->action_win, A_STANDOUT );
            else
                wattroff( app->action_win, A_STANDOUT );

            mvwprintw( app->action_win, start_y+i, LEFT_MARGIN, "%s", app->wdir->f_names[i]);
    }

    wrefresh(app->action_win);
    
    if(app->active != N_ACTION)
        return;

    // enable keyboard input for the window.
    keypad( app->action_win, TRUE );
    curs_set( 0 );

    int ch;
    for(;;) {
        ch = wgetch(app->action_win);
        mvwprintw( app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
                app->wdir->f_names[app->wdir->sel_file]);
        switch( ch ) {
            // 115 is s key, selects the file
            case 115:
                wattron(app->action_win, A_STANDOUT );
                mvwprintw(app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
                    app->wdir->f_names[app->wdir->sel_file]);
                wattroff(app->action_win, A_STANDOUT );
                app->tmp_file = read_from_file(app->wdir->f_names[app->wdir->sel_file]);
                keypad(app->main_win, TRUE);
                return;
            case KEY_UP:
                app->wdir->sel_file--;
                app->wdir->sel_file = ( app->wdir->sel_file < 0 )\
                                        ? app->wdir->f_count -1 : app->wdir->sel_file;
                break;
            case KEY_DOWN:
                app->wdir->sel_file++;
                app->wdir->sel_file = ( app->wdir->sel_file > app->wdir->f_count -1 )\
                                        ? 0 : app->wdir->sel_file;
                break;
            default:
                break;
        }
        // highlight the selected item
        wattron( app->action_win, A_STANDOUT );
        mvwprintw(app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
                app->wdir->f_names[app->wdir->sel_file]);
        wattroff( app->action_win, A_STANDOUT );
    }
    wrefresh(app->action_win);
}

Reading content from a file

The intrinsics of reading content from a binary file were discussed at the working with files guide, so in this step we are also going to do a small recap to follow along, but feel free to check that guide for further reference.

fct_file_t *read_from_file(char *filename){
    fct_file_t *_tmp_fct = calloc(1, sizeof(fct_file_t));

    FILE *in = fopen(filename, "r");

    if(in == NULL) {
        printf("%s\n", "there's been an error reading the file.");
        return NULL;
    }

    fread(_tmp_fct, sizeof(fct_file_t), 1, in);
    fclose(in);

    if (memcmp(_tmp_fct->header.id, fct_id, 12) != 0) {
        printf("%s\n", "Corrupted or not valid FCT V1 file");
        return NULL;
    }
    return _tmp_fct;
}

Resizing the TUI

It's true that a TUI can be run inside the tty, which makes it really portable as a user interface. In that case scenario, resizing the user interface makes little to no sense but when working inside X11, wouldn't it be great to have a program responsive to the terminal emulator window changes?

One of the solutions to this task is to listen to changes in the screen during the main loop execution. That is, we are constantly checking if the current rows and cols are the same as in the previous frame.

In order to do that, we can have two new values in our application struct definition. If you remember from the previous guide, we currently have an integer for cur_x and cur_y. Let's add new_x and new_y too.

typedef struct {
    ...
    int cur_y, cur_x;
    int new_y, new_x;
} app_t;

In the init_app function we can initialize new_x and new_y to be the same value as cur_x and cur_y.

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

    ...

    //associate values for screen size. To be used in resizing
    _app->cur_y = _app->new_y = y_max;
    _app->cur_x = _app->new_x = x_max;
    ...
}

This way at the starting point we know that there is no need to resize. Now in the event loop function we can implement the value checking for x and y (or rows and cols).

void event_loop (app_t *app) {
    ...
    for (;;) {
        getmaxyx(stdscr, app->new_y, app->new_x);

        if (app->new_y != app->cur_y || app->new_x != app->cur_x) {
            app->cur_y = app->new_y;
            app->cur_x = app->new_x;
            resize_win(app);
        }
    ...
    }
}

The next step is to handle the window resizing in its own function so we can control what happens in a dynamic way. Over the n_app source file we can implement the following function resize_win(app_t *app).

void resize_win(app_t *app) {
    //this three ncurses functions create a temporal escape from ncurses
    endwin();
    refresh();
    clear();
    
    //resize the main window
    wresize(app->main_win, app->new_y, app->new_x);

    //resize menu and action windows with custom size, not the new size of the terminal
    int menu_w_x = app->new_x/6;
    int action_w_x = app->new_x - menu_w_x -2;

    wresize(app->menu_win, app->new_y - 4, menu_w_x);
    
    //the action window needs to be repositioned based on the width of the new menu window
    mvwin(app->action_win, 2, menu_w_x + 1);
    wresize(app->action_win, app->new_y - 4, action_w_x);

    //clearing all the screens in order to remove artifacts and glitches from the terminal emulator
    wclear(stdscr);
    wclear(app->main_win);
    wclear(app->menu_win);
    wclear(app->action_win);

    //after cleaning we can repaint all the content inside the windows
    print_bars(app);
    draw_menu(app->menu_bar);
    redrawwin(app->main_win);
    redrawwin(app->menu_win);
    redrawwin(app->action_win);
}

There are some new concepts related to windows in this function that are worth taking a look.

Summing up

The demo program in this series started as a quick and practical way to showcase the ncurses(3X) potential to create terminal based user interfaces but as you might noticed, the more content and functionality is added to a program, the more it leaves the demo label and starts to be a long task to maintain. This is the cycle of a real program, it never stops growing.

For this guide we need to stop the development here, but there are some ideas that would be cool to implement and / or handle, such as:

All in all, I think we covered a lot of ncurses(3X) functionality. Make sure to check the workshop repo of this guides, it may grow in the future while I explore new programming concepts in C.