In Progress
Unit 1, Lesson 1
In Progress

Console Width

I’ve been deep in a book project lately, so this week I’ve got a little live-style digression from my project for you. Have you ever wondered how to tailor console output to the specific size of the user’s terminal? In this video you’ll learn how, using the io/console standard library!

Video transcript & code

So I've got this book project I've been working on. And recently I realized I wanted to get a sense of how long the various chapters were relative to each other. Are they all more or less the same length, or are they widely varying lengths? And so I wrote a little report in my Rakefile. A little report task that gives me this list of chapters and word counts, and then a little histogram next to them.

$ rake report
Name      Words
chapter01   181 
chapter02   391 #
chapter03   411 #
chapter04   307 #
chapter05   329 #
chapter06   520 #
chapter07  1668 #####
chapter08  1321 ####
chapter09  1439 #####
chapter10  2751 #########
chapter11  1705 #####
chapter12  2377 ########
chapter13  1306 ####
chapter14   553 #
chapter15  1398 ####
chapter16  1920 ######
chapter17  2351 ########
chapter18  2873 ##########
chapter19   531 #
chapter20   161

 

That gives me a relative sense of how long the chapters are . And I was looking at this and I was thinking, okay, this pretty much gives me what I need, but wouldn't it be fun i f when I'm on a wide terminal like this the histogram would grow to fill all the available space.

Right now, it's just hard coded to a maximum of 10 hash marks.  Before we go into how   to make that happen, let's take a look at the code.

task "report" do
  report = MANUSCRIPTS.map { |m|
    text = IO.read(m)
    {
      name: m.pathmap("%n"),
      word_count: text.scan(/\w+/).size,
    }
  }
  longest = report.map { |f| f[:word_count] }.max
  puts "Name      Words"
  report.each do |file|
    bar = "#" * (file[:word_count] / (longest / 10))
    printf "%-10s%5d %s\n", file[:name], file[:word_count], bar
  end
end

So there's a list of manuscripts. This is basically a list of file names.

It's a rake FileList, if you're familiar with that. We're mapping over this, to produce a new list. And inside the map we are reading in each file, and then we're creating a new hash . we're capturing.  Just the base name of the file without the directory or anything. And we're capturing a word count, and we're just using String#scan to capture the word count here.

We've got a regular expression pattern that looks for words. So that composes an array of hashes. And then we figure out which is the longest chapter by a mapping word counts. And then taking the max of that list. Then we output the column headers.

For each hash of file stats: first, we come up with the histogram bar. We do that with a string multiplication. And the number we multiply by is the result of this little algorithm here. Basically divide the longest into 10, divide the  current file's word count by that. And then we get  somewhere between zero and 10 hash marks.

The rest is just a printf. We've got some code here to say "left justified string for the filename. Right-justified decimal number for the word count. And then the rest after a space is the histogram bar. So that's what we have right now.

Okay. So what I'd love to see here is, again, I'd love to see these histogram bars grow to fill the screen and shrink to fit smaller screens. Is that possible? Well, spoiler: Yes, it is. And we're going to use a Ruby standard library to accomplish it.

So to understand how we're going to do this first, we need to understand what we're looking at right now. We're looking at the shell, right. Or we're looking at the terminal. Well, what we're really looking at is a shell running inside a terminal and from a programming language point of view, this is an interactive console.

Now is it always writing to an interactive console? No.  standard out is not always an interactive shell. Sometimes  it's a file. And Ruby does have a sense of the difference between these things.

So let's take a look here. We'll do a ruby -e to just evaluate something on the command line. And we'll say $stdout.tty? Oops. Let's make that dot. No, and let's actually remember to print it out. Okay. What does this say?

This says that standard out is a TTY . What is a TTY? Well, it's kind of an archaic UNIX-ism for "this is an interactive terminal". In the old days, this referred to a teletype device. But basically what it means is: this is not a file or just a raw byte stream that we're writing to. And we can see the difference if we do... let's do this.

We'll do... actually , we're gonna put a "warn" here because I need to still be able to write to the screen. But I'm going to be redirecting the  standard out of this program to /dev/null. So the reason I did the warn there is, is warn writes to standard error instead of standard out.

