The Developer’s Cry

Yet another blog by a hobbyist programmer

Curses programming practices

Way back in the early 90s, in the DOS days, we used to work primarily in text mode. There was an early version of Windows, but it was ugly and slow, and even when I made the switch to Linux I often worked at the text console because X11 was ugly and slow. I did miss (and still do) the speedy, plain text mode user interfaces of programs like Borland C compiler and Norton Utilities. These were the best you could get on a 80x25 characters screen, and you know what, there was nothing wrong with it. Fore one thing, I have yet to find a front-end to gdb or lldb that looks as good and works as nicely as Borlands Turbo Debugger once did.
Anyway, on Linux (and Mac) we can recreate these text interfaces using a library named curses. Back in August 2012 I wrote an introduction titled Crash course in ncurses. This time I want to show a couple of tricks that make working with curses a little easier.

Curses in practice: part 0. The curses API argument order

This is a no-brainer that every curses programmer loves to hate. To create a window, call newwin(h, w, y, x) which is totally backwards. What you want is: create_window(x, y, w, h). Some say you just have to stick with it, but it’s easy to create wrapper functions anyway.

Curses in practice: part 1. Keyboard input

Getting keystrokes from curses is done with getch(). That’s easy enough, once you’ve put the terminal into raw() mode. The getch() function will return an integer key code. This is nice when you are programming in C, and it doesn’t matter much if all you want to do is catch the cursor keys. But in Python, when I press ‘a’, I would really like to get 'a', which is a string. If we make our own getch() function that returns a string, then we need to do some more trickery to be able to return cursor key codes or keys combined with Ctrl.

def getch():
    '''Returns keystroke as string'''

    # this is a good moment to render any screen updates
    curses.doupdate()

    key = STDSCR.getch()

    # in debug mode, Ctrl-Q is hardwired to quit
    if DEBUG and key == 17:
        # terminate() resets curses and calls endwin()
        terminate()
        sys.exit(-1)

    if key == curses.KEY_RESIZE:
        resize_event()

    elif key >= ord(' ') and key <= ord('~'):
        # ASCII key
        return chr(key)

    elif key >= 1 and key <= 26:
        # Ctrl-A to Ctrl-Z
        return 'Ctrl-' + chr(ord('@') + key)

    # special key : use a lookup table
    skey = '0x%02x' % key
    if skey in KEY_TABLE:
        return KEY_TABLE[skey]

    # unknown key; just return as hex string
    return skey

As you can see, we return all keys as a string value. This is a lot easier to work with when interpreting keystrokes, because now the character ‘a’ will be a Python string 'a'.
Alternatively, you might do a hybrid function that returns strings for ASCII values, but integers for special or modified keys. In the above example I use a key table however. The key table looks like this:

KEY_TABLE = {'0x1b': 'ESC', '0x0a': 'RETURN',
             '0x09': 'TAB', '0x161': 'BTAB',
             '0x102': 'DOWN', '0x103': 'UP',
             '0x104': 'LEFT', '0x105': 'RIGHT',
             '0x152': 'PAGEDOWN', '0x153': 'PAGEUP',
             '0x106': 'HOME', '0x168': 'END',
             '0x14a': 'DEL', '0x107': 'BS', '0x7f': 'BS'}

A note about the Esc key: curses imposes a timeout on this key stroke, making the key slow to react. The reason for this timeout is that in the UNIX terminal special keys like the cursor keys, Function keys, etcetera are all translated to escape sequences. You can fiddle with this timeout at the risk of breaking the program’s sense of input. In practice it is safe however to set the timeout as low as 25 milliseconds. C programs may call set_escdelay(). In Python, we set an environment variable:

# set ESC delay before calling initscr()
os.environ['ESCDELAY'] = '25'
STDSCR = curses.initscr()

A note about Function keys: F1 .. F10 are absolutely evil on the Mac. They are supposed to work as-is when holding down the special Fn key, but they don’t. Sorry, but the Mac is just broken like that. This is incredibly sad. Therefore the F-keys are off limits; just stay away from them.

Curses in practice: part 2. Color management

curses has the weirdest color management the world has ever seen. You would expect to be able to set foreground and background color. Well, you can … but you can’t set them independently. In curses, you create a color_pair which is exactly that: a pair of colors that stick together. For a text color you might combine white on black, and for a window title you might want to choose yellow on blue, in bold. (The bold attribute is not part of the color pair).

Working with color pairs is annoying if you are not used to it. You can set up a bunch of color pairs at program initialization, and use those. Personally, I like having a more dynamic system. So I came up with having a color cache, which caches combinations of colors.

