cli-ui 1.2.2 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cli/ui/os.rb ADDED
@@ -0,0 +1,67 @@
1
+ require 'rbconfig'
2
+
3
+ module CLI
4
+ module UI
5
+ module OS
6
+ # Determines which OS is currently running the UI, to make it easier to
7
+ # adapt its behaviour to the features of the OS.
8
+ def self.current
9
+ @current_os ||= case RbConfig::CONFIG['host_os']
10
+ when /darwin/
11
+ Mac
12
+ when /linux/
13
+ Linux
14
+ else
15
+ if RUBY_PLATFORM !~ /cygwin/ && ENV['OS'] == 'Windows_NT'
16
+ Windows
17
+ else
18
+ raise "Could not determine OS from host_os #{RbConfig::CONFIG["host_os"]}"
19
+ end
20
+ end
21
+ end
22
+
23
+ class Mac
24
+ class << self
25
+ def supports_emoji?
26
+ true
27
+ end
28
+
29
+ def supports_color_prompt?
30
+ true
31
+ end
32
+
33
+ def supports_arrow_keys?
34
+ true
35
+ end
36
+
37
+ def shift_cursor_on_line_reset?
38
+ false
39
+ end
40
+ end
41
+ end
42
+
43
+ class Linux < Mac
44
+ end
45
+
46
+ class Windows
47
+ class << self
48
+ def supports_emoji?
49
+ false
50
+ end
51
+
52
+ def supports_color_prompt?
53
+ false
54
+ end
55
+
56
+ def supports_arrow_keys?
57
+ false
58
+ end
59
+
60
+ def shift_cursor_on_line_reset?
61
+ true
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,59 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Printer
6
+ # Print a message to a stream with common utilities.
7
+ # Allows overriding the color, encoding, and target stream.
8
+ # By default, it formats the string using CLI:UI and rescues common stream errors.
9
+ #
10
+ # ==== Attributes
11
+ #
12
+ # * +msg+ - (required) the string to output. Can be frozen.
13
+ #
14
+ # ==== Options
15
+ #
16
+ # * +:frame_color+ - Override the frame color. Defaults to nil.
17
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with a puts method. Defaults to $stdout.
18
+ # * +:encoding+ - Force the output to be in a certain encoding. Defaults to UTF-8.
19
+ # * +:format+ - Whether to format the string using CLI::UI.fmt. Defaults to true.
20
+ # * +:graceful+ - Whether to gracefully ignore common I/O errors. Defaults to true.
21
+ # * +:wrap+ - Whether to wrap text at word boundaries to terminal width. Defaults to true.
22
+ #
23
+ # ==== Returns
24
+ # Returns whether the message was successfully printed,
25
+ # which can be useful if +:graceful+ is set to true.
26
+ #
27
+ # ==== Example
28
+ #
29
+ # CLI::UI::Printer.puts('{{x}} Ouch', to: $stderr)
30
+ #
31
+ def self.puts(
32
+ msg,
33
+ frame_color:
34
+ nil,
35
+ to:
36
+ $stdout,
37
+ encoding: Encoding::UTF_8,
38
+ format: true,
39
+ graceful: true,
40
+ wrap: true
41
+ )
42
+ msg = (+msg).force_encoding(encoding) if encoding
43
+ msg = CLI::UI.fmt(msg) if format
44
+ msg = CLI::UI.wrap(msg) if wrap
45
+
46
+ if frame_color
47
+ CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
48
+ else
49
+ to.puts(msg)
50
+ end
51
+
52
+ true
53
+ rescue Errno::EIO, Errno::EPIPE, IOError => e
54
+ raise(e) unless graceful
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
@@ -26,11 +26,11 @@ module CLI
26
26
  #
27
27
  # Increase the percent by X
28
28
  # CLI::UI::Progress.progress do |bar|
29
- # bar.tick(percent: 5)
29
+ # bar.tick(percent: 0.05)
30
30
  # end
31
31
  def self.progress(width: Terminal.width)
32
32
  bar = Progress.new(width: width)
33
- print CLI::UI::ANSI.hide_cursor
33
+ print(CLI::UI::ANSI.hide_cursor)
34
34
  yield(bar)
35
35
  ensure
36
36
  puts bar.to_s
@@ -59,14 +59,16 @@ module CLI
59
59
  # * +:percent+ - Increment progress by a specific percent amount
60
60
  # * +:set_percent+ - Set progress to a specific percent
