My take on the world of coding

Using Ruby Curses

Overview

I am currently working on a project where I am rewriting an app I wrote five years ago. I originally write it in AutoIt. I have fallen hard for Ruby, and my application needed to be updated… So, I decided why not just give it the royal Ruby treatment?

Requirements

The project that I am working on is being developed to run on Windows and is going to be distributed as a packaged executable.

GUI Options

I implemented a GUI in my original application, and I wanted to do the same in the new version. I looked at many different Ruby GUI implementations, including: FXRuby, Shoes!, Visual Ruby and wxRuby.

Visual Ruby was the closest to fitting my needs, but it requires GTK+ to be installed on the machine that is running the application. That does not work, because I need there to be no prerequisites, as the project is going to be portable. I ended up trying Curses, which at first glance seems like a huge undertaking. After I started reading some examples and playing with code though, I found it is actually pretty simple to use.

By the way, I believe that Ncurses is possibly easier to use, and provides more built-in functionality. Ncurses has prerequisites that must be installed on the machine running the application though, which would make my application not portable. That is why I went with Curses.

Packager

I also played with multiple executable packaging options. I found Releasy to have tons of nice options, but for some reason it wanted to package every gem I have installed, not just the ones my application uses. Releasy is actually built on Ocra, so I gave that a shot. Ocra worked perfectly. That is what I am using, at least for now. I will probably go back and try to figure out how to make Releasy only package the needed gems, as I am sure there is probably an option that I missed in the documentation that achieves this.


Curses

I thought I would try to help any other Curses newbies that are looking for a tutorial or a useful example, which is why I am writing this blog post.

Example script final output

The code

I wrote a small (but very useful as a reference for my current project) example application, so I can explain it line by line and hopefully help to make Curses a little easier to understand and use.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
require 'curses'

# Setup magic numbers
SCREEN_HEIGHT      = 24
SCREEN_WIDTH       = 80
HEADER_HEIGHT      = 1
HEADER_WIDTH       = SCREEN_WIDTH
MAIN_WINDOW_HEIGHT = SCREEN_HEIGHT - HEADER_HEIGHT
MAIN_WINDOW_WIDTH  = SCREEN_WIDTH

Curses.noecho
Curses.nonl
Curses.stdscr.keypad(true)
Curses.raw
Curses.stdscr.nodelay = 1

Curses.init_screen

Curses.start_color

Curses.init_pair(2, Curses::COLOR_BLACK, Curses::COLOR_GREEN)
Curses.init_pair(3, Curses::COLOR_BLACK, Curses::COLOR_WHITE)

main_window.scrollok(true)
main_window.idlok(true)

main_window.color_set(1)

main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

main_window.addch('T')
main_window.addch('h')
main_window.addch('i')
main_window.addch('s')
main_window.addch(' ')
main_window.addch('i')
main_window.addch('s')
main_window.addch(' ')
main_window.addch('p')
main_window.addch('a')
main_window.addch('i')
main_window.addch('n')
main_window.addch('f')
main_window.addch('u')
main_window.addch('l')
main_window.addch('!')

main_window.scroll

main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

main_window.scroll
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

main_window << "Wow, that is even easier!"

main_window.refresh

# Setup header
header_window = Curses::Window.new(HEADER_HEIGHT, HEADER_WIDTH, 0, 0)
header_window.color_set(2)
header_window << "Curses example".center(HEADER_WIDTH)
header_window.refresh

main_window.scroll
main_window.scroll
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)
main_window << "Press a key to get the ordinal value. Press 'q' to quit."

until (input = main_window.getch) == 'q'
  main_window.scroll
  main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

  main_window << "You pressed: #{input}."
  unless input.is_a?(Fixnum)
    main_window << " That character has an ordinal value of: #{input.ord}"
  end

  main_window.refresh
end

Code walkthrough

Documentation is from http://ruby-doc.org/stdlib-2.1.0/libdoc/curses/rdoc/Curses.html


The first thing that you need to do is import the curses library

1
require 'curses'

Using constants for your “magic numbers” makes layout easy. If you decide you want one of the windows one row taller you can change the entire application layout simply by adjusting the constant values.

