Skip to content

Instantly share code, notes, and snippets.

@ddollar
Forked from avdi/countdown_prompt.rb
Created July 17, 2009 18:59
Show Gist options
  • Save ddollar/149226 to your computer and use it in GitHub Desktop.
Save ddollar/149226 to your computer and use it in GitHub Desktop.
# :PUBLISHER: markdown, shell, { command: 'rdiscount' }
# :BRACKET_CODE: '[ruby]', '[/ruby]'
# :TEXT:
#
# Have you ever started a long operation and walked away from the computer, and
# come back half an hour later only to find that the process is hung up waiting
# for some user input? It's a sub-optimal user experience, and in many cases it
# can be avoided by having the program choose a default if the user doesn't
# respond within a certain amount of time. One example of this UI technique in
# the wild is powering off your computer - most modern operating systems will
# pop up a dialogue to confirm or cancel the shutdown, with a countdown until
# the shutdown proceeds automatically.
#
# This article is about how to achieve the same effect in command-line programs
# using Ruby.
#
# Let's start with the end result. We want to be able to call our method like
# this:
#
# :INSERT: @usage
#
# We pass in a question, a (possibly fractional) number of seconds to wait, and
# a default value. The method should prompt the user with the given question
# and a visual countdown. If the user types 'y' or 'n', it should immediately
# return true or false, respectively. Otherwise when the countdown expires it
# should return the default value.
#
# Here's a high-level implementation:
#
# :INSERT: "@impl:/def\ ask_with_countdown/../end.*ask_with_countdown/"
#
# Let's take it step-by-step.
#
# By default, *NIX terminals operate in <a
# href="http://www.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap11.html#tag_11_01_06">"canonical
# mode"</a>, where they buffer a line of input internally and don't send it
# until the user hits RETURN. This is so that the user can do simple edits like
# backspacing and retyping a typo. This behavior is undesirable for our
# purposes, however, since we want the prompt to respond as soon as the user
# types a key. So we need to temporarily alter the terminal configuration.
#
# :INSERT: @impl:/with_unbuffered_input.*do/
#
# We use the POSIX Termios library, via the <a
# href="http://arika.org/ruby/termios">ruby-termios gem</a>, to accomplish this
# feat.
#
# :INSERT: "@impl:/def with_unbuffered_input/../end.*with_unbuffered_input/"
#
# <a
# href="http://www.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap11.html">POSIX
# Termios</a> defines a set of library calls for interacting with terminals. In
# our case, we want to disable some of the terminal's "local" features -
# functionality the terminal handles internally before sending input on to the
# controlling program.
#
# We start by getting a snapshot of the terminal's current configuration. Then
# we make a copy for our new configuration. We are interested in two flags:
# "ECHO" and "ICANON". The first, ECHO, controls whether the terminal displays
# characters that the user has types. The second controls canonical mode, which
# we explained above. After turning both flags off, we set the new
# configuration and yield. After the block is finished, or if an exception is
# raised, we ensure that the original terminal configuration is reinstated.
#
# Now we need to arrange for a countdown timer.
#
# :INSERT: @impl:/countdown_from.*do/
#
# Here's the implementation:
#
# :INSERT: "@impl:/def countdown_from/../end.*countdown_from/"
#
# First we calculate the wallclock time at which we should stop waiting. Then
# we begin looping, yielding the number of seconds left, and then when the block
# returns recalculating the number. We keep this up until the time has
# expired.
#
# Next up is writing, and re-writing, the prompt.
#
# :INSERT: @impl:/write_then_erase_prompt.*do/
#
# This method is implemented as follows:
#
# :INSERT: "@impl:/def write_then/../end.*write_then/"
#
# We format and print a prompt, flushing the output to insure that it is
# displayed immediately. The prompt includes a count of the number of seconds
# remaining until the query times out. In order to make it a nice visually
# consistent length, we use a fixed-width field for the countdown ("%2d"). Note
# that we don't use <code>puts</code> to print the prompt - we don't want it to advance to
# the next line, because we want to be able to dynamically rewrite the prompt as
# the countdown proceeds.
#
# After we are done yielding to the block, we erase the prompt in preparation
# for the next cycle. In order to erase it we create and output string of
# backspaces ("\b") the same length as the prompt.
#
# Now we need a way to wait until the user types something, while still
# periodically updating the prompt.
#
# :INSERT: @impl:/wait_for_input.*do/
#
# We pass <code>wait_for_input</code> an input stream and a (potentially fractional) number
# of seconds to wait. In this case we only want to wait until the next
# second-long "tick" so that we can update the countdown. So we pass in the
# remainder of dividing seconds_left by 1. E.g. if seconds_left was 5.3, we
# would set a timeout of 0.3 seconds. After 3/10 of a second of waiting for
# input, the wait would time out, the prompt would be erased and rewritten to
# show 4 seconds remaining, and then we'd start waiting for input again.
#
# Here's the implementation of <code>wait_for_input</code>:
#
# :INSERT: "@impl:/def wait_for_input/../end.*wait_for_input/"
#
# We're using <code>Kernel#select</code> to do the waiting. The parameters to <code>#select</code>
# are a set of arrays - one each for input, output, and errors. We only care
# about input, so we pass the input stream in the first array and leave the
# others blank. We also pass how long to wait until timing out.
#
# If new input is detected, <code>select</code> returns an array of arrays, corresponding
# to the three arrays we passed in. If it times out while waiting, it returns
# <code>nil</code>. We use the return value to determine whether to execute the given
# block or note. If there is input waiting we yield to the block; otherwise we
# just return.
#
# While it takes some getting used to, handling IO timeouts with
# <code>select</code> is safer and more reliable than using the
# <code>Timeout</code> module. And it's less messy than rescuing
# <code>Timeout::Error</code> every time a read times out.
#
# Finally, we need to read and interpret the character the user types, if any.
#
# :INSERT: @impl:/case char/../end/
#
# If the user types 'y' or 'n' (or uppercase versions of the same), we return
# <code>true</code> or <code>false</code>, respectively. Otherwise, we simply ignore any characters
# the user types. Typing characters other than 'y' or 'n' will cause the loop
# to be restarted.
#
# Note the use of character literals like <code>?y</code> to compare against the
# integer character code returned by <code>IO#getc</code>. We could alternately
# use <code>Integer#chr</code> to convert the character codes into
# single-character strings, if we wanted.
#
# Wrapping up, we make sure to return the default value should the timeout
# expire without any user input; and we output a newline to move the cursor past
# our prompt.
#
# :INSERT: @impl:/return default/..end/
#
# And there you have it; a yes/no prompt with a timeout and a visual countdown.
# Static text doesn't really capture the effect, so rather than include sample
# output I'll just suggest that you try the code out for yourself (sorry,
# Windows users, it's *NIX-only).
#
# :CUT:
# :SAMPLE: impl
require 'rubygems'
require 'termios'
def ask_with_countdown_to_default(question, seconds, default)
with_unbuffered_input($stdin) do
countdown_from(seconds) do |seconds_left|
write_then_erase_prompt(question, seconds_left) do
wait_for_input($stdin, seconds_left % 1) do
case char = $stdin.getc
when ?y, ?Y then return true
when ?n, ?N then return false
else # NOOP
end
end
end
end
end
return default
ensure
$stdout.puts
end # ask_with_countdown_to_default
def with_unbuffered_input(input = $stdin)
old_attributes = Termios.tcgetattr(input)
new_attributes = old_attributes.dup
new_attributes.lflag &= ~Termios::ECHO
new_attributes.lflag &= ~Termios::ICANON
Termios::tcsetattr(input, Termios::TCSANOW, new_attributes)
yield
ensure
Termios::tcsetattr(input, Termios::TCSANOW, old_attributes)
end # with_unbuffered_input
def countdown_from(seconds_left)
start_time = Time.now
end_time = start_time + seconds_left
begin
yield(seconds_left)
seconds_left = end_time - Time.now
end while seconds_left > 0.0
end # countdown_from
def write_then_erase_prompt(question, seconds_left)
prompt_format = "#{question} (y/n) (%2d)"
prompt = prompt_format % seconds_left.to_i
prompt_length = prompt.length
$stdout.write(prompt)
$stdout.flush
yield
$stdout.write("\b" * prompt_length)
$stdout.flush
end # write_then_erase_prompt
def wait_for_input(input, timeout)
# Wait until input is available
if select([input], [], [], timeout)
yield
end
end # wait_for_input
# :SAMPLE: usage
puts ask_with_countdown_to_default("Do you like pie?", 30.0, false)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment