A couple months ago I wrote a script that needed to take a CSV file with ~500k lines and process records from each of those lines. I wanted some way to keep track of the progress, and logging out each line number on a separate line was filling my console really fast. Even just outputting a single period without a newline was overwhelming. This led to me researching how to overwrite a console line. I decided to make a quick video about how to do it and try to share my knowledge.
The IO Module
If you’ve been working with Elixir for very long, you’ve probably seen
IO.inspect/2
. You can add it to your pipelines to be able to easily debug
output at various stages of processing. IO.inspect/2
is built on top of
IO.puts/1
which you may also be familiar with. Basically IO.puts/1
prints
whatever you tell it to in the console on a new line. This can be pretty useful
for debugging output as well.
What I’m excited to show you today is
IO.write/2
. I’ve been working
primarily in Elixir for two years now, but somehow I had still never used
IO.write/2
until pretty recently. IO.write/2
gives you the ability to print
onto the same line as your last output, rather than putting it on a new line.
This allows for some pretty cool tricks.
Tracking Time
To start with, let’s create a simple clock that will run in your terminal. You
can see the full code below, but we’ll create a Clock
module and create a
function inside of it simply called start
. This function will call
Time.utc_now()
to get the current UTC time. Then we’ll tell it that we only
care about seconds, so we’ll use Time.truncate/2
to discard any millisecond
and microsecond data. Finally we’ll convert the time struct to a string.
defmodule Clock do
def start do
Time.utc_now()
|> Time.truncate(:second)
|> Time.to_string()
end
end
Now here’s where IO.write/2
comes in. We’ll write the string out to the
terminal. Then we’ll use Process.sleep/1
to tell the process to sleep for 1
second until it’s ready to display the next time. Then we’ll recursively call
this same function so that it will create the loop that checks the clock every
second. Finally we’ll run our Clock.start/0
function outside of the module.
And now let’s run this in our terminal.
defmodule Clock do
def start do
...
IO.write(time_string)
Process.sleep(1_000)
start()
end
end
Clock.start()
You’ll notice that now it’s printing the current time on the same line as the
previous one, but we really want it to replace the current time instead. This is
where special characters
come in. You’ve likely seen the character for a newline (/n
). Maybe you’ve
seen the tab character as well (/t
). I’m guessing it’s likely though that you
haven’t seen the character for a carriage return. /r
is a carriage return, and
it returns the cursor to the beginning of the current line. When you use it you
can replace text on the current line. So we’ll add that and then our final code
looks like:
defmodule Clock do
def start do
time_string =
Time.utc_now()
|> Time.truncate(:second)
|> Time.to_string()
IO.write("\r#{time_string}")
Process.sleep(1_000)
start()
end
end
Clock.start()
Counting Down to the End
So now let’s try a different application. We’ll make a countdown: it will start
at 10 and work it’s way down to 0. When it reaches 0, we’ll print out that it’s
done. So for this, let’s make a module called Count
with a function called
count
that takes in the current number. Let’s make that function write the
current number to the terminal line, using our carriage return character to
overwrite what was already there. Then we’ll recursively call it again with the
decremented count. To slow things down and make it more obvious what’s going on,
I’m going to use the Process.sleep/1
function again. Now we need to handle the
base case as well and tell the program to stop when it hits 0, so let’s use
pattern matching to stop when the count is at 0. And finally let’s run that
function when this file gets run. Your code will look something like this:
defmodule Count do
def count(0) do
IO.puts("\nDone!")
end
def count(current) do
IO.write("\r#{current}")
Process.sleep(250)
count(current - 1)
end
end
Count.count(10)
Now if you try running that, you’ll notice something strange. The 0 from the 10 never goes away. That’s because when we use the carriage return, we’re telling it to overwrite what’s already there, but only as far as we actually write over it. If we don’t supply a character to replace the one that’s already there, it will leave it. So we’re going to need to add some blank spaces to overwrite the end characters. Since this is simply a countdown from 10 we can just add a single space to the end, but if it were to allow users to input whatever value they wanted, we would need to programmatically infer how many spaces to add. So if we run that again with the code below, it does what we expect.
defmodule Count do
def count(0) do
IO.puts("\nDone!")
end
def count(current) do
IO.write("\r#{current} ")
Process.sleep(250)
count(current - 1)
end
end
Count.count(10)
Great Progress
That’s most of what I wanted to show you, but I want to end by showing you one of the coolest applications for this. This example is a little bit more difficult to follow, but it will allow you to add a simple progress bar to a CLI tool. My friend Trevor Fenn implemented it in Ruby for some stuff we’re doing at Podium, so I’ve ported it over to Elixir for this example.
To start off we’re going to make a module called Progress
containing a
function called bar
. Let’s have bar
receive two parameters: count
and
total
. I’m going to add a private percent_complete
function that takes in
the same count
and total
and we’ll calculate the actual percentage here. So
let’s start by dividing the count by the total which gives us the decimal
percentage, and then we’ll multiply that by 100 to give us a more human readable
percentage. Then finally let’s round it to the nearest hundredth so that it
doesn’t get too big. I’m going to use a module attribute constant for the
rounding precision though so that it’s easily changeable if we want to do that
later.
defmodule Progress do
@rounding_precision 2
def bar(count, total) do
end
defp percent_complete(count, total) do
Float.round(100.0 * count / total, @rounding_precision)
end
end
OK now that we have the percentage complete, we need to actually create the
progress bar from that. So how this progress bar will work is we’re going to
have two different unicode characters that we’re using. The first is a halftone
box (░) and the other is a filled box (█). We’ll want to show the filled box the
number of times that it takes to show the current progress and then the halftone
box for the rest of it. So I’m going to create another module attribute constant
called @progress_bar_size
to say how many characters long the progress bar
should be. I think I’m going to say 50 for now to simplify the math.
@progress_bar_size 50
So to figure out how many percentage point each character is worth I will divide 100 by the progress bar size, so in this case, each character is worth 2% of the total. Then we’ll calculate how many completed characters we should write to the line and then subtract that from the total progress bar size to know how many incomplete characters to write.
def bar(count, total) do
percent = percent_complete(count, total)
divisor = 100 / @progress_bar_size
complete_count = round(percent / divisor)
incomplete_count = @progress_bar_size - complete_count
end
In the video, I created a new function called repeat
that returns a string
with the specified number of that character. When I showed my code to Trevor
though, he showed me that there is a String.duplicate/2
that will do exactly
what I was going for, so we’ll use that to generate the full progress bar. We’ll
start by writing the repeated completed characters and then we’ll append the
incomplete characters onto that. And finally let’s write the percent complete at
the end to make it a little more usable.
defmodule Progress do
...
@complete_character "█"
@incomplete_character "░"
def bar(count, total) do
...
complete = String.duplicate(@complete_character, complete_count)
incomplete = String.duplicate(@incomplete_character, incomplete_count)
"#{complete}#{incomplete} (#{percent}%)"
end
So now let’s actually run it. Outside of the module let’s say we have a total of
50 tasks to complete. So we’ll call Enum.each
over a range from 1 to 50 and
with each number we can generate a progress bar with the current task number and
the total. Let’s go ahead and write this to the console with a carriage return
at the front to tell it to overwrite the previous progress bar. Then to make it
a little more visible, let’s add a Process.sleep/1
to each iteration. And then
at the end let’s write a newline so that when the execution finishes it moves
our cursor down to the next line.
defmodule Progress do
...
end
total = 50
Enum.each(1..total, fn task ->
IO.write("\r#{Progress.bar(task, total)}")
Process.sleep(50)
end)
IO.write("\n")
So overall, you’re looking at something like this.
defmodule Progress do
@rounding_precision 2
@progress_bar_size 50
@complete_character "█"
@incomplete_character "░"
def bar(count, total) do
percent = percent_complete(count, total)
divisor = 100 / @progress_bar_size
complete_count = round(percent / divisor)
incomplete_count = @progress_bar_size - complete_count
complete = String.duplicate(@complete_character, complete_count)
incomplete = String.duplicate(@incomplete_character, incomplete_count)
"#{complete}#{incomplete} (#{percent}%)"
end
defp percent_complete(count, total) do
Float.round(100.0 * count / total, @rounding_precision)
end
end
total = 50
Enum.each(1..total, fn task ->
IO.write("\r#{Progress.bar(task, total)}")
Process.sleep(50)
end)
IO.write("\n")
So there you have it: a pretty simple, but pretty cool progress bar for CLI applications. If you end up using this somewhere please let me know. 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’m hoping to put out more 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. Thanks for reading!