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
:
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
:
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
:
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:
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:
addstr("total:".ljust(12, ' '))
Refreshing
This program attempts to display a counter from 0 through 999:
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
:
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:
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:
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:
@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:
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:
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:
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:
(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.
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
:
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:
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:
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.