cli-ui 1.5.1 → 2.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,41 +1,54 @@
1
+ # typed: true
2
+
1
3
  require 'cli/ui'
2
4
 
3
5
  module CLI
4
6
  module UI
5
7
  class Progress
8
+ extend T::Sig
9
+
6
10
  # A Cyan filled block
7
11
  FILLED_BAR = "\e[46m"
8
12
  # A bright white block
9
13
  UNFILLED_BAR = "\e[1;47m"
10
14
 
11
- # Add a progress bar to the terminal output
12
- #
13
- # https://user-images.githubusercontent.com/3074765/33799794-cc4c940e-dd00-11e7-9bdc-90f77ec9167c.gif
14
- #
15
- # ==== Example Usage:
16
- #
17
- # Set the percent to X
18
- # CLI::UI::Progress.progress do |bar|
19
- # bar.tick(set_percent: percent)
20
- # end
21
- #
22
- # Increase the percent by 1 percent
23
- # CLI::UI::Progress.progress do |bar|
24
- # bar.tick
25
- # end
26
- #
27
- # Increase the percent by X
28
- # CLI::UI::Progress.progress do |bar|
29
- # bar.tick(percent: 0.05)
30
- # end
31
- def self.progress(width: Terminal.width)
32
- bar = Progress.new(width: width)
33
- print(CLI::UI::ANSI.hide_cursor)
34
- yield(bar)
35
- ensure
36
- puts bar.to_s
37
- CLI::UI.raw do
38
- print(ANSI.show_cursor)
15
+ class << self
16
+ extend T::Sig
17
+
18
+ # Add a progress bar to the terminal output
19
+ #
20
+ # https://user-images.githubusercontent.com/3074765/33799794-cc4c940e-dd00-11e7-9bdc-90f77ec9167c.gif
21
+ #
22
+ # ==== Example Usage:
23
+ #
24
+ # Set the percent to X
25
+ # CLI::UI::Progress.progress do |bar|
26
+ # bar.tick(set_percent: percent)
27
+ # end
28
+ #
29
+ # Increase the percent by 1 percent
30
+ # CLI::UI::Progress.progress do |bar|
31
+ # bar.tick
32
+ # end
33
+ #
34
+ # Increase the percent by X
35
+ # CLI::UI::Progress.progress do |bar|
36
+ # bar.tick(percent: 0.05)
37
+ # end
38
+ sig do
39
+ type_parameters(:T)
40
+ .params(width: Integer, block: T.proc.params(bar: Progress).returns(T.type_parameter(:T)))
41
+ .returns(T.type_parameter(:T))
42
+ end
43
+ def progress(width: Terminal.width, &block)
44
+ bar = Progress.new(width: width)
45
+ print(CLI::UI::ANSI.hide_cursor)
46
+ yield(bar)
47
+ ensure
48
+ puts(bar)
49
+ CLI::UI.raw do
50
+ print(ANSI.show_cursor)
51
+ end
39
52
  end
40
53
  end
41
54
 
@@ -46,8 +59,9 @@ module CLI
46
59
  #
47
60
  # * +:width+ - The width of the terminal
48
61
  #
62
+ sig { params(width: Integer).void }
49
63
  def initialize(width: Terminal.width)
50
- @percent_done = 0
64
+ @percent_done = T.let(0, Numeric)
51
65
  @max_width = width
52
66
  end
53
67
 
@@ -61,18 +75,21 @@ module CLI
61
75
  #
62
76
  # *Note:* The +:percent+ and +:set_percent must be between 0.00 and 1.0
63
77
  #
64
- def tick(percent: 0.01, set_percent: nil)
65
- raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
66
- @percent_done += percent
78
+ sig { params(percent: T.nilable(Numeric), set_percent: T.nilable(Numeric)).void }
79
+ def tick(percent: nil, set_percent: nil)
80
+ raise ArgumentError, 'percent and set_percent cannot both be specified' if percent && set_percent
81
+
82
+ @percent_done += percent || 0.01
67
83
  @percent_done = set_percent if set_percent
68
84
  @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
69
85
 
70
- print(to_s)
86
+ print(self)
71
87
  print(CLI::UI::ANSI.previous_line + "\n")
72
88
  end
73
89
 
74
90
  # Format the progress bar to be printed to terminal
75
91
  #
92
+ sig { returns(String) }
76
93
  def to_s
77
94
  suffix = " #{(@percent_done * 100).floor}%".ljust(5)
78
95
  workable_width = @max_width - Frame.prefix_width - suffix.size
@@ -1,34 +1,48 @@
1
1
  # coding: utf-8
2
+
3
+ # typed: true
4
+
2
5
  require 'io/console'
3
6
 
4
7
  module CLI
5
8
  module UI
6
9
  module Prompt
7
10
  class InteractiveOptions
11
+ extend T::Sig
12
+
8
13
  DONE = 'Done'
9
14
  CHECKBOX_ICON = { false => '☐', true => '☑' }
10
15
 
11
- # Prompts the user with options
12
- # Uses an interactive session to allow the user to pick an answer
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
17
- #
18
- # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
19
- #
20
- # ==== Example Usage:
21
- #
22
- # Ask an interactive question
23
- # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
24
- #
25
- def self.call(options, multiple: false, default: nil)
26
- list = new(options, multiple: multiple, default: default)
27
- selected = list.call
28
- if multiple
29
- selected.map { |s| options[s - 1] }
30
- else
31
- options[selected - 1]
16
+ class << self
17
+ extend T::Sig
18
+
19
+ # Prompts the user with options
20
+ # Uses an interactive session to allow the user to pick an answer
21
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
22
+ # For more than 9 options, hitting 'e', ':', or 'G' will enter select
23
+ # mode allowing the user to type in longer numbers
24
+ # Pressing 'f' or '/' will allow the user to filter the results
25
+ #
26
+ # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
27
+ #
28
+ # ==== Example Usage:
29
+ #
30
+ # Ask an interactive question
31
+ # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
32
+ #
33
+ sig do
34
+ params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String])))
35
+ .returns(T.any(String, T::Array[String]))
36
+ end
37
+ def call(options, multiple: false, default: nil)
38
+ list = new(options, multiple: multiple, default: default)
39
+ selected = list.call
40
+ case selected
41
+ when Array
42
+ selected.map { |s| T.must(options[s - 1]) }
43
+ else
44
+ T.must(options[selected - 1])
45
+ end
32
46
  end
33
47
  end
34
48
 
@@ -39,6 +53,10 @@ module CLI
39
53
  #
40
54
  # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
41
55
  #
56
+ sig do
57
+ params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String])))
58
+ .void
59
+ end
42
60
  def initialize(options, multiple: false, default: nil)
43
61
  @options = options
44
62
  @active = 1
@@ -60,12 +78,13 @@ module CLI
60
78
  end
61
79
  end
62
80
  @redraw = true
63
- @presented_options = []
81
+ @presented_options = T.let([], T::Array[[String, T.nilable(Integer)]])
64
82
  end
65
83
 
66
84
  # Calls the +InteractiveOptions+ and asks the question
67
85
  # Usually used from +self.call+
68
86
  #
87
+ sig { returns(T.any(Integer, T::Array[Integer])) }
69
88
  def call
70
89
  calculate_option_line_lengths
71
90
  CLI::UI.raw { print(ANSI.hide_cursor) }
@@ -85,31 +104,40 @@ module CLI
85
104
 
86
105
  private
87
106
 
107
+ sig { void }
88
108
  def calculate_option_line_lengths
89
109
  @terminal_width_at_calculation_time = CLI::UI::Terminal.width
90
110
  # options will be an array of questions but each option can be multi-line
91
111
  # so to get the # of lines, you need to join then split
92
112
 
93
113
  # 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
114
+ # determine how many extra lines would be taken up by them.
115
+ #
116
+ # To accomplish this we split the string by new lines and add the
117
+ # extra characters to the first line.
118
+ # Then we calculate how many lines would be needed to render the string
119
+ # based on the terminal width
120
+ # 3 = space before the number, the . after the number, the space after the .
121
+ # multiple check is for the space for the checkbox, if rendered
122
+ # options.count.to_s.size gets us the max size of the number we will display
123
+ extra_chars = @marker.length + 3 + @options.count.to_s.size + (@multiple ? 1 : 0)
100
124
 
101
125
  @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
126
+ next 1 if text.empty?
127
+
128
+ # Find the length of all the lines in this string
129
+ non_empty_line_lengths = text.split("\n").reject(&:empty?).map do |line|
130
+ CLI::UI.fmt(line, enable_color: false).length
131
+ end
132
+ # The first line has the marker and number, so we add that so we can take it into account
133
+ non_empty_line_lengths[0] += extra_chars
134
+ # Finally, we need to calculate how many lines each one will take. We can do that by dividing each one
135
+ # by the width of the terminal, rounding up to the nearest natural number
136
+ non_empty_line_lengths.sum { |length| (length.to_f / @terminal_width_at_calculation_time).ceil }
110
137
  end
111
138
  end
112
139
 
140
+ sig { params(number_of_lines: Integer).void }
113
141
  def reset_position(number_of_lines = num_lines)
114
142
  # This will put us back at the beginning of the options
115
143
  # When we redraw the options, they will be overwritten
@@ -118,6 +146,7 @@ module CLI
118
146
  end
119
147
  end
120
148
 
149
+ sig { params(number_of_lines: Integer).void }
121
150
  def clear_output(number_of_lines = num_lines)
122
151
  CLI::UI.raw do
123
152
  # Write over all lines with whitespace
@@ -133,22 +162,26 @@ module CLI
133
162
 
134
163
  # Don't use this in place of +@displaying_metadata+, this updates too
135
164
  # quickly to be useful when drawing to the screen.
165
+ sig { returns(T::Boolean) }
136
166
  def display_metadata?
137
167
  filtering? || selecting? || has_filter?
138
168
  end
139
169
 
170
+ sig { returns(Integer) }
140
171
  def num_lines
141
172
  calculate_option_line_lengths if terminal_width_changed?
142
173
 
143
174
  option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
144
175
  # Handle continuation markers and "Done" option when multiple is true
145
176
  next total_length + 1 if option_number.nil? || option_number.zero?
177
+
146
178
  total_length + @option_lengths[option_number - 1]
147
179
  end
148
180
 
149
181
  option_length + (@displaying_metadata ? 1 : 0)
150
182
  end
151
183
 
184
+ sig { returns(T::Boolean) }
152
185
  def terminal_width_changed?
153
186
  @terminal_width_at_calculation_time != CLI::UI::Terminal.width
154
187
  end
@@ -158,6 +191,7 @@ module CLI
158
191
  CTRL_C = "\u0003"
159
192
  CTRL_D = "\u0004"
160
193
 
194
+ sig { void }
161
195
  def up
162
196
  active_index = @filtered_options.index { |_, num| num == @active } || 0
163
197
 
@@ -168,6 +202,7 @@ module CLI
168
202
  @redraw = true
169
203
  end
170
204
 
205
+ sig { void }
171
206
  def down
172
207
  active_index = @filtered_options.index { |_, num| num == @active } || 0
173
208
 
@@ -180,6 +215,7 @@ module CLI
180
215
 
181
216
  # n is 1-indexed selection
182
217
  # n == 0 if "Done" was selected in @multiple mode
218
+ sig { params(n: Integer).void }
183
219
  def select_n(n)
184
220
  if @multiple
185
221
  if n == 0
@@ -200,24 +236,29 @@ module CLI
200
236
  @redraw = true
201
237
  end
202
238
 
239
+ sig { params(char: String).void }
203
240
  def select_bool(char)