Okay. So what that just said  is standard out is no longer. A TTY in this case. Why? Because I said, redirect output to /dev/null standard out is not a smart terminal. It is just a stream. Now, when we have an interactive terminal, we have some more capabilities. One of those capabilities is finding out how wide and how tall the current terminal is.

And we can do that with a method called "windsize". There is no such method! ...but we can make it magically appear. We can do that by requiring "io/console".

Now we see 32 and 132. Let's get rid of the warn and let's  bring back our "p" because "p" always inspects objects. Ah, it's an array of 32 and 132. And I can tell you that this is the current height and width of this terminal. Okay. What just happened? Where we magically made that method appear, what was happening?

There is something that you often see in Ruby standard libraries, which is: rather than just adding new classes, a standard library will augment existing classes. So the io/console library augments the existing IO class with some new capabilities. And these are capabilities  that  only makes sense in the context of an interactive terminal.

Okay. So we know that we can use the IO console library to add the windsize method. And from there we can find out the width of the current terminal. It's the second element in the return from windsize.

Let's go back to our code.

Okay, we'll start right here. Let's go ahead and and require that io/console library. And then currently we're just putting. This header straight to standard out. Let's continue to do that, but let's also capture the header as we're doing it. And the reason we're going to do that is because we actually need to know how long the header is. Actually, we need to know how long are the first two columns? So we can subtract that out of the space that we have. And then we'll say our histogram width equals... and let's just put an "if" here because we need this to work in interactive terminals, but we also needed to work, when we're redirecting output to a file. So we need this to support both cases.

Let's do an "if" here and we'll say if standard out is a TTY, okay. Then we do some math, then we do, standard out dot windsize and we'll grab the the last piece of that array, which is the width. We will subtract the header size and we'll also subtract another 1 from there so that we have some space in between the columns and histogram bars.

Okay. So that's the case where it is a TTY. It is an interactive terminal. What if it is not interactive console? let's put an "else" in here and we'll say just default back to the old value of 10. Good enough. Okay. Next up.

Currently we have the block size of these histogram blocks just hard-coded here, longest divided by 10. we're going to take this out. We're gonna extract it as a variable and we're going to set that variable up here. box size equals longest divided by our histogram width.

Okay. Let's give this a whirl.

Well, it's close, but what's up with this line. This line is over-running. Did we get our math wrong? Well, as it turns out, we didn't exactly get our math wrong. but we threw away some information that we couldn't afford to throw away. Well, we actually need to do here is we need to convert both of these to floating point numbers before we do the division so that we're not  throwing away  the remainder,

 

when we fix that and go back here.

task "report" do
  report = MANUSCRIPTS.map { |m|
    text = IO.read(m)
    {
      name: m.pathmap("%n"),
      word_count: text.scan(/\w+/).size,
    }
  }
  longest = report.map { |f| f[:word_count] }.max
  require "io/console"
  puts header = "Name      Words"
  hist_width = if $stdout.tty?
      $stdout.winsize.last - header.size - 1
    else
      10
    end
  block_size = longest.to_f / hist_width.to_f
  report.each do |file|
    bar = "#" * (file[:word_count] / block_size)
    printf "%-10s%5d %s\n", file[:name], file[:word_count], bar
  end
end

Now, the longest bar night neatly fills up the screen without overrunning. Okay. But now for the acid test, whatever happens when we resize the terminal.

Looking good. And if we make it very large,

also good.

$ rake report
Name      Words
chapter01   181 ##########
chapter02   391 ######################
chapter03   411 #######################
chapter04   307 #################
chapter05   329 ##################
chapter06   520 #############################
chapter07  1668 ###############################################################################################
chapter08  1321 ###########################################################################
chapter09  1439 ##################################################################################
chapter10  2751 #############################################################################################################################################################
chapter11  1705 #################################################################################################
chapter12  2377 ########################################################################################################################################
chapter13  1306 ###########################################################################
chapter14   553 ###############################
chapter15  1398 ################################################################################
chapter16  1920 ##############################################################################################################
chapter17  2351 #######################################################################################################################################
chapter18  2873 ####################################################################################################################################################################
chapter19   531 ##############################
chapter20   161 #########
$ 

So there you have it, the io/console library, which adds a number of extra powers to Ruby's IO objects. this is just one of the powers that it adds; you can check out the documentation for more. Happy hacking!

Responses