61
61
  #
62
+ # *Note:* The +:percent+ and +:set_percent must be between 0.00 and 1.0
63
+ #
62
64
  def tick(percent: 0.01, set_percent: nil)
63
65
  raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
64
66
  @percent_done += percent
65
67
  @percent_done = set_percent if set_percent
66
68
  @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
67
69
 
68
- print to_s
69
- print CLI::UI::ANSI.previous_line + "\n"
70
+ print(to_s)
71
+ print(CLI::UI::ANSI.previous_line + "\n")
70
72
  end
71
73
 
72
74
  # Format the progress bar to be printed to terminal
@@ -77,11 +79,11 @@ module CLI
77
79
  filled = [(@percent_done * workable_width.to_f).ceil, 0].max
78
80
  unfilled = [workable_width - filled, 0].max
79
81
 
80
- CLI::UI.resolve_text [
82
+ CLI::UI.resolve_text([
81
83
  FILLED_BAR + ' ' * filled,
82
84
  UNFILLED_BAR + ' ' * unfilled,
83
- CLI::UI::Color::RESET.code + suffix
84
- ].join
85
+ CLI::UI::Color::RESET.code + suffix,
86
+ ].join)
85
87
  end
86
88
  end
87
89
  end
data/lib/cli/ui/prompt.rb CHANGED
@@ -2,6 +2,21 @@
2
2
  require 'cli/ui'
3
3
  require 'readline'
4
4
 
5
+ module Readline
6
+ unless const_defined?(:FILENAME_COMPLETION_PROC)
7
+ FILENAME_COMPLETION_PROC = proc do |input|
8
+ directory = input[-1] == '/' ? input : File.dirname(input)
9
+ filename = input[-1] == '/' ? '' : File.basename(input)
10
+
11
+ (Dir.entries(directory).select do |fp|
12
+ fp.start_with?(filename)
13
+ end - (input[-1] == '.' ? [] : ['.', '..'])).map do |fp|
14
+ File.join(directory, fp).gsub(/\A\.\//, '')
15
+ end
16
+ end
17
+ end
18
+ end
19
+
5
20
  module CLI
6
21
  module UI
7
22
  module Prompt
@@ -36,7 +51,8 @@ module CLI
36
51
  # * +:select_ui+ - Enable long-form option selection (default: true)
37
52
  #
38
53
  # Note:
39
- # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
54
+ # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
55
+ # you cannot set options with either of these keywords
40
56
  # * +:default+ conflicts with +:allow_empty:, you cannot set these together
41
57
  # * +:options+ conflicts with providing a +Block+ , you may only set one
42
58
  # * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
@@ -49,7 +65,7 @@ module CLI
49
65
  # ==== Return Value
50
66
  #
51
67
  # * If a +Block+ was not provided, the selected option or response to the free form question will be returned
52
- # * If a +Block+ was provided, the evaluted value of the +Block+ will be returned
68
+ # * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
53
69
  #
54
70
  # ==== Example Usage:
55
71
  #
@@ -76,18 +92,67 @@ module CLI
76
92
  # handler.option('python') { |selection| selection }
77
93
  # end
78
94
  #
79
- def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, multiple: false, filter_ui: true, select_ui: true, &options_proc)
80
- if ((options || block_given?) && (default || is_file))
95
+ def ask(
96
+ question,
97
+ options: nil,
98
+ default: nil,
99
+ is_file: nil,
100
+ allow_empty: true,
101
+ multiple: false,
102
+ filter_ui: true,
103
+ select_ui: true,
104
+ &options_proc
105
+ )
106
+ if (options || block_given?) && ((default && !multiple) || is_file)
81
107
  raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
82
108
  end
83
109
 
110
+ if options && multiple && default && !(default - options).empty?
111
+ raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
112
+ end
113
+
84
114
  if options || block_given?
85
- ask_interactive(question, options, multiple: multiple, filter_ui: filter_ui, select_ui: select_ui, &options_proc)
115
+ ask_interactive(
116
+ question,
117
+ options,
118
+ multiple: multiple,
119
+ default: default,
120
+ filter_ui: filter_ui,
121
+ select_ui: select_ui,
122
+ &options_proc
123
+ )
86
124
  else
87
125
  ask_free_form(question, default, is_file, allow_empty)
88
126
  end
89
127
  end
90
128
 