204
- return unless (@options - %w(yes no)).empty?
205
- opt = @options.detect { |o| o.start_with?(char) }
206
- @active = @options.index(opt) + 1
207
- @answer = @options.index(opt) + 1
241
+ return unless (@options - ['yes', 'no']).empty?
242
+
243
+ index = T.must(@options.index { |o| o.start_with?(char) })
244
+ @active = index + 1
245
+ @answer = index + 1
208
246
  @redraw = true
209
247
  end
210
248
 
249
+ sig { params(char: String).void }
211
250
  def build_selection(char)
212
251
  @active = (@active.to_s + char).to_i
213
252
  @redraw = true
214
253
  end
215
254
 
255
+ sig { void }
216
256
  def chop_selection
217
257
  @active = @active.to_s.chop.to_i
218
258
  @redraw = true
219
259
  end
220
260
 
261
+ sig { params(char: String).void }
221
262
  def update_search(char)
222
263
  @redraw = true
223
264
 
@@ -235,25 +276,28 @@ module CLI
235
276
  end
236
277
  end
237
278
 
279
+ sig { void }
238
280
  def select_current
239
281
  # Prevent selection of invisible options
240
282
  return unless presented_options.any? { |_, num| num == @active }
283
+
241
284
  select_n(@active)
242
285
  end
243
286
 
287
+ sig { void }
244
288
  def process_input_until_redraw_required
245
289
  @redraw = false
246
290
  wait_for_user_input until @redraw
247
291
  end
248
292
 
249
293
  # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
294
+ sig { void }
250
295
  def wait_for_user_input
251
- char = read_char
296
+ char = Prompt.read_char
252
297
  @last_char = char
253
298
 
254
299
  case char
255
- when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
256
- when CTRL_C ; raise Interrupt
300
+ when CTRL_C, nil ; raise Interrupt
257
301
  end
258
302
 
259
303
  max_digit = [@options.size, 9].min.to_s
@@ -302,51 +346,48 @@ module CLI
302
346
  end
303
347
  end
304
348
  end
305
- # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
349
+ # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
306
350
 
351
+ sig { returns(T::Boolean) }
307
352
  def selecting?
308
353
  @state == :line_select
309
354
  end
310
355
 
356
+ sig { returns(T::Boolean) }
311
357
  def filtering?
312
358
  @state == :filter
313
359
  end
314
360
 
361
+ sig { returns(T::Boolean) }
315
362
  def has_filter?
316
363
  !@filter.empty?
317
364
  end
318
365
 
366
+ sig { void }
319
367
  def start_filter
320
368
  @state = :filter
321
369
  @redraw = true
322
370
  end
323
371
 
372
+ sig { void }
324
373
  def start_line_select
325
374
  @state = :line_select
326
375
  @active = 0
327
376
  @redraw = true
328
377
  end
329
378
 
379
+ sig { void }
330
380
  def stop_line_select
331
381
  @state = :root
332
382
  @active = 1 if @active.zero?
333
383
  @redraw = true
334
384
  end
335
385
 
336
- def read_char
337
- if $stdin.tty? && !ENV['TEST']
338
- $stdin.getch # raw mode for tty
339
- else
340
- $stdin.getc
341
- end
342
- rescue IOError
343
- "\e"
344
- end
345
-
386
+ sig { params(recalculate: T::Boolean).returns(T::Array[[String, T.nilable(Integer)]]) }
346
387
  def presented_options(recalculate: false)
347
388
  return @presented_options unless recalculate
348
389
 
349
- @presented_options = @options.zip(1..Float::INFINITY)
390
+ @presented_options = @options.zip(1..)
350
391
  if has_filter?
351
392
  @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
352
393
  end
@@ -385,36 +426,44 @@ module CLI
385
426
  @presented_options
386
427
  end
387
428
 
429
+ sig { void }
388
430
  def ensure_visible_is_active
389
431
  unless presented_options.any? { |_, num| num == @active }
