cli-ui 1.2.1 → 1.5.0

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.
@@ -1,15 +1,19 @@
1
+ # coding: utf-8
1
2
  require 'io/console'
2
3
 
3
4
  module CLI
4
5
  module UI
5
6
  module Prompt
6
7
  class InteractiveOptions
7
- DONE = "Done"
8
- CHECKBOX_ICON = { false => "", true => "" }
8
+ DONE = 'Done'
9
+ CHECKBOX_ICON = { false => '', true => '' }
9
10
 
10
11
  # Prompts the user with options
11
12
  # Uses an interactive session to allow the user to pick an answer
12
13
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
14
+ # For more than 9 options, hitting 'e', ':', or 'G' will enter select
15
+ # mode allowing the user to type in longer numbers
16
+ # Pressing 'f' or '/' will allow the user to filter the results
13
17
  #
14
18
  # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
15
19
  #
@@ -18,8 +22,8 @@ module CLI
18
22
  # Ask an interactive question
19
23
  # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
20
24
  #
21
- def self.call(options, multiple: false)
22
- list = new(options, multiple: multiple)
25
+ def self.call(options, multiple: false, default: nil)
26
+ list = new(options, multiple: multiple, default: default)
23
27
  selected = list.call
24
28
  if multiple
25
29
  selected.map { |s| options[s - 1] }
@@ -35,23 +39,35 @@ module CLI
35
39
  #
36
40
  # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
37
41
  #
38
- def initialize(options, multiple: false)
42
+ def initialize(options, multiple: false, default: nil)
39
43
  @options = options
40
44
  @active = 1
41
45
  @marker = '>'
42
46
  @answer = nil
43
47
  @state = :root
44
48
  @multiple = multiple
49
+ # Indicate that an extra line (the "metadata" line) is present and
50
+ # the terminal output should be drawn over when processing user input
51
+ @displaying_metadata = false
52
+ @filter = ''
45
53
  # 0-indexed array representing if selected
46
54
  # @options[0] is selected if @chosen[0]
47
- @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
48
62
  @redraw = true
63
+ @presented_options = []
49
64
  end
50
65
 
51
66
  # Calls the +InteractiveOptions+ and asks the question
52
67
  # Usually used from +self.call+
53
68
  #
54
69
  def call
70
+ calculate_option_line_lengths
55
71
  CLI::UI.raw { print(ANSI.hide_cursor) }
56
72
  while @answer.nil?
57
73
  render_options
@@ -59,6 +75,7 @@ module CLI
59
75
  reset_position
60
76
  end
61
77
  clear_output
78
+
62
79
  @answer
63
80
  ensure
64
81
  CLI::UI.raw do
@@ -68,46 +85,96 @@ module CLI
68
85
 
69
86
  private
70
87
 
71
- def reset_position
88
+ def calculate_option_line_lengths
89
+ @terminal_width_at_calculation_time = CLI::UI::Terminal.width
90
+ # options will be an array of questions but each option can be multi-line
91
+ # so to get the # of lines, you need to join then split
92
+
93
+ # since lines may be longer than the terminal is wide, we need to
94
+ # determine how many extra lines would be taken up by them
95
+ max_width = (@terminal_width_at_calculation_time -
96
+ @options.count.to_s.size - # Width of the displayed number
97
+ 5 - # Extra characters added during rendering
98
+ (@multiple ? 1 : 0) # Space for the checkbox, if rendered
99
+ ).to_f
100
+
101
+ @option_lengths = @options.map do |text|
102
+ width = 1 if text.empty?
103
+ width ||= text
104
+ .split("\n")
105
+ .reject(&:empty?)
106
+ .map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
107
+ .reduce(&:+)
108
+
109
+ width
110
+ end
111
+ end
112
+
113
+ def reset_position(number_of_lines = num_lines)
72
114
  # This will put us back at the beginning of the options
73
115
  # When we redraw the options, they will be overwritten
74
116
  CLI::UI.raw do
75
- num_lines.times { print(ANSI.previous_line) }
117
+ number_of_lines.times { print(ANSI.previous_line) }
76
118
  end
