Using Curses with Python

Python’s curses module provides “terminal handling for character-cell displays”.

Here is a “Hello Curses!” program in a file named hello_curses.py:

import curses
from curses import wrapper


def main(stdscr):
    stdscr.clear()
    stdscr.addstr(11, 32, "Hello CSC 221!")
    stdscr.refresh()
    stdscr.getch()


wrapper(main)

Notice that we are not using our usual print function here to display output, nor are we using input. Instead, we create a screen object, stdscr, and use its addstr method to print a string and its getch method to get a character.

In our next example, read_chars.py, we save the character returned by getchar an print out both its character and numeric representation:

import curses


def main(stdscr):
    curses.noecho()

    stdscr.addstr(9, 25, "Enter a character or '!' to quite: ")
    stdscr.refresh()
    ch = stdscr.getch()

    while ch != ord('!'):
        stdscr.clear()
        stdscr.addstr(9, 25, "Enter a character or '!' to quite: ")
        stdscr.addstr(
            12, 18,
            f"You entered '{chr(ch)}', which has a numeric value of {ch}."
        )
        stdscr.move(9, 60)
        stdscr.refresh()
        ch = stdscr.getch()


if __name__ == "__main__":
    curses.wrapper(main)

This method returns the numeric value of the key pressed, so we need to use Python’s char function convert into a printable character.

Exploring the window environment

This next example, interrogate_env.py, uses several of the available curses functions to get information about the window environment:

import curses

COLOR_ORANGE = 8


def display_heading(win):
    win.attron(curses.A_STANDOUT)
    win.addstr(2, 22, "ncurses")
    win.attroff(curses.A_STANDOUT)
    win.addstr(2, 22 + len("ncurses"), " Environment Interrogator")
    win.box()
    win.refresh()


def test_color_support(win):
    colors = "YES" if curses.has_colors() else "NO"
    win.addstr(2, 5, "Supports color: ")
    if colors == "YES":
        curses.start_color()
        curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
        win.attron(curses.color_pair(1))
    win.addstr(2, 5 + len("Supports color: "), colors)
    if colors == "YES":
        win.attroff(curses.color_pair(1))


def test_change_color_support(win):
    change_color = "YES" if curses.can_change_color() else "NO"
    win.addstr(4, 5, "Supports change color: ")
    if change_color == "YES":
        curses.init_color(COLOR_ORANGE, 1000, 500, 0)
        curses.init_pair(2, COLOR_ORANGE, curses.COLOR_BLACK)
        win.attron(curses.color_pair(2))
    win.addstr(4, 5 + len("Supports change color: "), change_color)
    if change_color == "YES":
        win.attroff(curses.color_pair(2))


def display_window_sizes(win0, win1, win2):
    rows, cols = win0.getmaxyx()
    win2.addstr(6, 5, f"Main window size: {rows} by {cols}")
    win2_rows, win2_cols = win2.getmaxyx()
    win2.addstr(8, 5, f"Current window size: {win2_rows} by {win2_cols}")
    win1_rows, win1_cols = win1.getmaxyx()
    win2.addstr(10, 5, f"Top window size: {win1_rows} by {win1_cols}")
    beg_y, beg_x = win2.getbegyx()
    win2.addstr(12, 5, f"Current window top left pos: {beg_y}, {beg_x}")


def main(stdscr):
    curses.noecho()
    stdscr.refresh()

    win1 = curses.newwin(5, 76, 1, 2)
    win2 = curses.newwin(15, 46, 6, 18)

    display_heading(win1)
    test_color_support(win2)
    test_change_color_support(win2)
    display_window_sizes(stdscr, win1, win2)

    win2.box()
    win2.refresh()

    stdscr.addstr(22, 25, "Enter a character to quit: ")
    stdscr.getch()


if __name__ == "__main__":
    curses.wrapper(main)

Running this will on a terminal that supports colors and color changes will produce a screen something like this:

interrogate_env.py screenshot

Additional Curses Configuration

There are a few things that will be useful to setup as we get deeper into our exploration of Curses.

First, The convention is that terminal size should be 80 columns x 24 rows. (The history of this convention is fascinating - see this StackOverflow thread for more context ). It’s possible to resize modern terminal windows, but we’ll want to standardize on these dimensions for many of our upcoming projects.

The other challenging thing about using Curses is that its output takes up the entire terminal, and uses the stdout system stream to control the output. This means that, while our program is using curses, we can not use Python’s standard print() function. There are lots of ways to get around this, but our favorite is to use named pipes to redirect output to a different terminal window.

To set this up on a unix system, create a named pipe with this command:

$ mkfifo /tmp/debug_pipe

Then in a new terminal window, type this:

$ tail -f /tmp/debug_pipe

This command will listen to the pipe and print out anything that is written to it.

Here’s some starter Python code that sets the terminal size and writes a sample message to the pipe.

import curses
import subprocess

output_pipe = open("/tmp/debug_pipe", "w")


def main(stdscr):
    width = 80
    height = 24

    rows, cols = stdscr.getmaxyx()

    curses.start_color()
    curses.resize_term(height, width)
    subprocess.call(["/usr/bin/resize", "-s", str(height), str(width)])

    output_pipe.write("Resized screen to 80x24\n")
    output_pipe.flush()

    stdscr.addstr(6, 32, f"Original window size: {rows} by {cols}")
    stdscr.addstr(11, 32, "Now, This screen is 24x80!")
    stdscr.refresh()

    # clear any output that might have come from resize, and then
    # wait for user to press another key to exit
    curses.flushinp()
    stdscr.getch()


if __name__ == "__main__":
    curses.wrapper(main)

Generating a simple menu

This program creates a simple menu.

import curses

def main(stdscr):
    # Constants
    enter_key = 10
    
    # Menu choices
    choices = [
        "1st Choice",
        "2nd Choice", 
        "3rd Choice",
        "Choose me!",
        "Not me!"
    ]
    
    highlight = 0
    
    # Setup
    curses.curs_set(0)  # Hide cursor
    stdscr.keypad(True)  # Enable special keys
    
    # Get screen dimensions
    height, width = stdscr.getmaxyx()
    
    # Create menu window
    menu_height = len(choices) + 4
    menu_width = width - 2
    menu_y = height - menu_height
    menu_x = 1
    
    menuwin = curses.newwin(menu_height, menu_width, menu_y, menu_x)
    menuwin.keypad(True)  # Enable special keys for the menu window
    menuwin.box()
    
    stdscr.refresh()
    menuwin.refresh()
    
    while True:
        # Display menu items
        for i, choice in enumerate(choices):
            if i == highlight:
                menuwin.attron(curses.A_REVERSE)
            menuwin.addstr(i + 2, 3, choice)
            menuwin.attroff(curses.A_REVERSE)
        
        menuwin.refresh()
        
        # Get user input
        choice = menuwin.getch()
        
        # Handle navigation
        if choice == curses.KEY_UP:
            if highlight > 0:
                highlight -= 1
        elif choice == curses.KEY_DOWN:
            if highlight < len(choices) - 1:
                highlight += 1
        elif choice == enter_key:
            break
    
    # Display final choice
    stdscr.addstr(2, 3, f"You chose: {choices[highlight]}")
    stdscr.refresh()
    stdscr.getch()

if __name__ == "__main__":
    curses.wrapper(main)

It uses the up and down arrow keys to select from among a list of choices. Pressing the Enter key will write out a line at the top of the screen with a messaging about which menu option was selected.

menu1.py screenshot

Pressing any key after that will exit the application.

Resources