390
432
  @active = presented_options.first&.last.to_i
391
433
  end
392
434
  end
393
435
 
436
+ sig { returns(Integer) }
394
437
  def distance_from_selection_to_end
395
438
  @presented_options.count - index_of_active_option
396
439
  end
397
440
 
441
+ sig { returns(Integer) }
398
442
  def distance_from_start_to_selection
399
443
  index_of_active_option
400
444
  end
401
445
 
446
+ sig { returns(Integer) }
402
447
  def index_of_active_option
403
448
  @presented_options.index { |_, num| num == @active }.to_i
404
449
  end
405
450
 
451
+ sig { void }
406
452
  def ensure_last_item_is_continuation_marker
407
- @presented_options.push(['...', nil]) if @presented_options.last.last
453
+ @presented_options.push(['...', nil]) if @presented_options.last&.last
408
454
  end
409
455
 
456
+ sig { void }
410
457
  def ensure_first_item_is_continuation_marker
411
- @presented_options.unshift(['...', nil]) if @presented_options.first.last
458
+ @presented_options.unshift(['...', nil]) if @presented_options.first&.last
412
459
  end
413
460
 
461
+ sig { returns(Integer) }
414
462
  def max_lines
415
463
  CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
416
464
  end
417
465
 
466
+ sig { void }
418
467
  def render_options
419
468
  previously_displayed_lines = num_lines
420
469
 
@@ -436,11 +485,7 @@ module CLI
436
485
  "Filter: #{filter_text}"
437
486
  end
438
487
 
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
488
+ puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}") if metadata_text
444
489
 
445
490
  options.each do |choice, num|
446
491
  is_chosen = @multiple && num && @chosen[num - 1] && num != 0
@@ -449,8 +494,10 @@ module CLI
449
494
  message = " #{num}#{num ? "." : " "}#{padding}"
450
495
 
451
496
  format = '%s'
452
- # If multiple, bold only selected. If not multiple, bold everything
453
- format = "{{bold:#{format}}}" if !@multiple || is_chosen
497
+ # If multiple, bold selected. If not multiple, do not bold any options.
498
+ # Bolding options can cause confusion as some users may perceive bold white (default color) as selected
499
+ # rather than the actual selected color.
500
+ format = "{{bold:#{format}}}" if @multiple && is_chosen
454
501
  format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
455
502
  format = " #{format}"
456
503
 
@@ -460,15 +507,14 @@ module CLI
460
507
  if num == @active
461
508
 
462
509
  color = filtering? || selecting? ? 'green' : 'blue'
463
- message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
510
+ message = message.split("\n").map { |l| "{{#{color}:#{@marker} #{l.strip}}}" }.join("\n")
464
511
  end
465
512
 
466
- CLI::UI.with_frame_color(:blue) do
467
- puts CLI::UI.fmt(message)
468
- end
513
+ puts CLI::UI.fmt(message)
469
514
  end
470
515
  end
471
516
 
517
+ sig { params(format: String, choice: String).returns(String) }
472
518
  def format_choice(format, choice)
473
519
  eol = CLI::UI::ANSI.clear_to_end_of_line
474
520
  lines = choice.split("\n")
@@ -1,20 +1,28 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Prompt
4
6
  # A class that handles the various options of an InteractivePrompt and their callbacks
5
7
  class OptionsHandler
8
+ extend T::Sig
9
+
10
+ sig { void }
6
11
  def initialize
7
12
  @options = {}
8
13
  end
9
14
 
15
+ sig { returns(T::Array[String]) }
10
16
  def options
11
17
  @options.keys
12
18
  end
13
19
 
20
+ sig { params(option: String, handler: T.proc.params(option: String).returns(String)).void }
14
21
  def option(option, &handler)
15
22
  @options[option] = handler
16
23
  end
17
24
 
25
+ sig { params(options: T.any(T::Array[String], String)).returns(String) }
18
26
  def call(options)
19
27
  case options
20
28
  when Array