Tuesday, January 31, 2017

Keikaku Prelude Part 3: Curses Crash Course

This is the third and final entry in an introduction to C programming. Check out part one here and part two here. This is targeted at people that want to follow along with keikaku projects, but have no experience in C. This is important since C99 will be the main language for the foundation portion of keikaku.

Curses?


Most low level programs rely on a low level interface, like writing to memory, a buffer, system calls, etc... The terminal is the quintessential low-level interface. Every computing system will have this for all time [forever]. It's the first step to make and provides a base for everything else. However, every operating system insists on a different terminal interface and drawing method. Terminals on the same operating systems can have different capabilities. How does one navigate this hell?

Well friend, software converged on a standard library called "curses" to abstract the drawing mechanisms away from any system-specific implementation. It was originally a library for a terminal game all the way back on BSD and quickly became adopted on all standard unix platforms. ncurses(new curses) is a clone of curses, which is what most open-source programs use. Their interface is the same, on linux the library "curses" is just a symbolic link to ncurses.

Installation


pdcurses is the public domain version of curses. If you use windows, download the pdc34dllw.zip file from the latest branch. The w at the end denotes the windows version. Then put the files in mingw's install folder as such: .h files to "include" folder, .lib to the "lib" folder, pdcurses.dll to the "bin" folder. Then add -lpdcurses to the end of your compilation command. This way gcc knows to link against its libraries. If you're on linux, you just need to install the headers, on debian linuxes, the package is libncurses5-dev. You can rename the header to n curses.h, make a copy, or change the includes in the tutorial to curses.h.

Baby Steps


The first thing we'll want to do is draw something. Anything. Compile something like the following program.
#include <ncurses.h>

int main(){
    initscr();  //initializes screen
    for ( int i=0; i<10; i++){
        mvprintw(i*2, i, "I current : %d", i);
    }
    refresh(); //draws
    getch(); //gets your input, but here we just use it to delay the program's exit
    endwin();  //ends ncurses
}

and run it to see the results



All curses programs need to be initialized and ended using initscr() and endwin(). We can initialize it in several input modes that give us access to different levels of keyboard input. If you exit prematurely it can screw up your terminal. The only function for drawing we use is mvprintw, which prints at a (y, x) location by moving the curses cursor then essentially printf()-ing. This function uses identical formatting to printf statements and thus the number of arguments varies with the format string.


Curses queues up commands and edits an internal buffer before it pushes its results to a screen. refresh() forces this buffer to be updated to the screen.

Characters With Attributes

The next logical step of output is to make our output look fancy and accept some kind of user input.

ncurses characters aren't as boring as normal terminal characters, they have secret powers known as "attributes" that lets your text look dank.

Here's an example that styles the characters you input.


#include <ncurses .h>

int main(){
    unsigned long c;
    initscr();
    noecho(); //by default ncurses writes your typing to the screen
             //this disables that
    while( (c = getch()) != 'q')  //q for quit
        addch(c | A_BOLD | A_ITALIC);
    
    endwin();
}


You don't need to do bit operations with attributes, you can just toggle it globally instead.

#include <ncurses .h>

int main(){
    unsigned long c;
    initscr();
    noecho();
    attron( A_BOLD | A_ITALIC )
    while( (c = getch()) != 'q')  //q for quit
        addch(c);
    attroff( A_BOLD | A_ITALIC )
    endwin();
}

attron() just enables attributes for all outputs and is often useful for strings instead of adding characters one-by-be. However, keep in mind that it's not as cool.

Putting it Together


Let's look at a text editor that takes advantage of the curses display to save and load states. Skip the first part and jump to the while(1) loop to see what's happening, but we'll explain it in a second anyway.


#include <ncurses.h>


// ---- GLOBALS ---- 
int max_x, max_y;    //screen dimensions
int x, y;            //cursor location
unsigned long text_settings; //bit settings
int fg_color, bg_color; //foreground and background colors


int  query_color();  //when you hit ctrl+c it calls this function 
void apply_colors(); //applies global color values to terminal


