cli-ui 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,63 @@
1
+ module CLI
2
+ module UI
3
+ module OS
4
+ # Determines which OS is currently running the UI, to make it easier to
5
+ # adapt its behaviour to the features of the OS.
6
+ def self.current
7
+ @current_os ||= case RUBY_PLATFORM
8
+ when /darwin/
9
+ Mac
10
+ when /linux/
11
+ Linux
12
+ when /mingw32/
13
+ Windows
14
+ else
15
+ raise "Could not determine OS from platform #{RUBY_PLATFORM}"
16
+ end
17
+ end
18
+
19
+ class Mac
20
+ class << self
21
+ def supports_emoji?
22
+ true
23
+ end
24
+
25
+ def supports_color_prompt?
26
+ true
27
+ end
28
+
29
+ def supports_arrow_keys?
30
+ true
31
+ end
32
+
33
+ def shift_cursor_on_line_reset?
34
+ false
35
+ end
36
+ end
37
+ end
38
+
39
+ class Linux < Mac
40
+ end
41
+
42
+ class Windows
43
+ class << self
44
+ def supports_emoji?
45
+ false
46
+ end
47
+
48
+ def supports_color_prompt?
49
+ false
50
+ end
51
+
52
+ def supports_arrow_keys?
53
+ false
54
+ end
55
+
56
+ def shift_cursor_on_line_reset?
57
+ true
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,47 @@
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
+ #
22
+ # ==== Returns
23
+ # Returns whether the message was successfully printed,
24
+ # which can be useful if +:graceful+ is set to true.
25
+ #
26
+ # ==== Example
27
+ #
28
+ # CLI::UI::Printer.puts('{x} Ouch', stream: $stderr, color: :red)
29
+ #
30
+ def self.puts(msg, frame_color: nil, to: $stdout, encoding: Encoding::UTF_8, format: true, graceful: true)
31
+ msg = (+msg).force_encoding(encoding) if encoding
32
+ msg = CLI::UI.fmt(msg) if format
33
+
34
+ if frame_color
35
+ CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
36
+ else
37
+ to.puts(msg)
38
+ end
39
+
40
+ true
41
+ rescue Errno::EIO, Errno::EPIPE, IOError => e
42
+ raise(e) unless graceful
43
+ false
44
+ end
45
+ end
46
+ end
47
+ 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
@@ -36,7 +36,8 @@ module CLI
36
36
  # * +:select_ui+ - Enable long-form option selection (default: true)
37
37
  #
38
38
  # Note:
39
- # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
39
+ # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
40
+ # you cannot set options with either of these keywords
40
41
  # * +:default+ conflicts with +:allow_empty:, you cannot set these together
41
42
  # * +:options+ conflicts with providing a +Block+ , you may only set one
42
43
  # * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
@@ -49,7 +50,7 @@ module CLI
49
50
  # ==== Return Value
50
51
  #
51
52
  # * 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
53
+ # * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
53
54
  #
54
55
  # ==== Example Usage:
55
56
  #
@@ -76,13 +77,35 @@ module CLI
76
77
  # handler.option('python') { |selection| selection }
77
78
  # end
78
79
  #
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))
80
+ def ask(
81
+ question,
82
+ options: nil,
83
+ default: nil,
84
+ is_file: nil,
85
+ allow_empty: true,
86
+ multiple: false,
87
+ filter_ui: true,
88
+ select_ui: true,
89
+ &options_proc
90
+ )
91
+ if (options || block_given?) && ((default && !multiple) || is_file)
81
92
  raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
82
93
  end
83
94
 
95
+ if options && multiple && default && !(default - options).empty?
96
+ raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
97
+ end
98
+
84
99
  if options || block_given?
85
- ask_interactive(question, options, multiple: multiple, filter_ui: filter_ui, select_ui: select_ui, &options_proc)
100
+ ask_interactive(
101
+ question,
102
+ options,
103
+ multiple: multiple,
104
+ default: default,
105
+ filter_ui: filter_ui,
106
+ select_ui: select_ui,
107
+ &options_proc
108
+ )
86
109
  else
87
110
  ask_free_form(question, default, is_file, allow_empty)
88
111
  end
@@ -132,7 +155,9 @@ module CLI
132
155
  private
133
156
 
134
157
  def ask_free_form(question, default, is_file, allow_empty)
135
- raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false') if (default && !allow_empty)
158
+ if default && !allow_empty
159
+ raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
160
+ end
136
161
 
137
162
  if default
138
163
  puts_question("#{question} (empty = #{default})")
@@ -155,7 +180,7 @@ module CLI
155
180
  end
156
181
  end
157
182
 
158
- def ask_interactive(question, options = nil, multiple: false, filter_ui: true, select_ui: true)
183
+ def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
159
184
  raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
160
185
 
161
186
  options ||= if block_given?
@@ -165,16 +190,22 @@ module CLI
165
190
  end
166
191
 
167
192
  raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
168
- instructions = (multiple ? "Toggle options. " : "") + "Choose with ↑ ↓ ⏎"
193
+ navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
194
+ "Choose with ↑ ↓ ⏎"
195
+ else
196
+ "Navigate up with 'k' and down with 'j', press Enter to select"
197
+ end
198
+
199
+ instructions = (multiple ? "Toggle options. " : "") + navigate_text
169
200
  instructions += ", filter with 'f'" if filter_ui
