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/