Curses Recipes

Dear Computer

Howtos

Curses Recipes

In milestone 3 of the spreadsheet project, you must create a textual-user interface (TUI) so the user can navigate the spreadsheet, enter formulas, and see their evaluation in real-time. One of the oldest and most common libraries for composing a TUI is Curses, which was used to build the original Rogue game. Curses was born in 1978! And you can tell. Curses isn't tidily organized into objects; it's a set of top-level functions with cryptically terse names. Some languages add an object-oriented layer around the original library.

The web includes more resources on the original C library or the Python port. Some of this information applies to Ruby, but some does not. Here we offer a few recipes for working with the Curses gem for Ruby. Consult the gem's documentation for a list of all available methods. If you are using Rust or Haskell, the recipes are still relevant, but you will need to translate some of the calls into the API of the Curses library you are using.

Initializing

The Curses library maintains several data structures that need to be initialized before we call any other Curses function. We initialize these data structures by calling init_screen and dispose of them by calling close_screen:

Ruby
require 'curses'

Curses::init_screen
# ... draw in the terminal
Curses::close_screen

When we run this program, we won't be able to tell that anything is happening. The program ends immediately. Make it wait for the user to press a key by calling getch:

Ruby
Curses::init_screen
Curses::getch
Curses::close_screen

Run this and we will see the terminal clear and silently pause.

If we want to be able to call Curses functions without qualifying them, mix in the Curses module with include:

Ruby
include Curses

init_screen
getch
close_screen

Printing Text

Curses lets us place the cursor anywhere in the terminal and write characters. For example, these two calls draw some text at row 1, column 5 of the terminal:

Ruby
setpos(1, 5)
addstr("total:")

As the user interacts with a spreadsheet, we will need to overwrite text. The clear function wipes the entire window, which is extreme. The delch function wipes a single character, which is tedious. The deleteln function deletes from the cursor to the end of the line, which may damage other precious text. There's no function to wipe an intermediate span. Our best bet is to form a string that's as wide as the text we want to overwrite, padding it with spaces if necessary. Ruby's ljust and rjust methods are handy for this. Here the label is padded out with spaces to a width of 12 and left-justified:

Ruby
addstr("total:".ljust(12, ' '))

Refreshing

This program attempts to display a counter from 0 through 999:

Ruby
1000.times do |i|
  setpos(0, 0)
  addstr(i.to_s.rjust(10, ' '))
  sleep(0.001)
end

Try running it. The actual behavior is disappointing.

When we call addstr, we only change the backing data structure that Curses maintains. That data structure is not automatically imprinted on the terminal itself. One way to force the imprint is to call refresh:

Ruby
1000.times do |i|
  setpos(0, 0)
  addstr(i.to_s.rjust(10, ' '))
  refresh
  sleep(0.001)
end

We don't need to call refresh if our next action is getting user input. The getch function implicitly calls it.

Subdividing Terminal into Windows

By default, the whole terminal window is our drawing canvas. It is addressed by a global coordinate system, with the origin at the top left corner. We query its height with Curses.lines and its width with Curses.cols. Sometimes interfaces are logically subdivided into panels. Wouldn't it be nice if each panel had its own coordinate system? Then we could update its contents using local offsets instead of global addresses.

Curses does support subdividing into self-contained windows. The Ruby port encapsulates the subdivision logic in its Window class. This snippet creates a two-column display and plots a string in each column:

Ruby
width = Curses.cols
height = Curses.lines

left_panel = Window.new(height, width / 2, 0, 0)
left_panel.setpos(0, 0)
left_panel.addstr("costs")
left_panel.refresh

right_panel = Window.new(height, width / 2, 0, width / 2)
right_panel.setpos(0, 0)
right_panel.addstr("benefits")
right_panel.refresh

The Window constructor expects four parameters: a height, a width, the global index of the top row, and the global index of left column.

Once we've switched to a windowed layout, we should stop calling the global versions of the methods setpos, addstr, refresh, clear, and getch. Instead we should call these methods on window receivers.

Event Loop

Generally we want the TUI to keep displaying and responding to user events until some quitting event occurs. We therefore write an event loop. At this point, organizing our interface into a class is probably a good idea. The main event loop is one of its behaviors. For example, this interface class runs its main event loop until the user hits the Q key:

Ruby
class Interface
  # ...

  def main_loop
    loop do
      c = @main_window.getch
      if c == 'q'
        break
      # elsifs for other event triggers
      end

      # draw and refresh affected windows
    end
  end

end

Each window that supports interaction should probably have its own event loop method. The main loop drives the application's lifecycle, but if it detects a trigger to change the active window, it calls the appropriate event loop method.

Detecting Keys

The getch method returns a keycode. For simple letters, numbers, and punctuation, we compare this keycode to character literals to see which key was pressed. Detecting other keys is more challenging.

The Esc key seems like an apt choice for exiting an event loop. Sadly, detecting it is challenging as its keycode is also used as a prefix in certain other key combinations.