170
- instructions += ", enter option with 'e'" if select_ui and options.size > 9
201
+ instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
171
202
  puts_question("#{question} {{yellow:(#{instructions})}}")
172
- resp = interactive_prompt(options, multiple: multiple)
203
+ resp = interactive_prompt(options, multiple: multiple, default: default)
173
204
 
174
205
  # Clear the line
175
- print ANSI.previous_line + ANSI.clear_to_end_of_line
206
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
176
207
  # Force StdoutRouter to prefix
177
- print ANSI.previous_line + "\n"
208
+ print(ANSI.previous_line + "\n")
178
209
 
179
210
  # reset the question to include the answer
180
211
  resp_text = resp
@@ -195,8 +226,8 @@ module CLI
195
226
  end
196
227
 
197
228
  # Useful for stubbing in tests
198
- def interactive_prompt(options, multiple: false)
199
- InteractiveOptions.call(options, multiple: multiple)
229
+ def interactive_prompt(options, multiple: false, default: nil)
230
+ InteractiveOptions.call(options, multiple: multiple, default: default)
200
231
  end
201
232
 
202
233
  def write_default_over_empty_input(default)
@@ -231,11 +262,14 @@ module CLI
231
262
  # thread to manage output, but the current strategy feels like a
232
263
  # better tradeoff.
233
264
  prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
234
- prompt = prefix + CLI::UI.fmt('{{blue:> }}') + CLI::UI::Color::YELLOW.code
265
+ # If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
266
+ # not change the colour here.
267
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}')
268
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
235
269
 
236
270
  begin
237
271
  line = Readline.readline(prompt, true)
238
- print CLI::UI::Color::RESET.code
272
+ print(CLI::UI::Color::RESET.code)
239
273
  line.to_s.chomp
240
274
  rescue Interrupt
241
275
  CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
@@ -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
@@ -347,16 +355,27 @@ module CLI
347
355
 
348
356
  @presented_options = @options.zip(1..Float::INFINITY)
349
357
  if has_filter?
350
- @presented_options.select! { |option,_| option.downcase.include?(@filter.downcase) }
358
+ @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
351
359
  end
352
360
 
353
361
  # Used for selection purposes
362
+ @presented_options.push([DONE, 0]) if @multiple
354
363
  @filtered_options = @presented_options.dup
355
364
 
356
- @presented_options.unshift([DONE, 0]) if @multiple
357
-
358
365
  ensure_visible_is_active if has_filter?
359
366
 
367
+ # Must have more lines before the selection than we can display
368
+ if distance_from_start_to_selection > max_lines
369
+ @presented_options.shift(distance_from_start_to_selection - max_lines)
370
+ ensure_first_item_is_continuation_marker
371
+ end
372
+
373
+ # Must have more lines after the selection than we can display
374
+ if distance_from_selection_to_end > max_lines
375
+ @presented_options.pop(distance_from_selection_to_end - max_lines)
376
+ ensure_last_item_is_continuation_marker
377
+ end
378
+
360
379
  while num_lines > max_lines
361
380
  # try to keep the selection centered in the window:
362
381
  if distance_from_selection_to_end > distance_from_start_to_selection
@@ -388,7 +407,7 @@ module CLI
388
407
  end
389
408
 
390
409
  def index_of_active_option
391
- @presented_options.index { |_,num| num == @active }.to_i
410
+ @presented_options.index { |_, num| num == @active }.to_i
392
411
  end
393
412
 
394
413
  def ensure_last_item_is_continuation_marker
@@ -415,14 +434,14 @@ module CLI
415
434
  max_num_length = (@options.size + 1).to_s.length
416
435
 
417
436
  metadata_text = if selecting?
418
- select_text = @active
419
- select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
420
- "Select: #{select_text}"
421
- elsif filtering? or has_filter?
422
- filter_text = @filter
423
- filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
424
- "Filter: #{filter_text}"
425
- end
437
+ select_text = @active
438
+ select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
439
+ "Select: #{select_text}"
440
+ elsif filtering? || has_filter?
441
+ filter_text = @filter
442
+ filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
443
+ "Filter: #{filter_text}"
444
+ end
426
445
 
427
446
  if metadata_text
428
447
  CLI::UI.with_frame_color(:blue) do
@@ -431,7 +450,7 @@ module CLI
431
450
  end
432
451
 
433
452
  options.each do |choice, num|
434
- is_chosen = @multiple && num && @chosen[num - 1]
453
+ is_chosen = @multiple && num && @chosen[num - 1] && num != 0
435
454
 
436
455
  padding = ' ' * (max_num_length - num.to_s.length)
437
456
  message = " #{num}#{num ? '.' : ' '}#{padding}"
@@ -442,12 +461,12 @@ module CLI
442
461
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
443
462
  format = " #{format}"
444
463
 
445
- message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
464
+ message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
446
465
  message += format_choice(format, choice)
447
466
 
448
467
  if num == @active
449
468
 
450
- color = (filtering? or selecting?) ? 'green' : 'blue'
469
+ color = filtering? || selecting? ? 'green' : 'blue'
451
470
  message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
452
471
  end
453
472
 
@@ -463,7 +482,7 @@ module CLI
463
482
 
464
483
  return eol if lines.empty? # Handle blank options
465
484
 
466
- lines.map! { |l| sprintf(format, l) + eol }
485
+ lines.map! { |l| format(format, l) + eol }
467
486
  lines.join("\n")
468
487
  end
469
488
  end