int main(){

    //ncurses initialization
    initscr();
    keypad(stdscr, TRUE);  //enables arrow keys, f1, etc..
    raw();  //
    getmaxyx(stdscr, y, x);
    noecho();

    printw("Just Start Typing Buddy, Hit <ESC> to Exit\n"
            "F5 saves to saved_session and F6 loads from it\n"
            "Hotkeys Enable Attributes\n"
            "ctrl+b : A_BOLD\n"
            "ctrl+i : A_ITALIC\n"
            "ctrl+r : A_REVERSE\n"
            "ctrl+d : A_DIM\n"
            "ctrl+p : A_PROTECT\n"
            "ctrl+n : A_INVIS\n"
            "ctrl+a : A_ALTCHARSET\n"
            "ctrl+t : A_CHARTEXT\n"
            "ctrl+s : A_STANDOUT\n");
    if (has_colors() == TRUE){
        start_color();
        printw(
            "\n"
            "COLORS\n"
            "ctrl+c => number  selects a color for the text\n"
            "ctrl+x => number  selects a color for the background\n"
            "0 : COLOR_BLACK\n"
            "1 : COLOR_RED\n"
            "2 : COLOR_GREEN\n"
            "3 : COLOR_YELLOW\n"
            "4 : COLOR_BLUE\n"
            "5 : COLOR_MAGENTA\n"
            "6 : COLOR_CYAN\n"
            "7 : COLOR_WHITE\n");
    }
    refresh();

    int c, qc;
    text_settings = 0;
    fg_color = COLOR_WHITE;
    bg_color = COLOR_BLACK;
    while(1){
        getyx(stdscr, y, x);  /*gets initial x,y coordinates, resets our x,y values if they're out of bounds*/
        c = getch();
        switch(c) {
            /* #### Navigation Section ####*/
            case KEY_UP:
                y=y-1;
                move(y,x);
                break;
            case KEY_LEFT:
                x=x-1;
                move(y,x);
                break;
            case KEY_RIGHT:
                x=x+1;
                move(y,x);
                break;
            case KEY_DOWN:
                y=y+1;
                move(y,x);
                break;
            /* #### Toggling Attributes with Ctrl+key ####*/
            case 'b'-96:
                text_settings ^= A_BOLD;   //note: ctrl+key is just 'key'-96
                break;
            case 'i'-96:
                text_settings ^= A_ITALIC;
                break;
            case 'r'-96:
                text_settings ^= A_REVERSE;
                break;
            case 'd'-96:
                text_settings ^= A_DIM;
                break;
            case 'p'-96:
                text_settings ^= A_PROTECT;
                break;
            case 'n'-96:
                text_settings ^= A_INVIS;
                break;
            case 'a'-96:
                text_settings ^= A_ALTCHARSET;
                break;
            case 't'-96:
                text_settings ^= A_CHARTEXT;
                break;
            case 's'-96: 
                text_settings ^= A_STANDOUT;
                break;
            case 'c'-96:  //foreground color
                qc = query_color();
                if (qc >= 0){
                    fg_color = qc;
                    apply_colors();
                }
                break;
            case 'x'-96: //background color
                qc = query_color();
                if (qc >= 0){
                    bg_color = qc;
                    apply_colors();
                }
                break;
            /* #### Normal Editor Inputs #### */
            case KEY_BACKSPACE:
                x=x-1;
                mvdelch(y,x);
                break;
            default:  /* User Typing */
                mvaddch(y,x,c | text_settings);
                getyx(stdscr,y,x);
                break;
            case 27:  /* EXIT program with escape key(or alt key)*/
                endwin();
                return 0;
            case KEY_F(5): /*saves to   ./saved_session */
                scr_dump("saved_session");
                break;
            case KEY_F(6): /*loads from ./saved_session */
                scr_restore("saved_session");
                break;
        }
        refresh();
    }
    endwin();
}

int query_color(){
    int i=getch();
    //the colors, e.g. COLOR_RED are enumerated so they're just values from 0 to 7
    if ('0'<=i && i<='7'){
        return (i-'0');  
    } else {
        mvaddch(y,x, i | text_settings);
        return -1;
    }
}