Our terminal programs represent special keys as a sequence of keycodes. For example, a press on the left-cursor keys comes through as Esc, [, and D. To detect these special keys, we must ask Curses to preprocess the keycode stream and group any special sequence into a single virtual keycode. This is done by calling the keypad function on each window that supports input. For example:

Ruby
@left_panel.keypad(true)
@right_panel.keypad(true)

Curses defines constants for the virtual keycodes of these special keys. To detect a left cursor key, we might be able to write c == Key::LEFT. Unfortunately, these keycodes aren't always universal. If the builtin constants do not work, inspect the magic number returned by getch by printing it with addstr. This handy key inspector program does exactly that until the user enters Q:

Ruby
require 'curses'

include Curses

init_screen
noecho
curs_set(0)

input_panel = Window.new(1, 30, 0, 0)
input_panel.keypad(true)

loop do
  input_panel.setpos(0, 0)
  c = input_panel.getch
  if c == 'q'
    break
  else
    input_panel.clear
    input_panel.addstr(c.ord.to_s)
  end
end

input_panel.close
close_screen

Drawing Lines

The original Curses library has several handy functions for drawing lines. The Ruby gem doesn't include these. But we can recreate them. This function draws a horizontal line:

Ruby
def horizontal_line(window, row, start_column, end_column)
  (start_column...end_column).each do |column|
    window.setpos(row, column)
    window.addstr("\u2500")
  end
end

We favor drawing lines with the Unicode box-drawing characters over hyphens, pipes, and plus signs. The box-drawing characters produce more aesthetic solid lines.

Highlight with Color

Curses may be able to change the foreground and background colors of the text it prints. However, your terminal may not support color. On my Mac, Terminal, iTerm2, and Visual Studio Code all support color. In the past, Windows users have been less lucky.

To draw in color, we must first tell Curses to initialize its color data structures and register some foreground-background color combinations, like this:

Ruby
init_screen

start_color

init_pair(1, 0, 0)
init_pair(2, 1, 0)
init_pair(3, 2, 0)
init_pair(4, 3, 0)
init_pair(5, 4, 0)
init_pair(6, 5, 0)
init_pair(7, 6, 0)
init_pair(8, 7, 0)
init_pair(9, 8, 0)
init_pair(10, 9, 0)

This code calls init_pair to define ten color combinations, numbered 1–10. Pair 0 is the default combination, which we don't set. The first parameter to init_pair is the pair identifier, the second is the index of the foreground color, and the third is the index of the background color. The terminal decides to what colors these indices map. All the pairs here use a black (0) background color. Pair 1 renders black on black, which is impossible to read.

When we're ready to draw in color, we select a color pair with the attron and color_pair methods, like so:

Ruby
(0..10).each do |id|
  panel.setpos(id, 0)
  panel.attron(color_pair(id))
  panel.addstr("and yet")
  panel.attroff(color_pair(id))
  panel.addstr("...")
end

We restore the default with attroff.

Highlight without Color

If color doesn't work on our terminal, we can fall back on reverse video, which flips the foreground and background colors of the default color.

Ruby
panel.setpos(0, 0)
panel.attron(A_REVERSE)
panel.addstr("and yet")
panel.attroff(A_REVERSE)
panel.addstr("...")

Attribute A_BOLD is also widely supported.

Multi-line Editor

Earlier we saw how to get a single character of input with getch. What if we want to collect a whole string? We could call getstr, a builtin function that collects up characters with getch until a linebreak is entered. However, if we want to collect multiple lines of text, we need to write our own key-gobbling event loop.

Suppose we have a window named input_panel that acts just like a multiline form input on a webpage. When the window is focused, we'll run its event loop—which we keep separate from the main event loop. It accumulates the characters up in a string named text:

Ruby
text = '' 
loop do
  c = input_panel.getch
  if c == EXIT_KEYCODE
    break
  else
    text += c
  end     
end

The EXIT_KEYCODE constant is a placeholder. You need to decide what character ends the editing. Probably you don't want to use a normal printable character, since the user expects to be able to enter printable characters. Earlier we mentioned that ESC is challenging to detect because it's also used as a prefix in special key sequences. Enter is not a great choice either if you want to allow multiline input. Use the key inspector program to find the keycode for the key you want to use.

When you run this event loop, you'll notice a problem if you hit BACKSPACE. It appears as gibberish. Three actions will fix that: we disable automatic echoing of the pressed key with noecho, we explicitly print text on each iteration of the loop since Curses is no longer automatically echoing, and we respond to BACKSPACE by chopping off the last character from our string:

Ruby
noecho

text = '' 
loop do
  input_panel.clear
  input_panel.setpos(0, 0)
  input_panel.addstr(text)

  c = input_panel.getch
  if c == EXIT_KEYCODE
    break
  elsif c == 127
    text.chop!
  else
    text += c
  end     
end

BACKSPACE may have a different keycode in your terminal application. Use the key inspector to check.

This rudimentary editor allows only the most recently entered character to be deleted. It's effectively a stack. A more sophisticated editor would allow the cursor to be moved around with the cursor keys. It also collects only a single of text. To collect and display multiple lines, we need to print the string one line at a time and add a case for the ENTER key:

Ruby
text = '' 
loop do
  input_panel.clear
  text.lines.each_with_index do |line, i|
    input_panel.setpos(i, 0)
    input_panel.addstr(line)
  end

  c = input_panel.getch
  if c == EXIT_KEYCODE
    break
  elsif c == 127
    text.chop!
  elsif c == 10
    text += "\n"
  else
    text += c
  end     
end

The linefeed character has keycode 10 in the ASCII standard. On Windows, you may see a carriage return character (with keycode 13) before the linefeed. Probably you want to discard it.

← Managing Your RepositoryInstalling Ruby →