77
119
  end
78
120
 
79
- def clear_output
121
+ def clear_output(number_of_lines = num_lines)
80
122
  CLI::UI.raw do
81
123
  # Write over all lines with whitespace
82
- num_lines.times { puts(' ' * CLI::UI::Terminal.width) }
124
+ number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
83
125
  end
84
- reset_position
126
+ reset_position(number_of_lines)
127
+
128
+ # Update if metadata is being displayed
129
+ # This must be done _after_ the output is cleared or it won't draw over
130
+ # the entire output
131
+ @displaying_metadata = display_metadata?
132
+ end
133
+
134
+ # Don't use this in place of +@displaying_metadata+, this updates too
135
+ # quickly to be useful when drawing to the screen.
136
+ def display_metadata?
137
+ filtering? || selecting? || has_filter?
85
138
  end
86
139
 
87
140
  def num_lines
88
- options = presented_options.map(&:first)
89
- # @options will be an array of questions but each option can be multi-line
90
- # so to get the # of lines, you need to join then split
141
+ calculate_option_line_lengths if terminal_width_changed?
142
+
143
+ option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
144
+ # Handle continuation markers and "Done" option when multiple is true
145
+ next total_length + 1 if option_number.nil? || option_number.zero?
146
+ total_length + @option_lengths[option_number - 1]
147
+ end
91
148
 
92
- # empty_option_count is needed since empty option titles are omitted
93
- # from the line count when reject(&:empty?) is called
149
+ option_length + (@displaying_metadata ? 1 : 0)
150
+ end
94
151
 
95
- empty_option_count = options.count(&:empty?)
96
- joined_options = options.join("\n")
97
- joined_options.split("\n").reject(&:empty?).size + empty_option_count
152
+ def terminal_width_changed?
153
+ @terminal_width_at_calculation_time != CLI::UI::Terminal.width
98
154
  end
99
155
 
100
156
  ESC = "\e"
157
+ BACKSPACE = "\u007F"
158
+ CTRL_C = "\u0003"
159
+ CTRL_D = "\u0004"
101
160
 
102
161
  def up
103
- min_pos = @multiple ? 0 : 1
104
- @active = @active - 1 >= min_pos ? @active - 1 : @options.length
162
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
163
+
164
+ previous_visible = @filtered_options[active_index - 1]
165
+ previous_visible ||= @filtered_options.last
166
+
167
+ @active = previous_visible ? previous_visible.last : -1
105
168
  @redraw = true
106
169
  end
107
170
 
108
171
  def down
109
- min_pos = @multiple ? 0 : 1
110
- @active = @active + 1 <= @options.length ? @active + 1 : min_pos
172
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
173
+
174
+ next_visible = @filtered_options[active_index + 1]
175
+ next_visible ||= @filtered_options.first
176
+
177
+ @active = next_visible ? next_visible.last : -1
111
178
  @redraw = true
112
179
  end
113
180
 
@@ -141,7 +208,36 @@ module CLI
141
208
  @redraw = true
142
209
  end
143
210
 
211
+ def build_selection(char)
212
+ @active = (@active.to_s + char).to_i
213
+ @redraw = true
214
+ end
215
+
216
+ def chop_selection
217
+ @active = @active.to_s.chop.to_i
218
+ @redraw = true
219
+ end
220
+
221
+ def update_search(char)
222
+ @redraw = true
223
+
224
+ # Control+D or Backspace on empty search closes search
225
+ if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE))
226
+ @filter = ''
227
+ @state = :root
228
+ return
229
+ end
230
+
231
+ if char == BACKSPACE
232
+ @filter.chop!
233
+ else
234
+ @filter += char
235
+ end
236
+ end
237
+
144
238
  def select_current
239
+ # Prevent selection of invisible options
240
+ return unless presented_options.any? { |_, num| num == @active }
145
241
  select_n(@active)
146
242
  end
147
243
 
@@ -150,64 +246,130 @@ module CLI
150
246
  wait_for_user_input until @redraw
151
247
  end
152
248
 
