public
Last active

A command-line prompt with a timeout and countdown.

  • Download Gist
countdown_prompt.rb
Ruby
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
# :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)

Please sign in to comment on this gist.

Something went wrong with that request. Please try again.