TECH

Basics of curses library in Ruby - Make awesome terminal apps!

blogpost

Did you ever use Vim, top or its better alternative, htop? Immortal Midnight Commander? Or maybe the more user friendly version of du, ncdu? They are all terminal applications working in full screen mode, even with mouse support! Have you ever wondered, how these are made? Now you have chance to find out yourself or at the very least learn the basics. You don't have to stop there, though - the whole new world of TUI (text-based user interface) is waiting for you!

In this article you will learn how to use ruby port of curses library through writing of simple interface to ssh command.

Some knowledge

In terminal applications content and information how to display it are mixed together. Escape sequences (also know as control sequences) are part of the text data. These are certain series of bytes, that tell display mechanism to move the cursor, decorate text or change its color. You can try it yourself by using echo command with -e option which enables parsing of escape sequences.

echo -e '\e[32mIt is working!\e[0m'

Or in more ruby way in irb:

puts "\e[32mIt is working!\e[0m"
\e - escape character
\e[31m - green text
\e[0m  - reset all attributes

All of the output styling is being made by these sequences. Technically, this way we can draw all TUI, but fortunately curses library will do it for us.

One more thing. Earlier I mentioned full screen apps. There is something like alternate screen buffer. It's a terminal mode without ability to scroll back to previously displayed output. And of course, it takes up whole available screen - like vim. You guessed it right! There is a special control sequence to turn it on.

The curses library

The first version of curses library was made by Ken Arnold from University of California as a part of BDS Unix system and came out in late 70s. It's purpose was to deliver a friendly and unified interface for all types of terminals and it was even used to create video games, among them the genre-definining cult favourite - Rogue. The library itself (in the shape of new implementation like NCurses or PDCurses) is still popular and boasts a numerous ports to a lot of programming languages. PDCurses as a "module Curses" was a part of Ruby standard library up to 2.1.0 version, when it was exported to the separate gem.

Let's start!

Do you use ssh a lot? Do you have dozens of host records in ssh config file? I do! Wouldn't that be great if your had a comfy interface for choosing hosts in terminal?

First of all, we need some data. Let's pull them from config file.

ssh_config_path = File.expand_path('~/.ssh/config')
config_arr = File.read(ssh_config_path).lines # We read config content and split it by line

aliases =
  config_arr
    .grep(/^Host/) # we need only lines with host names
    .map do |line|
      line.sub(/^Host/, '').strip # remove Host str and unnecessary white characters
    end.sort # and sort result, because why not?

And now we have an array of ssh aliases. We can also write it in more concise form.

config = File.read(File.expand_path('~/.ssh/config')).lines
aliases = config.grep(/^Host/).map { |line| line.sub(/^Host/, '').strip }.sort

Now that we already have our data, we can move on to implementing the interface

TUI

Let' start by initializing the library:

#!/usr/bin/env ruby

require 'curses'

include Curses

To use this library we can invoke commands directly on Curses module or include it so it's methods become publicly available. For the sake of convenience and due to the fact that we don't risk the naming conflicts (our logic is really just 2 lines of code) we will go with the latter. Next, to put the library into working order, we need to set some configuration options

init_screen # Initializes a standard screen. At this point the present state of our terminal is saved and the alternate screen buffer is turned on
start_color # Initializes the color attributes for terminals that support it.
curs_set(0) # Hides the cursor
noecho # Disables characters typed by the user to be echoed by Curses.getch as they are typed.

Before we start utilizing colors we need to define them. In Curses, color are defined as foreground-backround pairs, so we define the first one as red text (which corresponding number is 1) on black background (nr 0). Exact shade and number of available colors depend on your terminal's settings. Most terminals are set to 256 colors mode by default, but in many cases (eg. ITerm2, Gome Terminal, konsole, Termin, alacritty) it is possible to turn on truecolour mode, which enables 16 million colors. Traditionally (as was the case with first "color" termimnals) simple apps are made with 8 basic colors: 0 - black, 1 - red, 2 - green, 3 - yellow, 4 - blue, 5 - magenta, 6 - cyan, 7 - white. Full list available colors can be found here: link

init_pair(1, 1, 0)

It's very important to restore the previous settings of the terminal after our application closes, or it will glitch our whole window. You have surely seen before the wall of weird broken text after application crashes. This is exactly because the previous setting weren't properly restored. Hence, we close our logic in the ensure block.

begin
  # logic
ensure
  close_screen # this method restore our terminal's settings
end

We will base our application on a loop that will refresh the view after our every action. The core of the app will look like this:

loop do
  # draw screen
  # wait for input
  # change data
end

Before we move on, we're gonna need one more thing - the window in which we will draw.

Curses::Window.new(0, 0, 1, 2)

These four arguments represent height, width and top/left placement respectively. Zeros in the first two will cause the window to set maximum width and height for itself, while 1,2 give us a neat margin.

After all this, our code should look like this:

#!/usr/bin/env ruby

require 'curses'

include Curses

init_screen
start_color
curs_set(0)
noecho

init_pair(1, 1, 0)


begin
  win = Curses::Window.new(0, 0, 1, 2)

  loop do
  end
ensure
  close_screen
end

Let's proceed to gathering data and setting up some basic config.

config = File.read(File.expand_path('~/.ssh/config')).lines
ALIASES = config.grep(/^Host/).map { |line| line.sub(/^Host/, '').strip }.sort

MAX_INDEX = ALIASES.size - 1
MIN_INDEX = 0

@index = 0 # currently chosen element

Time to draw!

win.setpos(0,0) # we set the cursor on the starting position

ALIASES.each.with_index(0) do |str, index| # we iterate through our data
  if index == @index # if the element is currently chosen...
    win.attron(color_pair(1)) { win << str } #...we color it red
  else
    win << str # rest of the elements are output with a default color
  end
  clrtoeol # clear to end of line
  win << "\n" # and move to next
end

win.attron(color_pair(1)) { win << str } Thanks to this, content of the block is encompassed in the control sequences that use our previously defined color pairs.

But why exactly do we need clrtoeol? Here's the catch. Once written, our text stays on the screen forever. What Cruses does, is merely keeping track of the changes, leaving rest of the screen untouched. This is a very good approach, performance-wise. That's also how GIFs or movies are "drawn", where consecutive frames only store info about changing pixels. Keeping that in mind, we need to take care of proper screen redrawing.

As a matter of fact, there is a method clear that clears our whole screen, leaving us with the clean sheet, but I strongly discourage using it as a fixed step in our loop, because it can severely hurt the performance. This may not pose big of a problem for state monitoring apps that refresh every second, but in the case of scrolling lists with continuous button press we would see lags during redrawing.

Now we must take care of clearing the rest of the screen. To be honest this is not crucial in our application because the size of our data stays the same and the lower part of the screen will be clear regardless, but for educational purposes we will do it anyway.

(win.maxy - win.cury).times {win.deleteln()}

And now for what's most important: we will tell Curses to implement our changes

win.refresh

This is another trick for optimalization purposes. We can make multiple changes in our displayed content, but they will not be written out before we consider given "view" to be ready. Thanks to this we avoid constant pushing of small changes in favour of more efficient, less frequent updates. This is what we call "bufforing".

Not we're gonna handle the input.

str = win.getch.to_s # Reads and returns a character
case str
when 'j'
  @index = @index >= MAX_INDEX ? MAX_INDEX : @index + 1 # we move our index indicator down
when 'k'
  @index = @index <= MIN_INDEX ? MIN_INDEX : @index - 1 # we move our index indicator up
when '10' # 10 corresponds to 'enter' button
  @selected = ALIASES[@index]
  exit 0
when 'q' then exit 0
end

Now for the last remaining thing. When our alias has been chosen and we exit the app through the enter button, we need to fire the ssh command. To do so we will use the method exec, which replaces the current process with the externally called command.

# ...
ensure
  # ... curses clenup
  exec "ssh #{@selected}" if @selected
end

Our whole script should look like this:

#!/usr/bin/env ruby

require 'curses'

include Curses

config = File.read(File.expand_path('~/.ssh/config')).lines
ALIASES = config.grep(/^Host/).map { |line| line.sub(/^Host/, '').strip }.sort

MAX_INDEX = ALIASES.size - 1
MIN_INDEX = 0

@index = 0

init_screen
start_color

init_pair(1, 1, 0)
curs_set(0)
noecho


begin

  win = Curses::Window.new(0, 0, 1, 2)

  loop do
    win.setpos(0,0)

    ALIASES.each.with_index(0) do |str, index|
      if index == @index
        win.attron(color_pair(1)) { win << str }
      else
        win << str
      end
      clrtoeol
      win << "\n"
    end
    (win.maxy - win.cury).times {win.deleteln()}
    win.refresh

    str = win.getch.to_s
    case str
    when 'j'
      @index = @index >= MAX_INDEX ? MAX_INDEX : @index + 1
    when 'k'
      @index = @index <= MIN_INDEX ? MIN_INDEX : @index - 1
    when '10'
      @selected = ALIASES[@index]
      exit 0
    when 'q' then exit 0
    end
  end
ensure
  close_screen
  exec "ssh #{@selected}" if @selected
end

Now that you acquired some theory and basics of practical usage, I strongly encourage you to delve into the world of TUI and create your own tools that will make the interactions with terminal pleasant. (But really, what I'm counting on is that you will create some neat games that I could play at work).

Some interesting links as an addendum:

Ruby Curses module documentation: https://ruby-doc.org/stdlib-2.0.0/libdoc/curses/rdoc/Curses.html

List of control sequences: https://en.wikipedia.org/wiki/ANSI_escape_code http://ascii-table.com/ansi-escape-sequences.php

Wikipedia entry for Rogue game: https://en.wikipedia.org/wiki/Rogue_(video_game)

Manual of the original curses library: https://www.mirbsd.org/htman/i386/manPSD/19.curses.htm

PDCurses website: http://pdcurses.org/

Read more on our blog

Check out the knowledge base collected and distilled by experienced
professionals.
bloglist_item
Tech

Over the years I had to deal with applications and system that have a long history of already being "legacy".
On top of that I met with clients/product owners that never want you to spend time ref...

bloglist_item
Tech

How many times have you searched for that one specific library that meets your needs? How much time have you spent customizing it to fit your project's requirements? I must admit, waaay too much. T...

Powstańców Warszawy 5
15-129 Białystok

+48 668 842 999
CONTACT US