Cool CLIs in Elixir (Part 2) with IO.ANSI

Mar 12, 2019

In the last post, I talked about using IO.write/2 to create cool CLIs. After writing that post, I got lots of great feedback along with recommendations for further topics. So that’s exactly what I’m doing. IO.write/2 is a cool function, but the real work there is done by the carriage return (\r). Carriage returns are a control character that let you reset the cursor to the beginning of the current line of text, similar to how an old-fashioned typewriter works. There are many other control characters that do a similar job. If you want to learn more about the history of how terminals and command line interfaces came about, I highly recommend this Crash Course on Keyboards & Commend Line Interfaces.

ANSI escape sequences are similar to carriage returns: they’re sequences of bytes that can be output to control the terminal. Almost 50 years ago they were implemented as a standard, and allow you to move the cursor to the front of the current line similar to the carriage return. They have a lot more functionality on top of that though. ANSI escape sequences are a little more complicated to remember and type. They look something like this: \u001b[0m. Good luck remembering that one! Luckily for us, Elixir has a wonderful library built in to the language that abstracts away the need to remember the sequences: IO.ANSI.

Colors

One of the most common uses of IO.ANSI is to change the color of text in a terminal output. You can see that when you run mix test and the line of green dots crosses the screen, or when an exception is thrown and the error and stacktrace show up in red. Well, let’s replicate some of that functionality.

We’ll start with the green. Let’s create a module called Color that has a green function. It will accept the text to make green and return a string with the green text. To do that, we’ll just prepend IO.ANSI.green/0 to the string that we want to be green. Then outside of the module we can call our function so that when we run elixir color.ex it will run the function. Make sure that you use IO.puts/1 or IO.write/1 to output your text because just calling the function won’t display anything.

Now save the file and run it in your terminal with elixir color.ex (assuming you named your file color.ex) and you’ll notice that after you run the program, the next line in your terminal continues to be green. That’s because the terminal will stick with that color until it’s told otherwise. To combat that, we’ll need to use IO.ANSI.reset/0 in our green/1 function:

defgreen(text)doIO.ANSI.green()<>text<>IO.ANSI.reset()end

Now things should work as planned! So we can copy this same pattern for red text:

More advanced terminals support up to 256 colors. That would be a lot of colors to have a function for each one, so this is implemented with IO.ANSI.code/1. It allows us to specify a code between 0 and 255 to select a color. Let’s check out all the possibilities:

All of those colors can also be used with IO.ANSI.color_background/1 to set the background color. You can even set a background and foreground for the same text. If you don’t want have to write that code every time to see the color codes, you can use this graphic.

Docker Compose

Docker Compose is a tool that allows you to control several Docker containers from one place. I noticed that when you run docker-compose up with multiple Docker containers defined, those containers will be started up in parallel, not necessarily finishing in the order they started. In the command line, you will see that it says that it’s starting all of the images, and then one by one will print done next to the ones that finished.

Notice these aren’t on the same line, so somehow Docker is printing over previous lines. That’s more than a simple carriage return, but luckily IO.ANSI is up to the task. Let’s start out by creating a Docker module with an up function that will “start” three different apps.

Now we need to display when each app is done “spinning up”. And to make it obvious that we’re updating these after the fact, we’ll sleep the process and update them in a different order. To do this, we’ll first want to set up a module attribute to contain the green “done” text:

Then we can create a line_done/1 function that will take in the line number that completed and append the “done” text to the end of it. We’ll start by sleeping for a half second so we can see the work being done. Then we’ll move the cursor to the end of the line we want to modify and write the text. Finally we’ll need to move the cursor back to the starting position so we know where it is and then write the whole thing to the console:

defmoduleDockerdo...defpline_done(line)doProcess.sleep(500)offset=4-lineoffset|>IO.ANSI.cursor_up()# move the cursor up to the line we want to modify|>Kernel.<>(IO.ANSI.cursor_right(30))# move the cursor to the end of the line (30 chars)|>Kernel.<>(@done_text)# write the done text|>Kernel.<>("\r")# move the cursor to the front of the line|>Kernel.<>(IO.ANSI.cursor_down(offset))# move the cursor back to the bottom|>IO.write()endend...

So now your whole file should look something like this:

defmoduleDockerdo@done_textIO.ANSI.green()<>"done"<>IO.ANSI.reset()defupdoIO.puts"Creating network \"dgraph_default\" with the default driver"IO.puts"Creating dgraph_zero_1 ... "IO.puts"Creating dgraph_server_1 ... "IO.puts"Creating dgraph_ratel_1 ... "1..3|>Enum.shuffle()|>Enum.each(&line_done/1)enddefpline_done(line)doProcess.sleep(500)offset=4-lineoffset|>IO.ANSI.cursor_up()# move the cursor up to the line we want to modify|>Kernel.<>(IO.ANSI.cursor_right(30))# move the cursor to the end of the line (30 chars)|>Kernel.<>(@done_text)# write the done text|>Kernel.<>("\r")# move the cursor to the front of the line|>Kernel.<>(IO.ANSI.cursor_down(offset))# move the cursor back to the bottom|>IO.write()endendDocker.up()

If you want a followup exercise, randomize the finish state of each app so that it can either be a green “done” or a red “error”. You could also make the app list dynamic so that even with 12 lines you’re writing the status on the proper line.

Downloader

The final example this post will cover is covering the terminal (see what I did there?). IO.ANSI has a nifty function called cover/0 that will let you cover the entire terminal window in the current background color. So we’re going to use that to make a nifty “downloader” UI. Let’s start by creating a screen for specifying a save filename. We’ll create a module called Downloader and give it a function called get_filename/0 that will allow us to capture the filename. We’ll start with just covering the screen in our specified background color:

Now if you run that you’ll see that once the program ends your terminal is still covered in the purple background color (unless you chose a different number instead of 53). So to take care of that, let’s add a new function called reset/0 that will reset the ANSI settings and then clear the screen at the end of getting the filename:

If you try running it now you probably won’t actually see anything in your terminal because we’re clearing it right after painting on it. Well let’s change that by printing our label and input and then using IO.read/1 to wait for the user to enter a filename:

defmoduleDownloaderdo...@label_color15@input_color183@input_text_color53@input_size20@label_text"Filename"defget_filenamedodraw_background()draw_input()filename=IO.read(:line)# read the entire line when the user presses Enterreset()filenameenddefpdraw_inputdoIO.putsIO.ANSI.color(@label_color)<>@label_textIO.writeIO.ANSI.color_background(@input_color)<>input_box()IO.write"\r"<>IO.ANSI.color(@input_text_color)enddefpinput_boxdoString.duplicate(" ",@input_size)end...endDownloader.get_filename()|>IO.inspect(label:"Filename")

Now when you run this, you should see a purple screen with an input that says “Filename” above it. When you type something in and press enter you should get a line that looks like Filename: "name.ex\n". We don’t really want that newline there, so let’s go ahead and change the returning line of our get_filename/0 function to remove it. Elixir has a handy function built in to the String library that we can use. String.trim/1 will remove any whitespace from the beginning or end of a string:

...defget_filenamedo...String.trim(filename)end...

Now you may have noticed that the input is currently in the top left of the screen. Wouldn’t it be cooler if that were centered on the screen? Well, with IO.ANSI it’s simple to position our cursor and write somewhere else on the screen. The problem though, is that it isn’t easy to get the size of the terminal window. Erlang’s io library exposes io:rows/0 and io:columns/0 that are supposed to help with this. If you run in iex you should see something like this:

iex>:io.rows(){:ok,24}iex>:io.columns(){:ok,101}

Because of that I created the following program to try using this in our program:

When running this program I just get a MatchError because both functions return {:error,:enotsup}. According to the Erlang docs:

The function succeeds for terminal devices and returns {error, enotsup} for all other I/O devices.

It appears that in iex we’re exposing a terminal device, but by running elixir filename.ex we aren’t. After spending a lot of time trying to find a workaround, I decided to let tput do the work. tput is a standard Unix operating system command and so if you run tput cols in your terminal, it will display the number of columns in the window and tput lines will output the number of rows. And Elixir has a simple way to call out to another program: System.cmd/2. It takes a command and list of arguments and returns the exit status code and output.

If you know of any better way to get the terminal size in a program like this, please reach out and let me know and I’ll happily update this post. Otherwise, let’s use System.cmd/2 and tput at the bottom of our Downloader to get our window size:

Now let’s go ahead and take advantage of that info in our draw_input\0 function. We’ll get the total number of lines and divide by 2 to move our cursor halfway down the screen. Then we’ll subtract the input size from the total number of columns and divide that by 2 to move our cursor so that our label will be centered on the screen both vertically and horizontally and our input will be just below it:

...defpdraw_inputdo{rows,cols}=screen_size()# floor to get the line just above center when rows or cols is odd# truncate to convert float to integerrow=Float.floor(rows/2)|>trunc()column=Float.floor((cols-@input_size)/2)|>trunc()# move the cursor to that position and draw the labelIO.writeIO.ANSI.cursor(row,column)<>IO.ANSI.color(@label_color)<>@label_text# move the cursor down a line and draw the inputIO.writeIO.ANSI.cursor(row+1,column)<>IO.ANSI.color_background(@input_color)<>input_box()# move the cursor to the beginning of the inputIO.writeIO.ANSI.cursor(row+1,column)<>IO.ANSI.color(@input_text_color)end...

Now if you run the program your terminal should be all purple with an input in the middle of the screen! 🎉 There’s just one remaining problem: when you run you may notice that the filename line is printing about halfway down the screen. That’s because we put the cursor in the middle of the screen to draw the input and didn’t do anything to move it back. IO.ANSI comes in clutch with a home/0 function that does just that. Let’s go ahead and append it to the end of our reset/0 function:

And voilà! We now have a module that covers the screen to display an input and reads the input out of it. Your code should look something like this:

defmoduleDownloaderdo@background_color53@label_color15@input_color183@input_text_color53@input_size20@label_text"Filename"defget_filenamedodraw_background()draw_input()filename=IO.read(:line)# read the entire line when the user presses Enterreset()String.trim(filename)enddefpdraw_inputdo{rows,cols}=screen_size()# floor to get the line just above center when rows or cols is odd# truncate to convert float to integerrow=Float.floor(rows/2)|>trunc()column=Float.floor((cols-@input_size)/2)|>trunc()# move the cursor to that position and draw the labelIO.writeIO.ANSI.cursor(row,column)<>IO.ANSI.color(@label_color)<>@label_text# move the cursor down a line and draw the inputIO.writeIO.ANSI.cursor(row+1,column)<>IO.ANSI.color_background(@input_color)<>input_box()# move the cursor to the beginning of the inputIO.writeIO.ANSI.cursor(row+1,column)<>IO.ANSI.color(@input_text_color)enddefpinput_boxdoString.duplicate(" ",@input_size)enddefpdraw_backgrounddoIO.writeIO.ANSI.color_background(@background_color)<>IO.ANSI.clear()enddefresetdoIO.writeIO.ANSI.reset()<>IO.ANSI.clear()<>IO.ANSI.home()enddefpscreen_sizedo{num("lines"),num("cols")}enddefpnum(subcommand)docaseSystem.cmd("tput",[subcommand])do{text,0}->text|>String.trim()|>String.to_integer()_->0endendendDownloader.get_filename()|>IO.inspect(label:"Filename")

Curses

That’s everything I intend to cover in this post. Overall ANSI escape codes allow you to do some really cool stuff with terminal output, and IO.ANSI is incredibly helpful in allowing you to work with them. There are some drawbacks however: Scroll up after running the code in our last example, and you will notice that all the times we “cover” the screen we’re really just printing enough lines that all we see is the new background color. It never really goes away. We could hack that together using IO.write to write over each line with blank characters, but there’s a better solution: ncurses and specifically the ex_ncurses library. It allows you to build some really powerful command line applications, and even games.

I may write about it in a later post, but in the meantime check out these awesome examples:

Like last time, please let me know if you end up using this somewhere. Also please let me know if you have any feedback on the format of this post or the video. I especially want to know if there was somewhere in the examples that you got lost so that I can work on clarifying more in the future. You can find me on Twitter at dnsbty or on the Elixir Slack group with the same name. Or you can just let me know in the comments of the video above. I plan to continue releasing videos like this, so if you’re interested, please like the video and subscribe to my channel so that YouTube will let you know when they come out. You can also subscribe to my mailing list below or my Telegram channel for updates. Thanks for reading!

Like what you read? There's more where that came from. Subscribe to my email newsletter to get notified when I post again.