In Progress
Unit 1, Lesson 21
In Progress

Dynamic Word Wrap

Video transcript & code

In episode #353, we learned about signals, and how to trap them in Ruby. In episode #355, we learned about word-wrapping text. These might not seem like particularly related topics. But today, we're going to bring them together in order to accomplish something useful and a little bit cool.

Let's say we have a program that dumps some long-form text to the console. This might be part of the program's built-in help system, for instance.

require "./text"
require "lovely_rufus"

puts LovelyRufus::TextWrapper.wrap(TEXT, width: 80)

This is the same code we saw in episode #355, which uses the lovely_rufus gem for intelligent and robust word-wrapping.

There's a potential problem with this code, and there's a good chance you've already spotted it. The text-wrapping width is hard-coded. As a result, the output looks fine in terminals 80 characters wide or larger. But once we shrink the terminal or increase the font size, the output starts looking really bad.

There's not much point adding special word-wrapping code if we're not going to take into account the actual size of the terminal the words are being displayed on.

Fortunately, since version 2.0, ruby has made it very easy to discover the size of the console window. All we have to do is require the io/console library, and then invoke IO.console.winsize.

irb(main):001:0> require "io/console"
=> true
irb(main):002:0> IO.console.winsize
=> [57, 101]
irb(main):003:0>

This method returns a two-element array. The first element is the height of the console in terms of character rows, and the second is the width in terms of character columns.

The two-element return scheme makes it easy to use destructuring assignment to assign both values to variables at once.

irb(main):003:0> height, width = IO.console.winsize
=> [57, 101]
irb(main):004:0> height
=> 57
irb(main):005:0> width
=> 101

Knowing about this method, it's a straightforward process to use the real detected console width when word-wrapping our text. We use the underscore in place of the height variable, to indicate that we don't care about this value.

require "./text"
require "lovely_rufus"
require "io/console"

_, width = IO.console.winsize

puts LovelyRufus::TextWrapper.wrap(TEXT, width: width)

Now when we put this program to work, we can see that each time we resize the terminal and run it again, it detects the window size and word-wraps accordingly.

This may be all we need in order to dump some text to the screen in a readable way. However, it's still static in a way. The text may adapt to the terminal width when it is first emitted, but any changes after the fact can break up that formatting.

We know that it's possible for interactive applications to do better than this, because we've used them. For instance, we know that tools like Vim and Emacs have no trouble resizing their interfaces as we stretch and shrink the terminal they are displayed in.

So how is this accomplished? How can we respond to changes in terminal window size on the fly?

The designers of UNIX-like systems realized that programs would want to know when the screen had been resized. They needed a way to let the program know. But how could a terminal notify a running program that something had changed?

The answer they came up with was to have the terminal send the running program a signal. Accordingly, one of the standard enumerated signals was set aside for this purpose: "SIGWINCH", where the "WINCH" is short for "WINdow size CHanged".

By default, programs simply ignore SIGWINCH. But using techniques we learned in episode #353, we can trap this signal and handle it any way we want. Let's write a program that simply sits and waits for SIGWINCH and then outputs the current console size. As you may recall, a signal can't carry any extra information with it. So once we get SIGWINCH, we still need to query the console to ask it what its new shape is.

In order to keep the program alive and waiting for signals, we end it with an indefinite sleep.

require "io/console"

trap("WINCH") do
  p IO.console.winsize
end

sleep

When we play with this in a console, we can see that every time we change the terminal window geometry, it outputs a new value.

Now that we know how to use SIGWINCH to react to terminal window changes, let's rewrite our text displaying program to dynamically re-wrap words.

First off, we're going to have to make our program stay running instead of immediately exiting once it has finished. An easy way to do that is to add a gets to the end, so the program will only finish when we hit ENTER.

Second, we take our text printing code and move it into a re-usable method called display.

Third, we need to arrange to have the screen cleared every time we re-display some text. To do this, we have to print a special ANSI escape sequence to the terminal. This code is recognized by most terminals as a signal to clear the screen.

Fourth, now that the display code has been moved into a method, we need to ensure that the method is called when the program starts.

Finally, we need to set up the signal trap. Inside the trap block, we re-run the display method.

require "./text"
require "lovely_rufus"
require "io/console"

def display
  print "\e[H\e[2J"
  _, width = IO.console.winsize
  puts LovelyRufus::TextWrapper.wrap(TEXT, width: width)
end

trap("WINCH") do
  display
end

display
gets

And now we're ready to try it out. We run our program, and proceed to resize our terminal window. As we can see, it now dynamically re-wraps the displayed text with every change. Pretty cool, right?

If we want to make sure that the paragraphs are never wrapped at more than 80 characters, we can simply update the code to use the smaller of the window width and 80.

def display
  print "\e[H\e[2J"
  _, width = IO.console.winsize
  puts LovelyRufus::TextWrapper.wrap(TEXT, width: [width, 80].min)
end

This program gives us a revealing demonstration of the interruptive, asynchronous way that signal handlers operate. The whole time we are re-sizing the terminal window, the program is sitting at the last line, waiting for input. When a signal arrives, the handler is triggered, and the screen is re-drawn. But at the end of the handler, the program goes right back to where it was: waiting for some input, as if nothing had happened.

So anyway, if you've ever wondered how interactive console applications are able to dynamically respond to resized terminal windows, now you know. And now you can use it in your own tools, if you ever need to. Happy hacking!

Responses