129
+ # Asks the user for a single-line answer, without displaying the characters while typing.
130
+ # Typically used for password prompts
131
+ #
132
+ # ==== Return Value
133
+ #
134
+ # The password, without a trailing newline.
135
+ # If the user simply presses "Enter" without typing any password, this will return an empty string.
136
+ def ask_password(question)
137
+ require 'io/console'
138
+
139
+ CLI::UI.with_frame_color(:blue) do
140
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
141
+
142
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
143
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
144
+ password = STDIN.noecho do
145
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
146
+ # " 123 \n".chomp => " 123 "
147
+ STDIN.gets.chomp
148
+ end
149
+
150
+ STDOUT.puts # Complete the line
151
+
152
+ password
153
+ end
154
+ end
155
+
91
156
  # Asks the user a yes/no question.
92
157
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
93
158
  #
@@ -105,7 +170,9 @@ module CLI
105
170
  private
106
171
 
107
172
  def ask_free_form(question, default, is_file, allow_empty)
108
- raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false') if (default && !allow_empty)
173
+ if default && !allow_empty
174
+ raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
175
+ end
109
176
 
110
177
  if default
111
178
  puts_question("#{question} (empty = #{default})")
@@ -128,7 +195,7 @@ module CLI
128
195
  end
129
196
  end
130
197
 
131
- def ask_interactive(question, options = nil, multiple: false, filter_ui: true, select_ui: true)
198
+ def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
132
199
  raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
133
200
 
134
201
  options ||= if block_given?
@@ -137,26 +204,32 @@ module CLI
137
204
  handler.options
138
205
  end
139
206
 
140
- raise(ArgumentError, 'insufficient options') if options.nil? || options.size < 2
141
- instructions = (multiple ? "Toggle options. " : "") + "Choose with ↑ ↓ ⏎"
207
+ raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
208
+ navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
209
+ 'Choose with ↑ ↓ ⏎'
210
+ else
211
+ "Navigate up with 'k' and down with 'j', press Enter to select"
212
+ end
213
+
214
+ instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
142
215
  instructions += ", filter with 'f'" if filter_ui
143
- instructions += ", enter option with 'e'" if select_ui and options.size > 9
216
+ instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
144
217
  puts_question("#{question} {{yellow:(#{instructions})}}")
145
- resp = interactive_prompt(options, multiple: multiple)
218
+ resp = interactive_prompt(options, multiple: multiple, default: default)
146
219
 
147
220
  # Clear the line
148
- print ANSI.previous_line + ANSI.clear_to_end_of_line
221
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
149
222
  # Force StdoutRouter to prefix
150
- print ANSI.previous_line + "\n"
223
+ print(ANSI.previous_line + "\n")
151
224
 
152
225
  # reset the question to include the answer
153
226
  resp_text = resp
154
227
  if multiple
155
228
  resp_text = case resp.size
156
229
  when 0
157
- "<nothing>"
230
+ '<nothing>'
158
231
  when 1..2
159
- resp.join(" and ")
232
+ resp.join(' and ')
160
233
  else
161
234
  "#{resp.size} items"
162
235
  end
@@ -168,8 +241,8 @@ module CLI
168
241
  end
169
242
 
170
243
  # Useful for stubbing in tests
171
- def interactive_prompt(options, multiple: false)
172
- InteractiveOptions.call(options, multiple: multiple)
244
+ def interactive_prompt(options, multiple: false, default: nil)
245
+ InteractiveOptions.call(options, multiple: multiple, default: default)
173
246
  end
174
247
 
175
248
  def write_default_over_empty_input(default)
@@ -193,10 +266,10 @@ module CLI
193
266
  def readline(is_file: false)
194
267
  if is_file
195
268
  Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
196
- Readline.completion_append_character = ""
269
+ Readline.completion_append_character = ''
197
270
  else
198
271
  Readline.completion_proc = proc { |*| nil }
199
- Readline.completion_append_character = " "
272
+ Readline.completion_append_character = ' '
200
273
  end
201
274
 
202
275
  # because Readline is a C library, CLI::UI's hooks into $stdout don't
@@ -204,11 +277,14 @@ module CLI
204
277
  # thread to manage output, but the current strategy feels like a
205
278
  # better tradeoff.
206
279
  prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
207
- prompt = prefix + CLI::UI.fmt('{{blue:> }}') + CLI::UI::Color::YELLOW.code
280
+ # If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
281
+ # not change the colour here.
282
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}')
283
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
208
284
 