1
2
3
4
5
6
7
# Setup magic numbers
SCREEN_HEIGHT      = 24
SCREEN_WIDTH       = 80
HEADER_HEIGHT      = 1
HEADER_WIDTH       = SCREEN_WIDTH
MAIN_WINDOW_HEIGHT = SCREEN_HEIGHT - HEADER_HEIGHT
MAIN_WINDOW_WIDTH  = SCREEN_WIDTH

We want to be able to use keystrokes in our application without displaying the entered keys on the screen (unless we manually add the keystroke to the output).

1
2
3
4
# noecho()
# Disables characters typed by the user to be echoed by ::getch as they are
# typed.
Curses.noecho

We want to be able to detect the return/enter key with ::getch.

1
2
3
4
5
6
7
8
9
10
11
# nl()
# Enable the underlying display device to translate the return key into newline
# on input, and whether it translates newline into return and line-feed on
# output (in either case, the call ::addch(‘n’) does the equivalent of return
# and line feed on the virtual screen).
#
# Initially, these translations do occur. If you disable them using ::nonl,
# curses will be able to make better use of the line-feed capability, resulting
# in faster cursor motion. Also, curses will then be able to detect the return
# key.
Curses.nonl

We want to be able to detect the arrow keys (useful for using the arrows in navigation).

1
2
3
4
5
6
7
8
9
10
11
12
13
# keypad(bool)
# Enables the keypad of the user’s terminal.
#
# If enabled (bool is true), the user can press a function key (such as an arrow
# key) and wgetch returns a single value representing the function key, as in
# KEY_LEFT. If disabled (bool is false), curses does not treat function keys
# specially and the program has to interpret the escape sequences itself. If the
# keypad in the terminal can be turned on (made to transmit) and off (made to
# work locally), turning on this option causes the terminal keypad to be turned
# on when #getch is called.
#
# The default value for keypad is false.
Curses.stdscr.keypad(true)

In raw mode, our application receives all keypresses when using the ::getch method. We are responsible for processing things such as control flow input, since the input will not be interpretted automatically in raw mode.

1
2
3
4
5
6
7
8
9
10
11
# raw()
# Put the terminal into raw mode.
#
# Raw mode is similar to ::cbreak mode, in that characters typed are immediately
# passed through to the user program.
#
# The differences are that in raw mode, the interrupt, quit, suspend, and flow
# control characters are all passed through uninterpreted, instead of generating
# a signal. The behavior of the BREAK key depends on other bits in the tty
# driver that are not set by curses.
Curses.raw

Turn getch into a non-blocking method. This is useful in a situation where you may or may not want to press a key, such as “press [c] to cancel”, during a file download. If ‘c’ is pressed, you can process it. Otherwise, the code continues to be processed.

1
2
3
4
5
6
7
# nodelay = bool
# When in no-delay mode #getch is a non-blocking call. If no input is ready
# getch returns ERR.
#
# When in delay mode (bool is false which is the default), #getch blocks until a
# key is pressed.
Curses.stdscr.nodelay = 1

Initialize the Curses screen.

1
2
3
# init_screen()
# Initialize a standard screen
Curses.init_screen

Enable color, if the user’s terminal supports it.

1
2
3
4
5
6
# start_color()
# Initializes the color attributes, for terminals that support it.
#
# This must be called, in order to use color attributes. It is good practice to
# call it just after ::init_screen
Curses.start_color

Create the color schemes that we will use throughout our application.