153
- # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
249
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
154
250
  def wait_for_user_input
155
251
  char = read_char
252
+ @last_char = char
253
+
254
+ case char
255
+ when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
256
+ when CTRL_C ; raise Interrupt
257
+ end
258
+
259
+ max_digit = [@options.size, 9].min.to_s
156
260
  case @state
157
261
  when :root
158
262
  case char
159
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
160
- when ESC ; @state = :esc
161
- when 'k' ; up
162
- when 'j' ; down
163
- when '0' ; select_n(char.to_i)
164
- when ('1'..@options.size.to_s) ; select_n(char.to_i)
165
- when 'y', 'n' ; select_bool(char)
166
- when " ", "\r", "\n" ; select_current # <enter>
167
- when "\u0003" ; raise Interrupt # Ctrl-c
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>
271
+ end
272
+ when :filter
273
+ case char
274
+ when ESC ; @state = :esc
275
+ when "\r", "\n" ; select_current
276
+ when "\b" ; update_search(BACKSPACE) # Happens on Windows
277
+ else ; update_search(char)
278
+ end
279
+ when :line_select
280
+ case char
281
+ when ESC ; @state = :esc
282
+ when 'k' ; up ; @state = :root
283
+ when 'j' ; down ; @state = :root
284
+ when 'e', ':', 'G', 'q' ; stop_line_select
285
+ when '0'..'9' ; build_selection(char)
286
+ when BACKSPACE ; chop_selection # Pop last input on backspace
287
+ when ' ', "\r", "\n" ; select_current
168
288
  end
169
289
  when :esc
170
290
  case char
171
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
172
291
  when '[' ; @state = :esc_bracket
173
292
  else ; raise Interrupt # unhandled escape sequence.
174
293
  end
175
294
  when :esc_bracket
176
- @state = :root
295
+ @state = has_filter? ? :filter : :root
177
296
  case char
178
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
179
297
  when 'A' ; up
180
298
  when 'B' ; down
299
+ when 'C' ; # Ignore right key
300
+ when 'D' ; # Ignore left key
181
301
  else ; raise Interrupt # unhandled escape sequence.
182
302
  end
183
303
  end
184
304
  end
185
305
  # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
186
306
 
187
- def read_char
188
- raw_tty! do
189
- getc = $stdin.getc
190
- getc ? getc.chr : :timeout
191
- end
192
- rescue IOError
193
- "\e"
307
+ def selecting?
308
+ @state == :line_select
309
+ end
310
+
311
+ def filtering?
312
+ @state == :filter
313
+ end
314
+
315
+ def has_filter?
316
+ !@filter.empty?
317
+ end
318
+
319
+ def start_filter
320
+ @state = :filter
321
+ @redraw = true
322
+ end
323
+
324
+ def start_line_select
325
+ @state = :line_select
326
+ @active = 0
327
+ @redraw = true
194
328
  end
195
329
 
196
- def raw_tty!
197
- if ENV['TEST'] || !$stdin.tty?
198
- yield
330
+ def stop_line_select
331
+ @state = :root
332
+ @active = 1 if @active.zero?
333
+ @redraw = true
334
+ end
335
+
336
+ def read_char
337
+ if $stdin.tty? && !ENV['TEST']
338
+ $stdin.getch # raw mode for tty
199
339
  else
200
- $stdin.raw { yield }
340
+ $stdin.getc
201
341
  end
342
+ rescue IOError
343
+ "\e"
202
344
  end
203
345
 
204
346
  def presented_options(recalculate: false)
205
347
  return @presented_options unless recalculate
206
348
 
207
349
  @presented_options = @options.zip(1..Float::INFINITY)
208
- @presented_options.unshift([DONE, 0]) if @multiple
350
+ if has_filter?
351
+ @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
352
+ end
353
+
354
+ # Used for selection purposes
355
+ @presented_options.push([DONE, 0]) if @multiple
356
+ @filtered_options = @presented_options.dup
357
+
358
+ ensure_visible_is_active if has_filter?
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
209
371
 
210
- while num_lines > max_options
372
+ while num_lines > max_lines
211
373
  # try to keep the selection centered in the window:
212
374
  if distance_from_selection_to_end > distance_from_start_to_selection
213
375
  # selection is closer to top than bottom, so trim a row from the bottom
@@ -223,57 +385,99 @@ module CLI
223
385
  @presented_options
224
386
  end
225
387
 
388
+ def ensure_visible_is_active
389
+ unless presented_options.any? { |_, num| num == @active }
390
+ @active = presented_options.first&.last.to_i
391
+ end
392
+ end
393
+
226
394
  def distance_from_selection_to_end
227
- last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last
228
- last_visible_option_number - @active
395
+ @presented_options.count - index_of_active_option
229
396
  end
230
397
 
231
398
  def distance_from_start_to_selection
232
- first_visible_option_number = @presented_options[0].last || @presented_options[1].last
233
- @active - first_visible_option_number
399
+ index_of_active_option
400
+ end
401
+
402
+ def index_of_active_option
403
+ @presented_options.index { |_, num| num == @active }.to_i
234
404
  end
235
405
 
236
406
  def ensure_last_item_is_continuation_marker
237
- @presented_options.push(["...", nil]) if @presented_options.last.last
407
+ @presented_options.push(['...', nil]) if @presented_options.last.last
238
408
  end
239
409
 
240
410
  def ensure_first_item_is_continuation_marker
241
- @presented_options.unshift(["...", nil]) if @presented_options.first.last
411
+ @presented_options.unshift(['...', nil]) if @presented_options.first.last
242
412
  end
243
413
 
244
- def max_options
245
- @max_options ||= CLI::UI::Terminal.height - 2 # Keeps a one line question visible
414
+ def max_lines
415
+ CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
246
416
  end
247
417
 
248
418
  def render_options
419
+ previously_displayed_lines = num_lines
420
+
421
+ @displaying_metadata = display_metadata?
422
+
423
+ options = presented_options(recalculate: true)
424
+
425
+ clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines
426
+
249
427
  max_num_length = (@options.size + 1).to_s.length
250
428
 
251
- presented_options(recalculate: true).each do |choice, num|
252
- is_chosen = @multiple && num && @chosen[num - 1]
429
+ metadata_text = if selecting?
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
438
+
439
+ if metadata_text
440
+ CLI::UI.with_frame_color(:blue) do
441
+ puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}")
442
+ end
443
+ end
444
+
445
+ options.each do |choice, num|
446
+ is_chosen = @multiple && num && @chosen[num - 1] && num != 0
253
447
 
254
448
  padding = ' ' * (max_num_length - num.to_s.length)
255
- message = " #{num}#{num ? '.' : ' '}#{padding}"
449
+ message = " #{num}#{num ? "." : " "}#{padding}"
256
450
 
257
- format = "%s"
451
+ format = '%s'
258
452
  # If multiple, bold only selected. If not multiple, bold everything
259
453
  format = "{{bold:#{format}}}" if !@multiple || is_chosen
260
454
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
261
455
  format = " #{format}"
262
456
 
263
- message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
264
- message += choice.split("\n").map { |l| sprintf(format, l) }.join("\n")
457
+ message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
458
+ message += format_choice(format, choice)
265
459
 
266
460
  if num == @active
267
- message = message.split("\n").map.with_index do |l, idx|
268
- idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
269
- end.join("\n")
461
+
462
+ color = filtering? || selecting? ? 'green' : 'blue'
463
+ message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
270
464
  end
271
465
 
272
466
  CLI::UI.with_frame_color(:blue) do
273
- puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line
467
+ puts CLI::UI.fmt(message)
274
468
  end
275
469
  end
276
470
  end
471
+
472
+ def format_choice(format, choice)
473
+ eol = CLI::UI::ANSI.clear_to_end_of_line
474
+ lines = choice.split("\n")
475
+
476
+ return eol if lines.empty? # Handle blank options
477
+
478
+ lines.map! { |l| format(format, l) + eol }
479
+ lines.join("\n")
480
+ end
277
481
  end
278
482
  end
279
483
  end