void apply_colors(){
    static int pair_num = 1;
    init_pair(pair_num, fg_color, bg_color);
    attron(COLOR_PAIR(pair_num));
    pair_num++;
}

So this is a lot at once, but it's pretty much the bulk of curses anyway. Notice first that the only part of this code outputting anything is the default case of the main switch statement.

The rest of the switch statements are styling and save/load related. The attribute components flip a bit in a settings variable that we use to keep overriding the curses settings.

The color section is very important to understand. In order to start using a color, we run start_color() and then initialize our pairs. Pairs are just a mapping of colors on an integer attribute. If you change the color for a pair number, all outputs of that color_pair will be render as that new color on the next refresh. The colors displayed will be your terminal's default. We don't do this here, but you can overwrite those and redefine COLOR_RED and such macros to your taste.

The scrdump/load method saves the state to a file, but the format isn't particularly flexible. Recall that we initialize the color pairs during our program's run, so if we save this.



It will load as this.



And will discard the attributes that aren't writable.



The bigger lesson here, is that we should always consider relying on the screen itself for keeping states, not for memory constraints, but for elegance of code. Memory is cheap, after all.

Mess around with it for a bit to get a feel for what each attribute does. e.g. write a haiku


A Rendering Loop

Since getch() is blocking by default, we won't be able to have a smooth rendering loop unless we use the nodelay() function. Let's do a simple example where we our typed characters fall to the ground.


#include <ncurses.h>
#include <unistd.h>
//unistd.h == unix std library

#define DELAY 35000

int main(){

    int c, x, y;

    initscr();
    noecho();
    nodelay(stdscr, TRUE); //our getch is no longer blocking
    getmaxyx(stdscr, y, x);

    while ((c=getch()) !='q'){  //q for quit
        //get input
        if (c != ERR){ //default result when we don't input anything in a loop
            if (c == '1')
                addch('1'); //just dump a 1 where the cursor is when the user hits 1
        }
        //display
        refresh();

        //update all 1's on the screen
        for(int i=x-1; i>=0; i--){
            for(int j=y-1; j>=0; j--){
                char bb = mvinch(j,i) & A_CHARTEXT; //eliminates formatting
                if (bb == '1'){
                    mvaddch(j,   i,   ' ');
                    mvaddch(j+1, i,   '1');
                }
            }
        }
        
        usleep(DELAY);
    }
    endwin();
    //returns 0 by default if you no return statement present
}

The main action is we set nodelay() to true for stdscr(i.e. the main window), which changes our getch() to be immedate, returning an ERR if nothing was input when queried. After our logic, we make the process sleep using  usleep(), which lets us specify microseconds for the process to do nothing(the u in the name should be thought of as a mu).


When you type, your cursor position will be reset back to 0,0 since mvinch first moves the cursor and then reads.

A Real Game

Let's take the concept of the rendering loop to recreate the classic "snake" game. Try to read the following code as practice, but don't stress too much since we'll be covering it in the next paragraph.

#include <ncurses.h>
#include <unistd.h>
#include <stdlib.h>
#define DELAY 65000


//the snake is a doubly-linked list
typedef struct node{
    int x;
    int y;
    struct node* next;
    struct node* prev;
} node_t;

node_t * snek_front = NULL;
node_t * snek_back  = NULL;


//snek treat
int treat_x;
int treat_y;
int snek_alive=1;


int max_y=0;
int max_x=0;     //screen limits


void grow_snek(int x, int y){
    node_t * new_head = malloc(sizeof(node_t));
    new_head->x = x;
    new_head->y = y;
    new_head->next = snek_front;
    new_head->prev = NULL;
    snek_front->prev = new_head;
    snek_front = new_head;
}

void push_position(int x, int y, node_t* snek){
    if (snek != NULL){
        push_position(snek->x, snek->y, snek->next);
        snek->x = x;
        snek->y = y;
    }
}

int in_snek(int x, int y){
    node_t * cur_node = snek_front;
    while (cur_node != NULL){
        if (x == cur_node->x && y == cur_node->y)
            return 1;
        cur_node = cur_node->next;
    }
    return 0;
}