1
2
3
4
5
6
7
8
9
10
11
# init_pair(pair, f, b)
# Changes the definition of a color-pair.
#
# It takes three arguments: the number of the color-pair to be changed pair, the
# foreground color number f, and the background color number b.
#
# If the color-pair was previously initialized, the screen is refreshed and all
# occurrences of that color-pair are changed to the new definition.
Curses.init_pair(1, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
Curses.init_pair(2, Curses::COLOR_BLACK, Curses::COLOR_GREEN)
Curses.init_pair(3, Curses::COLOR_BLACK, Curses::COLOR_WHITE)

Create a window within the Curses screen that we initialized.

1
2
3
4
5
6
7
8
# new(height, width, top, left)
# Construct a new Curses::Window with constraints of height lines, width
# columns, begin at top line, and begin left most column.
#
# A new window using full screen is called as
#
# Curses::Window.new(0,0,0,0)
main_window = Curses::Window.new(24, MAIN_WINDOW_WIDTH, 1, 0)

Enable scrolling of the display.

1
2
3
4
5
6
7
8
9
10
11
# scrollok(bool)
# Controls what happens when the cursor of a window is moved off the edge of the
# window or scrolling region, either as a result of a newline action on the
# bottom line, or typing the last character of the last line.
#
# If disabled, (bool is false), the cursor is left on the bottom line.
#
# If enabled, (bool is true), the window is scrolled up one line (Note that to
# get the physical scrolling effect on the terminal, it is also necessary to
# call #idlok)
main_window.scrollok(true)

Enabled to get physical scrolling effect.

1
2
3
4
5
6
7
8
9
10
11
12
13
# idlok(bool)
# If bool is true curses considers using the hardware insert/delete line feature
# of terminals so equipped.
#
# If bool is false, disables use of line insertion and deletion. This option
# should be enabled only if the application needs insert/delete line, for
# example, for a screen editor.
#
# It is disabled by default because insert/delete line tends to be visually
# annoying when used in applications where it is not really needed. If
# insert/delete line cannot be used, curses redraws the changed portions of all
# lines.
main_window.idlok(true)

Apply the color scheme that we want main_window to use. The set number corresponds to the init_pair calls that we executed earlier.

1
2
3
4
# color_set(col)
# Sets the current color of the given window to the foreground/background
# combination described by the Fixnum col.
main_window.color_set(1)

Move the cursor to the position within the ‘main_window’ window, where we want the output to appear. We must use “MAIN_WINDOW_HEIGHT – 1” because the height of a window is one-based, but the row numbers are zero-based. That means that a window with 23 rows has rows number 0 through 22.

1
2
3
4
# setpos(y, x)
# A setter for the position of the cursor in the current window, using
# coordinates x and y
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

Output a sentence at the current cursor position, one character at a time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# addch(ch)
# Add a character ch, with attributes, to the window, then advance the cursor.
#
# see also the system manual for curs_addch(3)
main_window.addch('T')
main_window.addch('h')
main_window.addch('i')
main_window.addch('s')
main_window.addch(' ')
main_window.addch('i')
main_window.addch('s')
main_window.addch(' ')
main_window.addch('p')
main_window.addch('a')
main_window.addch('i')
main_window.addch('n')
main_window.addch('f')
main_window.addch('u')
main_window.addch('l')
main_window.addch('!')

Scroll everything that is currently displayed up one line.

1
2
3
# scroll()
# Scrolls the current window up one line.
main_window.scroll

Reset cursor again.

1
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

Output a string all at once.

1
2
3
# addstr(str)
# add a string of characters str, to the window and advance cursor
main_window.addstr('Ahhh, that is better.')

Scroll the display and reset the cursor again.

1
2
main_window.scroll
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

This is another way to output an entire string all at once.

1
2
3
4
5
# <<(str)
# <<
#
# Add String str to the current string.
main_window << "Wow, that is even easier!"

Refresh the display of the ‘main_window’ window. This is required after you are done adding to the output. Curses windows are not automatically refreshed.

1
2
3
# refresh()
# Refreshes the windows and lines.
main_window.refresh

Initialize and display a second window, for use as a header.

1
2
3
4
5
# Setup header
header_window = Curses::Window.new(HEADER_HEIGHT, HEADER_WIDTH, 0, 0)
header_window.color_set(2)
header_window << "Curses example".center(HEADER_WIDTH)
header_window.refresh

Each time the user presses a key, display the character and ordinal value in ‘main_window’. When the user presses ‘q’, exit instead. Before outputting, the window is scrolled and the cursor is reset. After the output, we have to make sure the window is refreshed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
main_window.scroll
main_window.scroll
main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)
main_window << "Press a key to get the ordinal value. Press 'q' to quit."

