cli-ui 1.5.1 → 2.2.3

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,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