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.
data/lib/cli/ui/prompt.rb CHANGED
@@ -1,4 +1,7 @@
1
1
  # coding: utf-8
2
+
3
+ # typed: true
4
+
2
5
  require 'cli/ui'
3
6
  require 'readline'
4
7
 
@@ -22,9 +25,26 @@ module CLI
22
25
  module Prompt
23
26
  autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
24
27
  autoload :OptionsHandler, 'cli/ui/prompt/options_handler'
25
- private_constant :InteractiveOptions, :OptionsHandler
26
28
 
27
29
  class << self
30
+ extend T::Sig
31
+
32
+ sig { returns(Color) }
33
+ def instructions_color
34
+ @instructions_color ||= Color::YELLOW
35
+ end
36
+
37
+ # Set the instructions color.
38
+ #
39
+ # ==== Attributes
40
+ #
41
+ # * +color+ - the color to use for prompt instructions
42
+ #
43
+ sig { params(color: Colorable).void }
44
+ def instructions_color=(color)
45
+ @instructions_color = CLI::UI.resolve_color(color)
46
+ end
47
+
28
48
  # Ask a user a question with either free form answer or a set of answers (multiple choice)
29
49
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control multiple choice selection
30
50
  # Do not use this method for yes/no questions. Use +confirm+
@@ -92,26 +112,52 @@ module CLI
92
112
  # handler.option('python') { |selection| selection }
93
113
  # end
94
114
  #
115
+ sig do
116
+ params(
117
+ question: String,
118
+ options: T.nilable(T::Array[String]),
119
+ default: T.nilable(T.any(String, T::Array[String])),
120
+ is_file: T::Boolean,
121
+ allow_empty: T::Boolean,
122
+ multiple: T::Boolean,
123
+ filter_ui: T::Boolean,
124
+ select_ui: T::Boolean,
125
+ options_proc: T.nilable(T.proc.params(handler: OptionsHandler).void),
126
+ ).returns(T.any(String, T::Array[String]))
127
+ end
95
128
  def ask(
96
129
  question,
97
130
  options: nil,
98
131
  default: nil,
99
- is_file: nil,
132
+ is_file: false,
100
133
  allow_empty: true,
101
134
  multiple: false,
102
135
  filter_ui: true,
103
136
  select_ui: true,
104
137
  &options_proc
105
138
  )
106
- if (options || block_given?) && ((default && !multiple) || is_file)
107
- raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
139
+ has_options = !!(options || block_given?)
140
+ if has_options && default && !multiple
141
+ raise(ArgumentError, 'conflicting arguments: default may not be provided with options when not multiple')
108
142
  end
109
143
 
110
- if options && multiple && default && !(default - options).empty?
144
+ if has_options && is_file
145
+ raise(ArgumentError, 'conflicting arguments: is_file is only useful when options are not provided')
146
+ end
147
+
148
+ if options && multiple && default && !(Array(default) - options).empty?
111
149
  raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
112
150
  end
113
151
 
114
- if options || block_given?
152
+ if multiple && !has_options
153
+ raise(ArgumentError, 'conflicting arguments: options must be provided when multiple is true')
154
+ end
155
+
156
+ if !multiple && default.is_a?(Array)
157
+ raise(ArgumentError, 'conflicting arguments: multiple defaults may only be provided when multiple is true')
158
+ end
159
+
160
+ if has_options
115
161
  ask_interactive(
116
162
  question,
117
163
  options,
@@ -122,7 +168,7 @@ module CLI
122
168
  &options_proc
123
169
  )
124
170
  else
125
- ask_free_form(question, default, is_file, allow_empty)
171
+ ask_free_form(question, T.cast(default, T.nilable(String)), is_file, allow_empty)
126
172
  end
127
173
  end
128
174
 
@@ -133,21 +179,22 @@ module CLI
133
179
  #
134
180
  # The password, without a trailing newline.
135
181
  # If the user simply presses "Enter" without typing any password, this will return an empty string.
182
+ sig { params(question: String).returns(String) }
136
183
  def ask_password(question)
137
184
  require 'io/console'
138
185
 
139
- CLI::UI.with_frame_color(:blue) do
140
- STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
186
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
187
+ $stdout.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
141
188
 
142
189
  # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
143
190
  # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
144
- password = STDIN.noecho do
191
+ password = $stdin.noecho do
145
192
  # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
146
193
  # " 123 \n".chomp => " 123 "
147
- STDIN.gets.chomp
194
+ $stdin.gets.to_s.chomp
148
195
  end
149
196
 