until (input = main_window.getch) == 'q'
  main_window.scroll
  main_window.setpos(MAIN_WINDOW_HEIGHT - 1, 0)

  main_window << "You pressed: #{input}."
  unless input.is_a?(Fixnum)
    main_window << " That character has an ordinal value of: #{input.ord}"
  end

  main_window.refresh
end

OOP version

Here is an object-oriented version, for comparison

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
require 'curses'

class CursesScreen
  SCREEN_HEIGHT      = 24
  SCREEN_WIDTH       = 80
  HEADER_HEIGHT      = 1
  HEADER_WIDTH       = SCREEN_WIDTH
  MAIN_WINDOW_HEIGHT = SCREEN_HEIGHT - HEADER_HEIGHT
  MAIN_WINDOW_WIDTH  = SCREEN_WIDTH

  def initialize
    Curses.noecho
    Curses.nonl
    Curses.stdscr.keypad(true)
    Curses.raw
    Curses.stdscr.nodelay = 1
    Curses.init_screen
    Curses.start_color
    Curses.init_pair(1, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
    Curses.init_pair(2, Curses::COLOR_BLACK, Curses::COLOR_GREEN)
    Curses.init_pair(3, Curses::COLOR_BLACK, Curses::COLOR_WHITE)
  end

  def build_display
    @header_window = HeaderWindow.new
    @header_window.build_display
    @main_window = MainWindow.new

    @main_window.addch_example
    @main_window.scroll
    @main_window.addstr_example
    @main_window.scroll
    @main_window.push_example
    @main_window.scroll
    @main_window.echo_keys
  end
end

class MainWindow
  def initialize
    @window = Curses::Window.new(24, CursesScreen::MAIN_WINDOW_WIDTH, 1, 0)
    @window.scrollok(true)
    @window.idlok(true)
    @window.color_set(1)
    @window.setpos(CursesScreen::MAIN_WINDOW_HEIGHT - 1, 0)
  end

  def addch_example
    @window.addch('T')
    @window.addch('h')
    @window.addch('i')
    @window.addch('s')
    @window.addch(' ')
    @window.addch('i')
    @window.addch('s')
    @window.addch(' ')
    @window.addch('p')
    @window.addch('a')
    @window.addch('i')
    @window.addch('n')
    @window.addch('f')
    @window.addch('u')
    @window.addch('l')
    @window.addch('!')
  end

  def addstr_example
    @window.addstr('Ahhh, that is better.')
  end

  def push_example
    @window << "Wow, that is even easier!"
  end

  def scroll
    @window.scroll
    @window.setpos(CursesScreen::MAIN_WINDOW_HEIGHT - 1, 0)
  end

  def echo_keys
    self.scroll
    @window << "Press a key to get the ordinal value. Press 'q' to quit."
    until (input = @window.getch) == 'q'
      self.scroll

      @window << "You pressed: #{input}."
      unless input.is_a?(Fixnum)
        @window << " That character has an ordinal value of: #{input.ord}"
      end

      @window.refresh
    end
  end
end

class HeaderWindow
  def initialize
    @window = Curses::Window.new(CursesScreen::HEADER_HEIGHT,
                                 CursesScreen::HEADER_WIDTH, 0, 0)
    @window.color_set(2)
  end

  def build_display
    @window << "Curses example".center(CursesScreen::HEADER_WIDTH)
    @window.refresh
  end
end

@screen = CursesScreen.new
@screen.build_display

Helpful tip

You may find yourself wanting to call Curses::Window methods on your MainWindow::@window object, from the CursesScreen context. Something like:

1
2
@main_window << 'some text'
@main_window.refresh

The MainWindow class does not respond to << though, so that is not going to work. All you need to do is implement the method_missing method.

1
2
3
def method_missing(m, *args)
  @window.send(m, *args)
end

Now calls that MainWindow does not respond to will be passed on to the Curses::Window class, in the MainWindow instance’s context.


The scripts I wrote for this example are also available on GitHub.

Comments