C Programming | ncurses part II
Index
- Reading data from the user
- Storing data in the disk
- Reading data from the system
- Resizing the TUI
- Summing up
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:
getch()
reads a single character input from thestdscr
.getstr()
does the same as calling several times togetch
waiting for a newline or a carriage return to end the instruction. It returns a string with aNUL
termination.getnstr()
does the same as callinggetstr
but we can delimit the number of characters the user can input.wgetstr()
does the same as callinggetstr
but it allows us to target a specific window instead of thestdscr
.wgetnstr()
does the same as callinggetnstr
but again, since it's preceded with aw
in the function name, we can target a desired window instead of thestdscr
.
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;
(app->action_win, sz_y, sz_x);
getmaxyx
(app->action_win, 1, (sz_x - 10) / 2, "EDIT FRAME");
mvwprintw
//move through the window and print the temporary values of our inputs. They may be empty
(app->action_win, 3, LEFT_MARGIN, "Average voltage: %s\t\tmax RPM: %s",\
mvwprintw.av_volt, _data.max_rpm);
_data(app->action_win, 4, LEFT_MARGIN, "Average temperature %s:\tmax temperature: %s",\
mvwprintw.av_temp, _data.msx_temp);
_data(app->action_win, 5, LEFT_MARGIN, "Average Co: %s, _data.av_co");
mvwprintw
(app->action_win);
wrefresh
//if we enter the action window we enable the cursor and the user input
if(app->active == N_ACTION){
(app);
draw_borders
(app->action_win, 3, 21);
wmove(1);
curs_set();
echo(app->action_win, _data.av_volt, 5);
wgetnstr(app->action_win, 3, 41);
wmove(app->action_win, _data.max_rpm, 4);
wgetnstr...
();
noecho(0);
curs_set->tmp_file->data.av_volt = atof(_data.av_volt);
app->tmp_file->data.max_rpm = atoi(_data.max_rpm);
app...
(app->tmp_file, "example");
write_to_file}
}
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:
- A
struct
that defines how a file for the program is constructed, which in our case requires a file headerstruct
and a file datastruct
. - The file header
struct
contains tailored info for the program, such as a magic number id for the file type, a file version, and the offset to the actual datastruct
. - The file data
struct
that can contain all the specific data we want to store.
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));
(_tmpfn, filename);
strcpy
char *_ctm = current_time();
(_tmpfn, _ctm);
strcat(_tmpfn, ext);
strcat
FILE *out = fopen(_tmpfn, "w");
if(out == NULL) {
("there's been an error with the file %s", _tmpfn);
printfreturn 1;
}
(file, sizeof(fct_file_t), 1, out);
fwrite(out);
fclose
(_tmpfn);
free(_ctm);
freereturn 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;
(&rawtime);
timestruct tm *tm_now = localtime(&rawtime);
char *_res = malloc(sizeof(char) * 16);
(_res, sizeof(_res), "_%02d%02d", tm_now->tm_hour, tm_now->tm_min);
snprintf
return _res;
}
In order to return a
char
pointer with a dynamic content inside it, we can usesnprintf
.
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:
- An internal library to access directories from C.
- A
struct
we define with the meaningful content for our program regarding that 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:
- The directory path.
- An array of file names found in that directory.
- The total amount of files in the selected directory.
- A selector that tracks the current selected file by the user.
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 {
...
*wdir;
dir_t } app_t;
And remember to initialize it when we call init_app()
the first time:
*init_app() {
app_t ...
->wdir = (dir_t*)calloc(1, sizeof(dir_t));
_app...
}
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) {
("path is %p\n", path);
printf(1);
exit}
if(wd->d_path != NULL) {
return;
}
//allocate memory for our working dir struct and initialize some values
->d_path = malloc(strlen(path) +1 * sizeof(char));
wd(wd->d_path, path);
strcpy
->f_names = malloc(1 * sizeof(char*));
wd->f_count = 0;
wd->sel_file = 0;
wd
//open directory path
*dp = opendir(path);
DIR
if(dp == NULL)
(1);
exit
//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*));
->f_names = tmp;
wd}
->f_names[wd->f_count] = (char*)malloc(256 * sizeof(char));
wd(wd->f_names[wd->f_count], dir->d_name);
strcpy->f_count++;
wd}
}
}
//close the directory
(dp);
closedir}
Let's digest the code block a bit:
- In addition to the usual
NULL
handlers, we start the function by allocating memory to ourdir_t struct
, and initializing the file count and selected file values to0
. We can also store the directory path string into ourdir_t struct
so it can be displayed later to the user interface. - Once we have our directory path loaded into the
dirent
pointer, we traverse its contents looking only for file entries, and we also filter for files with an specific extension. - If we detect that we have more than one file while traversing the directory path contents, we reallocate memory in the file names
char**
from ourdir_t struct
to store the newly discovered data. - Finally we close the
DIR
pointer before exiting the function.
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:
- First we make sure that the directory path is not null. That will indicate us the program didn't load any directory.
- The next step is to iterate on the file count of the directory
struct
to print them in the action window- In this step we can also leave the first file as selected so the user can get a hint on where the cursor is
- After displaying the files in the action window, we need to check in which window is the user currently working at. If it isn't the action one, we skip the rest of the function.
- In the other hand, if the user is in the action window, we need to allow the user to interact with the file list:
- First we need to pass the keyboard control from the main window to the action window.
- Then we need to enable a new loop where we listen to the pressed key on the action window. In this scenario we are tracking the following keys:
- The
s
key (ascii 115) selects the file and loads its content into the edit window. - The
up
anddown
arrow keys allow the user to navigate the displayed file list.
- The
- On each user interaction we update the window content so the user can feel the interaction in real time.
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)
( app->action_win, A_STANDOUT );
wattronelse
( app->action_win, A_STANDOUT );
wattroff
( app->action_win, start_y+i, LEFT_MARGIN, "%s", app->wdir->f_names[i]);
mvwprintw}
(app->action_win);
wrefresh
if(app->active != N_ACTION)
return;
// enable keyboard input for the window.
( app->action_win, TRUE );
keypad( 0 );
curs_set
int ch;
for(;;) {
= wgetch(app->action_win);
ch ( app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
mvwprintw->wdir->f_names[app->wdir->sel_file]);
appswitch( ch ) {
// 115 is s key, selects the file
case 115:
(app->action_win, A_STANDOUT );
wattron(app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
mvwprintw->wdir->f_names[app->wdir->sel_file]);
app(app->action_win, A_STANDOUT );
wattroff->tmp_file = read_from_file(app->wdir->f_names[app->wdir->sel_file]);
app(app->main_win, TRUE);
keypadreturn;
case KEY_UP:
->wdir->sel_file--;
app->wdir->sel_file = ( app->wdir->sel_file < 0 )\
app? app->wdir->f_count -1 : app->wdir->sel_file;
break;
case KEY_DOWN:
->wdir->sel_file++;
app->wdir->sel_file = ( app->wdir->sel_file > app->wdir->f_count -1 )\
app? 0 : app->wdir->sel_file;
break;
default:
break;
}
// highlight the selected item
( app->action_win, A_STANDOUT );
wattron(app->action_win, start_y + app->wdir->sel_file, LEFT_MARGIN, "%s",\
mvwprintw->wdir->f_names[app->wdir->sel_file]);
app( app->action_win, A_STANDOUT );
wattroff}
(app->action_win);
wrefresh}
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.
*read_from_file(char *filename){
fct_file_t *_tmp_fct = calloc(1, sizeof(fct_file_t));
fct_file_t
FILE *in = fopen(filename, "r");
if(in == NULL) {
("%s\n", "there's been an error reading the file.");
printfreturn NULL;
}
(_tmp_fct, sizeof(fct_file_t), 1, in);
fread(in);
fclose
if (memcmp(_tmp_fct->header.id, fct_id, 12) != 0) {
("%s\n", "Corrupted or not valid FCT V1 file");
printfreturn 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
.
*init_app() {
app_t //get values from terminal size
int y_max, x_max;
(stdscr, y_max, x_max);
getmaxyx
...
//associate values for screen size. To be used in resizing
->cur_y = _app->new_y = y_max;
_app->cur_x = _app->new_x = x_max;
_app...
}
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 (;;) {
(stdscr, app->new_y, app->new_x);
getmaxyx
if (app->new_y != app->cur_y || app->new_x != app->cur_x) {
->cur_y = app->new_y;
app->cur_x = app->new_x;
app(app);
resize_win}
...
}
}
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
(app->main_win, app->new_y, app->new_x);
wresize
//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;
(app->menu_win, app->new_y - 4, menu_w_x);
wresize
//the action window needs to be repositioned based on the width of the new menu window
(app->action_win, 2, menu_w_x + 1);
mvwin(app->action_win, app->new_y - 4, action_w_x);
wresize
//clearing all the screens in order to remove artifacts and glitches from the terminal emulator
(stdscr);
wclear(app->main_win);
wclear(app->menu_win);
wclear(app->action_win);
wclear
//after cleaning we can repaint all the content inside the windows
(app);
print_bars(app->menu_bar);
draw_menu(app->main_win);
redrawwin(app->menu_win);
redrawwin(app->action_win);
redrawwin}
There are some new concepts related to windows in this function that are worth taking a look.
In the first three lines we use a combination of three internal
ncurses(3X)
functions to resetncurses(3X)
temporarily.endwin()
used along withrefresh()
in this order makes the program to resetncurses()
without exitingclear()
blanks every position in the screen and then callsclearok()
to ensure the screen is cleared and repainted from scratch the next time we callwrefresh()
.
We are creating new custom sizes for the menu and action window, since we are treating them as child windows from the main window.
- The key part is to relocate the right side window (aka action window) before applying the resize call, since the initial anchor point for this window is relative to the menu one
Once we have all the new data to work with, we need to clear all the windows (one by one) by calling
wclear()
so we can reduce the number of glitches and artifacts that may appear on the terminal emulator when repainting our program.Finally, we can repaint all the content of the program's windows. The separate pieces of the program such as the information bars and the menu can be repainted by calling their respective functions, but we also need an internal
ncurses(3X)
to make this work.redrawwin()
tellsncurses(3X)
the window has some corrupted lines and needs to throw them away before repainting anything.
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:
- Including and event based system to handle callbacks, so the core logic of the program is more consistent.
- Handling large directories where we have more than a couple of files. In this case we'll need to refactor the way files are displayed in the action window, since we are going to need several columns to display them all.
- Asking the user for the name of the file to save instead of automatically saving it with a generative one.
- Ensuring all functions are error safe so we can trace bugs easily.
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.