150
- STDOUT.puts # Complete the line
197
+ $stdout.puts # Complete the line
151
198
 
152
199
  password
153
200
  end
@@ -163,38 +210,85 @@ module CLI
163
210
  #
164
211
  # CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
165
212
  #
213
+ sig { params(question: String, default: T::Boolean).returns(T::Boolean) }
166
214
  def confirm(question, default: true)
167
- ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
215
+ ask_interactive(question, default ? ['yes', 'no'] : ['no', 'yes'], filter_ui: false) == 'yes'
216
+ end
217
+
218
+ # Present the user with a message and wait for any key to be pressed, returning the pressed key.
219
+ #
220
+ # ==== Example Usage:
221
+ #
222
+ # CLI::UI::Prompt.any_key # Press any key to continue...
223
+ #
224
+ # CLI::UI::Prompt.any_key('Press RETURN to continue...') # Then check if that's what they pressed
225
+ sig { params(prompt: String).returns(T.nilable(String)) }
226
+ def any_key(prompt = 'Press any key to continue...')
227
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
228
+ puts_question(prompt)
229
+ read_char
230
+ end
231
+ end
232
+
233
+ # Wait for any key to be pressed, returning the pressed key.
234
+ sig { returns(T.nilable(String)) }
235
+ def read_char
236
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
237
+ if $stdin.tty? && !ENV['TEST']
238
+ require 'io/console'
239
+ $stdin.getch # raw mode for tty
240
+ else
241
+ $stdin.getc # returns nil at end of input
242
+ end
243
+ end
244
+ rescue Errno::EIO, Errno::EPIPE, IOError
245
+ "\e"
168
246
  end
169
247
 
170
248
  private
171
249
 
250
+ sig do
251
+ params(question: String, default: T.nilable(String), is_file: T::Boolean, allow_empty: T::Boolean)
252
+ .returns(String)
253
+ end
172
254
  def ask_free_form(question, default, is_file, allow_empty)
173
255
  if default && !allow_empty
174
256
  raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
175
257
  end
176
258
 
177
- if default
178
- puts_question("#{question} (empty = #{default})")
179
- else
180
- puts_question(question)
181
- end
259
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
260
+ if default
261
+ puts_question("#{question} (empty = #{default})")
262
+ else
263
+ puts_question(question)
264
+ end
182
265
 
183
- # Ask a free form question
184
- loop do
185
- line = readline(is_file: is_file)
266
+ # Ask a free form question
267
+ loop do
268
+ line = readline(is_file: is_file)
186
269
 
187
- if line.empty? && default
188
- write_default_over_empty_input(default)
189
- return default
190
- end
270
+ if line.empty? && default
271
+ write_default_over_empty_input(default)
272
+ return default
273
+ end
191
274
 
192
- if !line.empty? || allow_empty
193
- return line
275
+ if !line.empty? || allow_empty
276
+ return line
277
+ end
194
278
  end
195
279
  end
196
280
  end
197
281
 
282
+ sig do
283
+ params(
284
+ question: String,
285
+ options: T.nilable(T::Array[String]),
286
+ multiple: T::Boolean,
287
+ default: T.nilable(T.any(String, T::Array[String])),
288
+ filter_ui: T::Boolean,
289
+ select_ui: T::Boolean,
290
+ ).returns(T.any(String, T::Array[String]))
291
+ end
198
292
  def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
199
293
  raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
200
294
 
@@ -205,7 +299,8 @@ module CLI
205
299
  end
206
300
 
207
301
  raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
208
- navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
302
+
303
+ navigate_text = if CLI::UI::OS.current.suggest_arrow_keys?
209
304
  'Choose with ↑ ↓ ⏎'
210
305
  else
211
306
  "Navigate up with 'k' and down with 'j', press Enter to select"
@@ -214,55 +309,70 @@ module CLI
214
309
  instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
215
310
  instructions += ", filter with 'f'" if filter_ui
216
311
  instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
217
- puts_question("#{question} {{yellow:(#{instructions})}}")
218
- resp = interactive_prompt(options, multiple: multiple, default: default)
219
-
220
- # Clear the line
221
- print(ANSI.previous_line + ANSI.clear_to_end_of_line)
222
- # Force StdoutRouter to prefix
223
- print(ANSI.previous_line + "\n")
224
-
225
- # reset the question to include the answer
226
- resp_text = resp
227
- if multiple
228
- resp_text = case resp.size
229
- when 0
230
- '<nothing>'
231
- when 1..2
232
- resp.join(' and ')
312
+
313
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
314
+ puts_question("#{question} " + instructions_color.code + "(#{instructions})" + Color::RESET.code)
315
+ resp = interactive_prompt(options, multiple: multiple, default: default)
316
+
317
+ # Clear the line
318
+ print(ANSI.previous_line + ANSI.clear_to_end_of_line)
319
+ # Force StdoutRouter to prefix
320
+ print(ANSI.previous_line + "\n")
321
+
322
+ # reset the question to include the answer
323
+ resp_text = case resp
324
+ when Array
325
+ case resp.size
326
+ when 0
327
+ '<nothing>'
328
+ when 1..2
329
+ resp.join(' and ')
330
+ else
331
+ "#{resp.size} items"
332
+ end
233
333
  else