209
285
  begin
210
286
  line = Readline.readline(prompt, true)
211
- print CLI::UI::Color::RESET.code
287
+ print(CLI::UI::Color::RESET.code)
212
288
  line.to_s.chomp
213
289
  rescue Interrupt
214
290
  CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
@@ -5,8 +5,8 @@ module CLI
5
5
  module UI
6
6
  module Prompt
7
7
  class InteractiveOptions
8
- DONE = "Done"
9
- CHECKBOX_ICON = { false => "", true => "" }
8
+ DONE = 'Done'
9
+ CHECKBOX_ICON = { false => '', true => '' }
10
10
 
11
11
  # Prompts the user with options
12
12
  # Uses an interactive session to allow the user to pick an answer
@@ -22,8 +22,8 @@ module CLI
22
22
  # Ask an interactive question
23
23
  # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
24
24
  #
25
- def self.call(options, multiple: false)
26
- list = new(options, multiple: multiple)
25
+ def self.call(options, multiple: false, default: nil)
26
+ list = new(options, multiple: multiple, default: default)
27
27
  selected = list.call
28
28
  if multiple
29
29
  selected.map { |s| options[s - 1] }
@@ -39,7 +39,7 @@ module CLI
39
39
  #
40
40
  # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
41
41
  #
42
- def initialize(options, multiple: false)
42
+ def initialize(options, multiple: false, default: nil)
43
43
  @options = options
44
44
  @active = 1
45
45
  @marker = '>'
@@ -52,7 +52,13 @@ module CLI
52
52
  @filter = ''
53
53
  # 0-indexed array representing if selected
54
54
  # @options[0] is selected if @chosen[0]
55
- @chosen = Array.new(@options.size) { false } if multiple
55
+ if multiple
56
+ @chosen = if default
57
+ @options.map { |option| default.include?(option) }
58
+ else
59
+ Array.new(@options.size) { false }
60
+ end
61
+ end
56
62
  @redraw = true
57
63
  @presented_options = []
58
64
  end
@@ -95,16 +101,16 @@ module CLI
95
101
  @option_lengths = @options.map do |text|
96
102
  width = 1 if text.empty?
97
103
  width ||= text
98
- .split("\n")
99
- .reject(&:empty?)
100
- .map { |l| (l.length / max_width).ceil }
101
- .reduce(&:+)
104
+ .split("\n")
105
+ .reject(&:empty?)
106
+ .map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
107
+ .reduce(&:+)
102
108
 
103
109
  width
104
110
  end
105
111
  end
106
112
 
107
- def reset_position(number_of_lines=num_lines)
113
+ def reset_position(number_of_lines = num_lines)
108
114
  # This will put us back at the beginning of the options
109
115
  # When we redraw the options, they will be overwritten
110
116
  CLI::UI.raw do
@@ -112,12 +118,12 @@ module CLI
112
118
  end
113
119
  end
114
120
 
115
- def clear_output(number_of_lines=num_lines)
121
+ def clear_output(number_of_lines = num_lines)
116
122
  CLI::UI.raw do
117
123
  # Write over all lines with whitespace
118
124
  number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
119
125
  end
120
- reset_position number_of_lines
126
+ reset_position(number_of_lines)
121
127
 
122
128
  # Update if metadata is being displayed
123
129
  # This must be done _after_ the output is cleared or it won't draw over
@@ -128,7 +134,7 @@ module CLI
128
134
  # Don't use this in place of +@displaying_metadata+, this updates too
129
135
  # quickly to be useful when drawing to the screen.
130
136
  def display_metadata?
131
- filtering? or selecting? or has_filter?
137
+ filtering? || selecting? || has_filter?
132
138
  end
133
139
 
134
140
  def num_lines
@@ -136,7 +142,7 @@ module CLI
136
142
 
137
143
  option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
138
144
  # Handle continuation markers and "Done" option when multiple is true
139
- next total_length + 1 if option_number.nil? or option_number.zero?
145
+ next total_length + 1 if option_number.nil? || option_number.zero?
140
146
  total_length + @option_lengths[option_number - 1]
141
147
  end
142
148
 
@@ -153,7 +159,7 @@ module CLI
153
159
  CTRL_D = "\u0004"
154
160
 
155
161
  def up
156
- active_index = @filtered_options.index { |_,num| num == @active } || 0
162
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
157
163
 