COLOR_CACHE = {}
COLOR_PAIR_IDX = 0

def get_color(fg, bg, bold=True):
    '''Returns the curses color attribute'''

    global COLOR_PAIR_IDX

    pair = '%x:%x' % (fg, bg)
    if pair not in COLOR_CACHE:
        assert COLOR_PAIR_IDX < curses.COLOR_PAIRS

        # make new color pair
        COLOR_PAIR_IDX += 1
        curses.init_pair(COLOR_PAIR_IDX, fg, bg)
        COLOR_CACHE[pair] = COLOR_PAIR_IDX

    color = curses.color_pair(COLOR_CACHE[pair])
    if bold:
        color |= curses.A_BOLD
    return color

def init():
    '''initialize'''

    # get any colors at all
    curses.start_colors()

You could also add support for reverse video with curses.A_REVERSE.

The reason for curses having color pairs is that the number of colors that a terminal can simultaneously display may be severely limited. In practice however, that holds true for very ancient displays only. To make it perfect, we should check if the terminal has_colors() too.

Curses in practice: part 3. Screen emulation

So, you have your curses windows up and running, and it looks really nice. But next you want to add a shadow to the window, and find out that it doesn’t work because curses won’t allow you to draw outside the window, which actually makes sense.
We can make the shadow by writing into the STDSCR. This is a gross hack, and won’t work correctly once you start stacking windows — either with or without curses.panels.
I tried making a shadow by having a larger curses window so that the shadow would fall inside, but there is no easy way to draw a border that is smaller than the curses window.
So curses is letting us down here. We can fix it, but we are going to have to take a leap and leave the standard way of doing things behind.

The IBM PC text mode VGA screen worked like this: there was a frame buffer at segment address B800 that held the screen. You could poke into that buffer and directly manipulate what was on the screen. One byte represented a character, and the next was its color: 3 bits of foreground, 1 bold/bright bit, 3 bits of background color, 1 bit for blinking. What I really want is something like this. It’s easier though to choose two planes: one character buffer, and one color buffer.

You can see where this is going; we won’t use any curses windows at all. There is only one STDSCR, and in the STDSCR we can draw whatever we want, where we want, and make windows with shadows. We can have the windows clip and not throw an exception (unlike curses). We can easily save the background and restore it when a window closes. The stretch of code that does all that is too large to post here, but I will show the basics:

class ScreenBuffer(object):
    '''represents a screen buffer'''

    def __init__(self, x, y, w, h):
        '''initialize'''

        # TODO assert x >= 0 etc.

        self.w = w
        self.h = h
        self.textbuf = bytearray(w * h)
        self.colorbuf = bytearray(w * h)
        self.current_color = 0

    def putch(self, x, y, ch, color=-1):
        '''put character at x, y'''

        # TODO do clipping for x,y

        if color == -1:
            color = self.current_color

        if isinstance(ch, str):
            ch = ord(ch)

        offset = self.w * y + x
        self.textbuf[offset] = ch & 0x7f
        if ch > 0x400000:
            self.textbuf[offset] |= 0x80
        self.colorbuf[offset] = color
        # update curses screen, too
        STDSCR.addch(y, x, ch, curses_color(color))

There are two things to note here. First, I used the VGA color coding scheme that works with bits and bit shifts. These colors need to be translated again to curses color pairs. It’s not shown here, but it’s best to keep a current color member for both the VGA and the curses color. Secondly, when curses draws lines for borders, it uses character codes that are large integers: above 0x400000. You can’t fit those into a single byte. What I do is take the 7-bit ASCII value, and set the 8th bit if it was a special curses character. Knowing that there was a special character there is important when saving and restoring window backgrounds.

This code looks horribly inefficient (and in a way, it is) but we can make optimized versions for drawing horizontal and vertical lines, rectangles, and outputting messages on the screen.
Because curses doesn’t do its screen update magic until doupdate() gets called by our getch() routine, this homemade VGA-like screen emulation thing works surprisingly well.

This cool trick changes to awesome when overloading the index operator for the ScreenBuffer class with __getitem__() and __setitem__(). This enables writing code like:

screen[x, y] = (ch, color)

curses just got a whole lot easier. It’s a large library with lots of functions, but ultimately I’m only interested in getting a colored character on-screen at an arbitrary position. That’s all I need.

It’s too bad, but there are some things that curses just can not do; Novell Netware’s menu’s had double borders; Borland’s buttons had half-height shadows; Norton Utilities redefined the PC font to have pretty radio buttons, checkboxes, and extra wide window borders. Emulating these may only be possible by installing a special font file, or going full blown graphics mode and not using curses at all.