234
- "#{resp.size} items"
334
+ resp
235
335
  end
236
- end
237
- puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
336
+ puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
238
337
 
239
- return handler.call(resp) if block_given?
240
- resp
338
+ if block_given?
339
+ T.must(handler).call(resp)
340
+ else
341
+ resp
342
+ end
343
+ end
241
344
  end
242
345
 
243
346
  # Useful for stubbing in tests
347
+ sig do
348
+ params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(T::Array[String], String)))
349
+ .returns(T.any(T::Array[String], String))
350
+ end
244
351
  def interactive_prompt(options, multiple: false, default: nil)
245
- InteractiveOptions.call(options, multiple: multiple, default: default)
352
+ CLI::UI::StdoutRouter::Capture.in_alternate_screen do
353
+ InteractiveOptions.call(options, multiple: multiple, default: default)
354
+ end
246
355
  end
247
356
 
357
+ sig { params(default: String).void }
248
358
  def write_default_over_empty_input(default)
249
359
  CLI::UI.raw do
250
- STDERR.puts(
360
+ $stderr.puts(
251
361
  CLI::UI::ANSI.cursor_up(1) +
252
362
  "\r" +
253
363
  CLI::UI::ANSI.cursor_forward(4) + # TODO: width
254
364
  default +
255
- CLI::UI::Color::RESET.code
365
+ CLI::UI::Color::RESET.code,
256
366
  )
257
367
  end
258
368
  end
259
369
 
370
+ sig { params(str: String).void }
260
371
  def puts_question(str)
261
- CLI::UI.with_frame_color(:blue) do
262
- STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
263
- end
372
+ $stdout.puts(CLI::UI.fmt('{{?}} ' + str))
264
373
  end
265
374
 
375
+ sig { params(is_file: T::Boolean).returns(String) }
266
376
  def readline(is_file: false)
267
377
  if is_file
268
378
  Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
@@ -276,18 +386,18 @@ module CLI
276
386
  # work. We could work around this by having CLI::UI use a pipe and a
277
387
  # thread to manage output, but the current strategy feels like a
278
388
  # better tradeoff.
279
- prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
389
+ prefix = CLI::UI::Frame.prefix
280
390
  # If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
281
391
  # not change the colour here.
282
392
  prompt = prefix + CLI::UI.fmt('{{blue:> }}')
283
- prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
393
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.use_color_prompt?
284
394
 
285
395
  begin
286
396
  line = Readline.readline(prompt, true)
287
397
  print(CLI::UI::Color::RESET.code)
288
398
  line.to_s.chomp
289
399
  rescue Interrupt
290
- CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
400
+ CLI::UI.raw { $stderr.puts('^C' + CLI::UI::Color::RESET.code) }
291
401
  raise
292
402
  end
293
403
  end
@@ -0,0 +1,169 @@
1
+ # typed: ignore
2
+ # frozen_string_literal: true
3
+
4
+ module T
5
+ class << self
6
+ def absurd(value); end
7
+ def all(type_a, type_b, *types); end
8
+ def any(type_a, type_b, *types); end
9
+ def attached_class; end
10
+ def class_of(klass); end
11
+ def enum(values); end
12
+ def nilable(type); end
13
+ def noreturn; end
14
+ def self_type; end
15
+ def type_alias(type = nil, &_blk); end
16
+ def type_parameter(name); end
17
+ def untyped; end
18
+
19
+ def assert_type!(value, _type, _checked: true)
20
+ value
21
+ end
22
+
23
+ def cast(value, _type, _checked: true)
24
+ value
25
+ end
26
+
27
+ def let(value, _type, _checked: true)
28
+ value
29
+ end
30
+
31
+ def must(arg, _msg = nil)
32
+ arg
33
+ end
34
+
35
+ def proc
36
+ T::Proc.new
37
+ end
38
+
39
+ def reveal_type(value)
40
+ value
41
+ end
42
+
43
+ def unsafe(value)
44
+ value
45
+ end
46
+ end
47
+
48
+ module Sig
49
+ def sig(arg0 = nil, &blk); end
50
+ end
51
+
52
+ module Helpers
53
+ def abstract!; end
54
+ def interface!; end
55
+ def final!; end
56
+ def sealed!; end
57
+ def mixes_in_class_methods(mod); end
58
+ end
59
+
60
+ module Generic
61
+ include(T::Helpers)
62
+
63
+ def type_parameters(*params); end
64
+ def type_member(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
65
+ def type_template(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
66
+
67
+ def [](*types)
68
+ self
69
+ end
70
+ end
71
+
72
+ module Array
73
+ class << self
74
+ def [](type); end
75
+ end
76
+ end
77
+
78
+ Boolean = Object.new.freeze
79
+
80
+ module Configuration
81
+ class << self
82
+ def call_validation_error_handler(signature, opts); end
83
+ def call_validation_error_handler=(value); end
84
+ def default_checked_level=(default_checked_level); end
85
+ def enable_checking_for_sigs_marked_checked_tests; end
86
+ def enable_final_checks_on_hooks; end
87
+ def enable_legacy_t_enum_migration_mode; end
88
+ def reset_final_checks_on_hooks; end
89
+ def hard_assert_handler(str, extra); end
90
+ def hard_assert_handler=(value); end
91
+ def inline_type_error_handler(error); end
92
+ def inline_type_error_handler=(value); end
93
+ def log_info_handler(str, extra); end
94
+ def log_info_handler=(value); end
95
+ def scalar_types; end
96
+ def scalar_types=(values); end
97
+ # rubocop:disable Naming/InclusiveLanguage
98
+ def sealed_violation_whitelist; end
99
+ def sealed_violation_whitelist=(sealed_violation_whitelist); end
100
+ # rubocop:enable Naming/InclusiveLanguage
101
+ def sig_builder_error_handler(error, location); end
102
+ def sig_builder_error_handler=(value); end
103
+ def sig_validation_error_handler(error, opts); end
104
+ def sig_validation_error_handler=(value); end
105
+ def soft_assert_handler(str, extra); end
106
+ def soft_assert_handler=(value); end
107
+ end
108
+ end
109
+
110
+ module Enumerable
111
+ class << self
112
+ def [](type); end
113
+ end
114
+ end
115
+
116
+ module Enumerator
117
+ class << self
118
+ def [](type); end
119
+ end
120
+ end
121
+
122
+ module Hash
123
+ class << self
124
+ def [](keys, values); end
125
+ end
126
+ end
127
+
128
+ class Proc
129
+ def bind(*_)
130
+ self
131
+ end
132
+
133
+ def params(*_param)
134
+ self
135
+ end
136
+
137
+ def void
138
+ self
139
+ end
140
+
141
+ def returns(_type)
142
+ self
143
+ end
144
+ end
145
+
146
+ module Range
147
+ class << self
148
+ def [](type); end
149
+ end
150
+ end
151
+
152
+ module Set
153
+ class << self
154
+ def [](type); end
155
+ end
156
+ end
157
+
158
+ class << self
159
+ def const_added(name)
160
+ super
161
+ raise 'When using both cli-ui and sorbet, you must require sorbet before cli-ui'
162
+ end
163
+
164
+ def method_added(name)
165
+ super
166
+ raise 'When using both cli-ui and sorbet, you must require sorbet before cli-ui'
167
+ end
168
+ end
169
+ end
@@ -1,11 +1,20 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Spinner
4
6
  class Async
5
- # Convenience method for +initialize+
6
- #
7
- def self.start(title)
8
- new(title)
7
+ extend T::Sig
8
+
9
+ class << self
10
+ extend T::Sig
11
+
12
+ # Convenience method for +initialize+
13
+ #
14
+ sig { params(title: String).returns(Async) }
15
+ def start(title)
16
+ new(title)
17
+ end
9
18
  end
10
19
 
11
20
  # Initializes a new asynchronous spinner with no specific end.
@@ -19,6 +28,7 @@ module CLI
19
28
  #
20
29
  # CLI::UI::Spinner::Async.new('Title')
21
30
  #
31
+ sig { params(title: String).void }
22
32
  def initialize(title)
23
33
  require 'thread'
24
34
  sg = CLI::UI::Spinner::SpinGroup.new
@@ -30,6 +40,7 @@ module CLI
30
40
 
31
41
  # Stops an asynchronous spinner
32
42
  #
43
+ sig { returns(T::Boolean) }
33
44
  def stop
34
45
  @m.synchronize { @cv.signal }
35
46
  @t.value