cli-ui 1.3.0 → 1.4.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.
@@ -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