158
164
  previous_visible = @filtered_options[active_index - 1]
159
165
  previous_visible ||= @filtered_options.last
@@ -163,7 +169,7 @@ module CLI
163
169
  end
164
170
 
165
171
  def down
166
- active_index = @filtered_options.index { |_,num| num == @active } || 0
172
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
167
173
 
168
174
  next_visible = @filtered_options[active_index + 1]
169
175
  next_visible ||= @filtered_options.first
@@ -216,7 +222,7 @@ module CLI
216
222
  @redraw = true
217
223
 
218
224
  # Control+D or Backspace on empty search closes search
219
- if char == CTRL_D or (@filter.empty? and char == BACKSPACE)
225
+ if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE))
220
226
  @filter = ''
221
227
  @state = :root
222
228
  return
@@ -231,7 +237,7 @@ module CLI
231
237
 
232
238
  def select_current
233
239
  # Prevent selection of invisible options
234
- return unless presented_options.any? { |_,num| num == @active }
240
+ return unless presented_options.any? { |_, num| num == @active }
235
241
  select_n(@active)
236
242
  end
237
243
 
@@ -240,7 +246,7 @@ module CLI
240
246
  wait_for_user_input until @redraw
241
247
  end
242
248
 
243
- # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
249
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
244
250
  def wait_for_user_input
245
251
  char = read_char
246
252
  @last_char = char
@@ -250,22 +256,24 @@ module CLI
250
256
  when CTRL_C ; raise Interrupt
251
257
  end
252
258
 
259
+ max_digit = [@options.size, 9].min.to_s
253
260
  case @state
254
261
  when :root
255
262
  case char
256
- when ESC ; @state = :esc
257
- when 'k' ; up
258
- when 'j' ; down
259
- when 'e', ':', 'G' ; start_line_select
260
- when 'f', '/' ; start_filter
261
- when ('0'..@options.size.to_s) ; select_n(char.to_i)
262
- when 'y', 'n' ; select_bool(char)
263
- when " ", "\r", "\n" ; select_current # <enter>
263
+ when ESC ; @state = :esc
264
+ when 'k' ; up
265
+ when 'j' ; down
266
+ when 'e', ':', 'G' ; start_line_select
267
+ when 'f', '/' ; start_filter
268
+ when ('0'..max_digit) ; select_n(char.to_i)
269
+ when 'y', 'n' ; select_bool(char)
270
+ when ' ', "\r", "\n" ; select_current # <enter>
264
271
  end
265
272
  when :filter
266
273
  case char
267
274
  when ESC ; @state = :esc
268
275
  when "\r", "\n" ; select_current
276
+ when "\b" ; update_search(BACKSPACE) # Happens on Windows
269
277
  else ; update_search(char)
270
278
  end
271
279
  when :line_select
@@ -273,9 +281,9 @@ module CLI
273
281
  when ESC ; @state = :esc
274
282
  when 'k' ; up ; @state = :root
275
283
  when 'j' ; down ; @state = :root
276
- when 'e',':','G','q' ; stop_line_select
284
+ when 'e', ':', 'G', 'q' ; stop_line_select
277
285
  when '0'..'9' ; build_selection(char)
278
- when BACKSPACE ; chop_selection # Pop last input on backspace
286
+ when BACKSPACE ; chop_selection # Pop last input on backspace
279
287
  when ' ', "\r", "\n" ; select_current
280
288
  end
281
289
  when :esc
@@ -288,6 +296,8 @@ module CLI
288
296
  case char
289
297
  when 'A' ; up
290
298
  when 'B' ; down
299
+ when 'C' ; # Ignore right key
300
+ when 'D' ; # Ignore left key
291
301
  else ; raise Interrupt # unhandled escape sequence.
292
302
  end
293
303
  end
@@ -324,37 +334,41 @@ module CLI
324
334
  end
325
335
 
326
336
  def read_char
327
- raw_tty! do
328
- getc = $stdin.getc
329
- getc ? getc.chr : :timeout
337
+ if $stdin.tty? && !ENV['TEST']
338
+ $stdin.getch # raw mode for tty
339
+ else
340
+ $stdin.getc
330
341
  end
331
342
  rescue IOError
332
343
  "\e"
333
344
  end
334
345
 
335
- def raw_tty!
336
- if ENV['TEST'] || !$stdin.tty?
337
- yield
338
- else
339
- $stdin.raw { yield }
340
- end
341
- end
342
-
343
346
  def presented_options(recalculate: false)