void spawn_treat(){
    //make sure: treat isn't where the snek is 
    treat_x = rand() % max_x;
    treat_y = rand() % max_y;

    while (in_snek(treat_x, treat_y)){
        treat_x = rand() % max_x;
        treat_y = rand() % max_y;
    }
}

void move_snek(int dx, int dy){
    int new_x = snek_front->x + dx;
    int new_y = snek_front->y + dy;
    node_t  * temp_ptr = snek_front;
    if (new_x < 0 || new_x > max_x || new_y < 0 || new_y > max_y) {
        snek_alive = 0;
    } else if (new_x == treat_x && new_y == treat_y){ //treat
        grow_snek(new_x, new_y);
        spawn_treat(); //change x&y coordinates of treat
    } else {
        push_position(new_x, new_y, snek_front);
    }
}


void draw_snek(){
    node_t * cur_node = snek_front;
    while (cur_node != NULL){
        mvprintw(cur_node->y, cur_node->x, "s");
        cur_node = cur_node->next;
    }
    return 0;
}

void draw_treat(){
    mvprintw(treat_y, treat_x,"t");
}

int main(){
    //ncurses init stuff
 initscr();
 keypad(stdscr, TRUE);
 nodelay(stdscr, TRUE);
    noecho();
    curs_set(0);
    getmaxyx(stdscr, max_y, max_x);

    int last_key = KEY_LEFT;

    //init
    snek_front =  malloc(sizeof(node_t));
    snek_back  =  malloc(sizeof(node_t));
    snek_front->x = max_x/2;
    snek_front->y = max_y/2;
    snek_back ->x = max_x/2 + 1;
    snek_back ->y = max_x/2;
    snek_front->prev = NULL; snek_back->next = NULL;
    //point to each other
    snek_front->next = snek_back; snek_back->prev = snek_front;


    //init treat somewhere to the left of snek
    treat_x= max_x/4;
    treat_y= rand()%max_y;

 int input;

    //our loop is fairly simple, we could have handles input a number
    //of different ways, such as a separate function to handle a global flag
    //or just a handle_input that returns a struct with the dx, dy vals
 while(snek_alive) {

  input = getch();
  if (input != ERR)   //ERR is returned if we don't provide any input
   last_key = input;  //if the user hits any other key it becomes last_key!

        switch(last_key){
            case KEY_UP:    //KEY_DIRECTION globals are provided by ncurses
                move_snek(0,-1);
                break;
            case KEY_LEFT:
                move_snek(-1,0);
                break;
            case KEY_RIGHT:
                move_snek(1, 0);
                break;
            case KEY_DOWN:
                move_snek(0,1);
                break;
        }

        clear();    //draws screen
        draw_snek();
        draw_treat();
        refresh();

  usleep(DELAY);  //u = mu = sleep for microseconds

 }
    endwin();
}

The central loop is the same, including our DELAY macro. The main difference is that we keep track of all the entities internally, instead of using the screen for it.* Again, this choice was made for the sake of elegance.

The only new ncurses function introduced is set_curs(0) to render our cursor invisible to the user.

From a data structure perspective, our snake is just a dynamically allocated, doubly-linked list. We propagate position by using it as a queue. This makes it pretty easy to code around, and we just traverse the queue to make sure we didn't generate our random treat inside it. Note that randomly generating a position for the snake can potentially get slower once we occupy almost all of the screen.

*If you're really constrained for memory, you could keep the states of the game on the screen itself using the A_INVIS attributes to hide it from the user. You'll probably never need to do that though



That's all you need to know to make scientific applications**, so our job is basically done. We learned the basics of input, output. I recommend making a few toy projects to practice these basic concepts, and gradually introduce new ones you're less familiar with. There are many small things you can optimize in these examples. Don't go OCD over them! Always focus on basic code that looks good and scales well, not the most efficient code possible. I know, I know. It physically hurts me too, but we need to accept "good enough" versions first when programming.

**the only kind of applications that matter

Some links of interest:
terminfo and termcap, important stuff you don't need to touch
menus an add-on that's pretty useful
guide to internals of ncurses

No comments :

Post a Comment