cli-ui 1.2.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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