344
347
  return @presented_options unless recalculate
345
348
 
346
349
  @presented_options = @options.zip(1..Float::INFINITY)
347
350
  if has_filter?
348
- @presented_options.select! { |option,_| option.downcase.include?(@filter.downcase) }
351
+ @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
349
352
  end
350
353
 
351
354
  # Used for selection purposes
355
+ @presented_options.push([DONE, 0]) if @multiple
352
356
  @filtered_options = @presented_options.dup
353
357
 
354
- @presented_options.unshift([DONE, 0]) if @multiple
355
-
356
358
  ensure_visible_is_active if has_filter?
357
359
 
360
+ # Must have more lines before the selection than we can display
361
+ if distance_from_start_to_selection > max_lines
362
+ @presented_options.shift(distance_from_start_to_selection - max_lines)
363
+ ensure_first_item_is_continuation_marker
364
+ end
365
+
366
+ # Must have more lines after the selection than we can display
367
+ if distance_from_selection_to_end > max_lines
368
+ @presented_options.pop(distance_from_selection_to_end - max_lines)
369
+ ensure_last_item_is_continuation_marker
370
+ end
371
+
358
372
  while num_lines > max_lines
359
373
  # try to keep the selection centered in the window:
360
374
  if distance_from_selection_to_end > distance_from_start_to_selection
@@ -386,15 +400,15 @@ module CLI
386
400
  end
387
401
 
388
402
  def index_of_active_option
389
- @presented_options.index { |_,num| num == @active }.to_i
403
+ @presented_options.index { |_, num| num == @active }.to_i
390
404
  end
391
405
 
392
406
  def ensure_last_item_is_continuation_marker
393
- @presented_options.push(["...", nil]) if @presented_options.last.last
407
+ @presented_options.push(['...', nil]) if @presented_options.last.last
394
408
  end
395
409
 
396
410
  def ensure_first_item_is_continuation_marker
397
- @presented_options.unshift(["...", nil]) if @presented_options.first.last
411
+ @presented_options.unshift(['...', nil]) if @presented_options.first.last
398
412
  end
399
413
 
400
414
  def max_lines
@@ -413,14 +427,14 @@ module CLI
413
427
  max_num_length = (@options.size + 1).to_s.length
414
428
 
415
429
  metadata_text = if selecting?
416
- select_text = @active
417
- select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
418
- "Select: #{select_text}"
419
- elsif filtering? or has_filter?
420
- filter_text = @filter
421
- filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
422
- "Filter: #{filter_text}"
423
- end
430
+ select_text = @active
431
+ select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
432
+ "Select: #{select_text}"
433
+ elsif filtering? || has_filter?
434
+ filter_text = @filter
435
+ filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
436
+ "Filter: #{filter_text}"
437
+ end
424
438
 
425
439
  if metadata_text
426
440
  CLI::UI.with_frame_color(:blue) do
@@ -429,23 +443,23 @@ module CLI
429
443
  end
430
444
 
431
445
  options.each do |choice, num|
432
- is_chosen = @multiple && num && @chosen[num - 1]
446
+ is_chosen = @multiple && num && @chosen[num - 1] && num != 0
433
447
 
434
448
  padding = ' ' * (max_num_length - num.to_s.length)
435
- message = " #{num}#{num ? '.' : ' '}#{padding}"
449
+ message = " #{num}#{num ? "." : " "}#{padding}"
436
450
 
437
- format = "%s"
451
+ format = '%s'
438
452
  # If multiple, bold only selected. If not multiple, bold everything
439
453
  format = "{{bold:#{format}}}" if !@multiple || is_chosen
440
454
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
441
455
  format = " #{format}"
442
456
 
443
- message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
457
+ message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
444
458
  message += format_choice(format, choice)
445
459
 
446
460
  if num == @active
447
461
 
448
- color = (filtering? or selecting?) ? 'green' : 'blue'
462
+ color = filtering? || selecting? ? 'green' : 'blue'
449
463
  message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
450
464
  end
451
465
 
@@ -461,7 +475,7 @@ module CLI
461
475
 
462
476
  return eol if lines.empty? # Handle blank options
463
477
 
464
- lines.map! { |l| sprintf(format, l) + eol }
478
+ lines.map! { |l| format(format, l) + eol }
465
479
  lines.join("\n")
466
480
  end
467
481
  end