cli-ui 1.2.1 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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