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